diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index aad2580dad0..ad001bf0eea 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -82,6 +82,7 @@ export default defineConfig({ "**/cloud-provenance.spec.ts", "**/mention-recipients.spec.ts", "**/remote-owned-mentions.spec.ts", + "**/client-only-agents.spec.ts", "**/forum-agent-invitation.spec.ts", "**/team-mentions.spec.ts", "**/persistent-agent-audience.spec.ts", diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index f1136e88923..634269a620d 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -44,6 +44,12 @@ pub struct AppState { /// PID set: spawn/register, adoption, stop, shutdown, and sweep snapshots. /// Never perform network I/O while holding this lock. pub managed_agent_runtime_transition: Mutex<()>, + /// Device execution policy is fixed until restart; never synchronized. + pub(crate) agent_device_policy: std::sync::OnceLock< + Result, + >, + /// Serializes local agent name checks through durable creation or rename. + pub(crate) agent_name_transition: Arc>, pub managed_agents_store_lock: Mutex<()>, pub channel_templates_store_lock: Mutex<()>, pub managed_agent_processes: Mutex>, @@ -220,6 +226,8 @@ pub fn build_app_state() -> AppState { shutdown_started: AtomicBool::new(false), managed_agent_runtime_transition: Mutex::new(()), identity_mutation: Mutex::new(()), + agent_device_policy: std::sync::OnceLock::new(), + agent_name_transition: Arc::new(tokio::sync::Mutex::new(())), managed_agents_store_lock: Mutex::new(()), channel_templates_store_lock: Mutex::new(()), managed_agent_processes: Mutex::new(HashMap::new()), diff --git a/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs b/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs index 8f7493e1e8b..b9621fd8348 100644 --- a/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs +++ b/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs @@ -306,8 +306,22 @@ async fn list_relay_agents_for_selection( } #[tauri::command] -pub async fn list_relay_agents(state: State<'_, AppState>) -> Result, String> { - list_relay_agents_for_state(&state).await +pub async fn list_relay_agents( + state: State<'_, AppState>, + app: tauri::AppHandle, +) -> Result, String> { + let policy = crate::managed_agents::device_policy::active(&app)?; + let relay_url = crate::relay::relay_api_base_url_with_override(&state); + let mut agents = list_relay_agents_for_state(&state).await?; + agents.retain(|agent| { + policy.allows_identity( + &relay_url, + agent.owner_pubkey.as_deref(), + &agent.name, + &agent.pubkey, + ) + }); + Ok(agents) } /// Revalidate only the selected relay agents in the target channel. diff --git a/desktop/src-tauri/src/commands/agent_models_update.rs b/desktop/src-tauri/src/commands/agent_models_update.rs index 2ef014d7956..63d42dcaeca 100644 --- a/desktop/src-tauri/src/commands/agent_models_update.rs +++ b/desktop/src-tauri/src/commands/agent_models_update.rs @@ -145,8 +145,44 @@ pub async fn update_managed_agent( app: AppHandle, state: State<'_, AppState>, ) -> Result { + crate::managed_agents::device_policy::require_hosting(&app)?; + let _name_guard = state.agent_name_transition.clone().lock_owned().await; + { + let records = load_managed_agents(&app)?; + let record = records + .iter() + .find(|record| record.pubkey == input.pubkey) + .ok_or("Agent not found")?; + crate::managed_agents::device_policy::require_record(&app, record)?; + if let Some(name) = input.name.as_deref() { + crate::managed_agents::device_policy::active(&app)? + .check_name_update( + &record.name, + name, + Some(&record.pubkey), + record.persona_id.as_deref(), + || { + crate::managed_agents::device_policy::unique_names::preflight( + &app, + &state, + name, + record.persona_id.as_deref(), + Some(&record.pubkey), + ) + }, + ) + .await?; + } + } // Phase 1: local save (synchronous, under lock) - let (mut summary, sync_params, rollback, access_policy_changed, access_restart_relays) = { + let ( + mut summary, + sync_params, + rollback, + access_policy_changed, + access_restart_relays, + retention_error, + ) = { let _store_guard = state .managed_agents_store_lock .lock() @@ -163,6 +199,7 @@ pub async fn update_managed_agent( } let record = find_managed_agent_mut(&mut records, &input.pubkey)?; + crate::managed_agents::device_policy::require_record(&app, record)?; let previous_record = record.clone(); let mut name_changed = false; @@ -330,7 +367,8 @@ pub async fn update_managed_agent( // Publish the edit to the relay. After-save, inside the lock, before // any .await. The retention upsert hashes the opt-IN projection, so an // update that touched only runtime/local fields is a no-op publish. - super::super::agents::retain_managed_agent_pending(&app, &state, record); + let retention_error = + super::super::agents::retain_managed_agent_pending(&app, &state, record).err(); let sync_params = if name_changed { let agent_keys = Keys::parse(&record.private_key_nsec) @@ -374,6 +412,7 @@ pub async fn update_managed_agent( rollback, access_policy_changed, access_restart_relays, + retention_error, ) }; // lock dropped here @@ -421,7 +460,10 @@ pub async fn update_managed_agent( let rollback = rollback.ok_or_else(|| { "missing local rollback state after relay profile sync failure".to_string() })?; - rollback_failed_agent_update(&app, &state, &summary.pubkey, rollback)?; + let rollback_sync_error = + rollback_failed_agent_update(&app, &state, &summary.pubkey, rollback)? + .map(|error| format!(" Rollback synchronization also failed: {error}")) + .unwrap_or_default(); let restart_suffix = if access_restart_relays.is_empty() { String::new() } else { @@ -445,7 +487,7 @@ pub async fn update_managed_agent( "No changes were saved" }; return Err(format!( - "Agent rename failed because its relay profile could not be updated. {rollback_message}: {sync_error}.{restart_suffix}" + "Agent rename failed because its relay profile could not be updated. {rollback_message}: {sync_error}.{restart_suffix}{rollback_sync_error}" )); } } @@ -467,7 +509,7 @@ pub async fn update_managed_agent( Ok(UpdateManagedAgentResponse { agent: summary, - profile_sync_error: profile_sync_error.take(), + profile_sync_error: retention_error.or(profile_sync_error.take()), }) } diff --git a/desktop/src-tauri/src/commands/agent_settings.rs b/desktop/src-tauri/src/commands/agent_settings.rs index 1371abba2c6..f5dd7cd2176 100644 --- a/desktop/src-tauri/src/commands/agent_settings.rs +++ b/desktop/src-tauri/src/commands/agent_settings.rs @@ -53,6 +53,7 @@ pub async fn set_managed_agent_start_on_app_launch( { let record = find_managed_agent_mut(&mut records, &pubkey)?; + crate::managed_agents::device_policy::require_record(&app, record)?; record.start_on_app_launch = start_on_app_launch; record.updated_at = now_iso(); } @@ -97,6 +98,7 @@ pub async fn set_managed_agent_auto_restart( { let record = find_managed_agent_mut(&mut records, &pubkey)?; + crate::managed_agents::device_policy::require_record(&app, record)?; record.auto_restart_on_config_change = auto_restart_on_config_change; record.updated_at = now_iso(); } diff --git a/desktop/src-tauri/src/commands/agent_update_rollback.rs b/desktop/src-tauri/src/commands/agent_update_rollback.rs index 78734797f04..e9442e14929 100644 --- a/desktop/src-tauri/src/commands/agent_update_rollback.rs +++ b/desktop/src-tauri/src/commands/agent_update_rollback.rs @@ -90,8 +90,8 @@ pub(super) fn rollback_failed_agent_update( state: &AppState, pubkey: &str, rollback: AgentUpdateRollback, -) -> Result<(), String> { - { +) -> Result, String> { + let retention_error = { let _store_guard = state .managed_agents_store_lock .lock() @@ -103,10 +103,10 @@ pub(super) fn rollback_failed_agent_update( .iter() .find(|record| record.pubkey == pubkey) .ok_or_else(|| format!("agent {pubkey} not found after failed rename rollback"))?; - super::agents::retain_managed_agent_pending(app, state, restored); - } + super::agents::retain_managed_agent_pending(app, state, restored).err() + }; try_regenerate_nest(app); - Ok(()) + Ok(retention_error) } #[cfg(test)] diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 0ad7fd321c5..003b644dfdb 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -93,6 +93,7 @@ pub(super) async fn start_local_agent_pairs_with_preflight( .find(|record| record.pubkey == pubkey) .ok_or_else(|| format!("agent {pubkey} not found"))? }; + crate::managed_agents::device_policy::require_record(app, &record_snapshot)?; if record_snapshot.backend != BackendKind::Local { return Err(format!("agent {pubkey} is not a local agent")); } @@ -107,6 +108,7 @@ pub(super) async fn start_local_agent_pairs_with_preflight( ); ensure_relay_mesh_for_record(app, mesh_model_id.as_deref(), false).await?; + let mut retention_error = None; { let _store_guard = state .managed_agents_store_lock @@ -123,7 +125,7 @@ pub(super) async fn start_local_agent_pairs_with_preflight( } save_managed_agents(app, &records)?; if let Some(saved_record) = records.iter().find(|record| record.pubkey == pubkey) { - retain_managed_agent_pending(app, state, saved_record); + retention_error = retain_managed_agent_pending(app, state, saved_record).err(); } } @@ -157,6 +159,9 @@ pub(super) async fn start_local_agent_pairs_with_preflight( .iter() .find(|record| record.pubkey == pubkey) .ok_or_else(|| format!("agent {pubkey} not found"))?; + if let Some(error) = retention_error { + return Err(error); + } summarize_from_disk(app, record, &runtimes) } @@ -182,6 +187,7 @@ pub(super) async fn start_local_agent_with_preflight( .ok_or_else(|| format!("agent {pubkey} not found"))? }; + crate::managed_agents::device_policy::require_record(app, &record_snapshot)?; if record_snapshot.backend != BackendKind::Local { return Err(format!("agent {pubkey} is not a local agent")); } @@ -265,7 +271,7 @@ pub(super) async fn start_local_agent_with_preflight( )?; save_managed_agents(app, &records)?; if let Some(saved_record) = records.iter().find(|r| r.pubkey == pubkey) { - retain_managed_agent_pending(app, state, saved_record); + retain_managed_agent_pending(app, state, saved_record)?; } let record = records .iter() @@ -291,6 +297,9 @@ pub(crate) use provider_deploy::deploy_to_provider; // and `std::sync::MutexGuard` is not `Send`. #[tauri::command] pub async fn list_managed_agents(app: AppHandle) -> Result, String> { + if crate::managed_agents::device_policy::is_client_only(&app) { + return Ok(Vec::new()); + } use tauri::Manager; tokio::task::spawn_blocking(move || { let state = app.state::(); @@ -322,6 +331,7 @@ pub async fn list_managed_agents(app: AppHandle) -> Result, ) -> Result { + crate::managed_agents::device_policy::require_hosting(&app)?; let name = input.name.trim().to_string(); let requested_persona_id = input .persona_id @@ -351,6 +362,15 @@ pub async fn create_managed_agent( .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string); + let _name_guard = state.agent_name_transition.clone().lock_owned().await; + crate::managed_agents::device_policy::unique_names::preflight( + &app, + &state, + &name, + requested_persona_id.as_deref(), + None, + ) + .await?; validate_create_definition(&name, requested_persona_id.as_deref(), &input)?; if let Some(parallelism) = input.parallelism { if !(1..=32).contains(¶llelism) { @@ -400,7 +420,11 @@ pub async fn create_managed_agent( let personas = load_personas(&app)?; ensure_persona_is_active(&personas, persona_id)?; } - let keys = Keys::generate(); + let keys = crate::managed_agents::device_policy::generate_agent_keys( + &app, + &name, + requested_persona_id.as_deref(), + )?; let pubkey = keys.public_key().to_hex(); if records.iter().any(|record| record.pubkey == pubkey) { return Err(format!("agent {pubkey} already exists")); @@ -448,7 +472,7 @@ pub async fn create_managed_agent( }; // ── Phase 3: save record (sync lock) ─────────────────────────────────────── - let (agent, resolved_avatar_url, profile_about) = { + let (agent, resolved_avatar_url, profile_about, retention_error) = { let _store_guard = state .managed_agents_store_lock .lock() @@ -700,13 +724,14 @@ pub async fn create_managed_agent( // Publish the agent to the relay. Inside the Phase-3 lock, after save, // before any .await — owner-authored, every agent (Will's ruling: no // is_builtin/persona-membership gate). - retain_managed_agent_pending(&app, &state, record); + let retention_error = retain_managed_agent_pending(&app, &state, record).err(); // Effective owner-authored description for the kind:0 `about`. let profile_about = crate::managed_agents::record_effective_description(record, &personas); ( summarize_from_disk(&app, record, &runtimes)?, resolved_avatar_url, profile_about, + retention_error, ) }; @@ -727,6 +752,7 @@ pub async fn create_managed_agent( .lock() .map_err(|e| e.to_string())?; let record = find_managed_agent_mut(&mut records, &pubkey)?; + crate::managed_agents::device_policy::require_record(&app, record)?; record.updated_at = now_iso(); record.last_error = Some(error.clone()); save_managed_agents(&app, &records)?; @@ -759,6 +785,7 @@ pub async fn create_managed_agent( .await; profile_sync_error = super::agent_models::flush_managed_agent_policy(&app, &state, profile_sync_error).await; + profile_sync_error = retention_error.or(profile_sync_error); let spawn_error = if input.spawn_after_create && input.backend != BackendKind::Local { if let BackendKind::Provider { ref id, ref config } = input.backend { @@ -827,6 +854,7 @@ pub async fn start_managed_agent( app: AppHandle, state: State<'_, AppState>, ) -> Result { + crate::managed_agents::device_policy::require_hosting(&app)?; // Snapshot the workspace owner pubkey for the legacy auth_tag fallback. // Read outside the records lock to keep lock ordering simple. let owner_hex = workspace_owner_hex(&state)?; @@ -887,6 +915,7 @@ pub async fn start_managed_agent( } let record = find_managed_agent_mut(&mut records, &pubkey)?; + crate::managed_agents::device_policy::require_record(&app, record)?; // Resolve the effective harness for the avatar-fallback derivation in // profile reconcile (the create-time snapshot may be empty or stale for @@ -1032,6 +1061,7 @@ pub async fn stop_managed_agent( { let record = find_managed_agent_mut(&mut records, &pubkey)?; + crate::managed_agents::device_policy::require_record(&app, record)?; // Remote agents are stopped via !shutdown @mention from the frontend, // not via this backend command. Reject the call. if record.backend != BackendKind::Local { @@ -1060,6 +1090,7 @@ fn run_managed_agent_deletion( base_dir: &std::path::Path, pubkey: &str, records: &mut Vec, + prepare: impl FnOnce(&ManagedAgentRecord) -> Result<(), String>, delete: impl FnOnce(&mut Vec) -> Result, ) -> Result { recover_pending_assignment_cleanup(base_dir, |pending_pubkey| { @@ -1067,6 +1098,11 @@ fn run_managed_agent_deletion( .iter() .any(|record| record.pubkey.eq_ignore_ascii_case(pending_pubkey)) })?; + let record = records + .iter() + .find(|record| record.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} not found"))?; + prepare(record)?; with_agent_assignments_cleared(base_dir, pubkey, || delete(records)) } @@ -1076,6 +1112,7 @@ pub async fn delete_managed_agent( force_remote_delete: Option, app: AppHandle, ) -> Result<(), String> { + crate::managed_agents::device_policy::require_hosting(&app)?; use tauri::Manager; tokio::task::spawn_blocking(move || { let state = app.state::(); @@ -1113,6 +1150,7 @@ pub async fn delete_managed_agent( // remote deployment. The frontend sends force_remote_delete: true only after // the user confirms the orphan warning. if let Some(record) = records.iter().find(|r| r.pubkey == pubkey) { + crate::managed_agents::device_policy::require_record(&app, record)?; if record.backend != BackendKind::Local && record.backend_agent_id.is_some() && !force_remote_delete.unwrap_or(false) @@ -1127,14 +1165,28 @@ pub async fn delete_managed_agent( if !records.iter().any(|record| record.pubkey == pubkey) { return Err(format!("agent {pubkey} not found")); } - run_managed_agent_deletion(&base_dir, &pubkey, &mut records, |records| { - if let Some(record) = records.iter_mut().find(|record| record.pubkey == pubkey) { - stop_managed_agent_process(&app, record, &mut runtimes)?; - } - state.clear_agent_session_caches(&pubkey); - records.retain(|record| record.pubkey != pubkey); - save_managed_agents(&app, records) - })?; + run_managed_agent_deletion( + &base_dir, + &pubkey, + &mut records, + |record| { + // Fail before stopping/removing the only local record if its + // durable deletion retry cannot be authorized. + if crate::managed_agents::device_policy::active(&app)?.unique_names { + retain_managed_agent_pending(&app, &state, record)?; + } + Ok(()) + }, + |records| { + if let Some(record) = records.iter_mut().find(|record| record.pubkey == pubkey) + { + stop_managed_agent_process(&app, record, &mut runtimes)?; + } + state.clear_agent_session_caches(&pubkey); + records.retain(|record| record.pubkey != pubkey); + save_managed_agents(&app, records) + }, + )?; crate::managed_agents::delete_agent_key(&pubkey); // Tombstone after confirmed removal (inside lock; every published // agent tombstones). The NIP-IA kind:9035 archive request — which diff --git a/desktop/src-tauri/src/commands/agents/provider_access.rs b/desktop/src-tauri/src/commands/agents/provider_access.rs index 69c2d2f7f83..102b8e8b85d 100644 --- a/desktop/src-tauri/src/commands/agents/provider_access.rs +++ b/desktop/src-tauri/src/commands/agents/provider_access.rs @@ -62,15 +62,23 @@ pub(crate) async fn reconcile_on_workspace_apply( app: &AppHandle, state: &AppState, ) -> Result<(), String> { + if crate::managed_agents::device_policy::is_client_only(app) { + return Ok(()); + } let owner_only_access = crate::managed_agents::owner_only_access_build(); let targets = { let _store_guard = state .managed_agents_store_lock .lock() .map_err(|error| error.to_string())?; - collect_targets_with(load_managed_agents(app)?, owner_only_access, |record| { - super::build_deploy_payload(app, state, record) - }) + collect_targets_with( + load_managed_agents(app)? + .into_iter() + .filter(|record| crate::managed_agents::device_policy::can_host_record(app, record)) + .collect(), + owner_only_access, + |record| super::build_deploy_payload(app, state, record), + ) }; for target in targets { diff --git a/desktop/src-tauri/src/commands/agents/provider_deploy.rs b/desktop/src-tauri/src/commands/agents/provider_deploy.rs index 15db4dec5aa..ac2c766e68b 100644 --- a/desktop/src-tauri/src/commands/agents/provider_deploy.rs +++ b/desktop/src-tauri/src/commands/agents/provider_deploy.rs @@ -51,6 +51,7 @@ pub(crate) async fn deploy_to_provider( expected_signer_pubkey: Option<&str>, replay_floor_unix: Option, ) -> Result<(), String> { + crate::managed_agents::device_policy::require_hosting(app)?; let deploy_lock = { let mut locks = state .provider_deploy_locks diff --git a/desktop/src-tauri/src/commands/agents_deploy.rs b/desktop/src-tauri/src/commands/agents_deploy.rs index de8ca8cc789..c1ac97611d4 100644 --- a/desktop/src-tauri/src/commands/agents_deploy.rs +++ b/desktop/src-tauri/src/commands/agents_deploy.rs @@ -193,6 +193,7 @@ pub(crate) fn build_deploy_payload( state: &AppState, record: &ManagedAgentRecord, ) -> Result { + crate::managed_agents::device_policy::require_record(app, record)?; if let Some(err) = crate::managed_agents::spawn_key_refusal(record) { return Err(err); } diff --git a/desktop/src-tauri/src/commands/agents_pending.rs b/desktop/src-tauri/src/commands/agents_pending.rs index 0a7f91eb854..27ad0e6d05d 100644 --- a/desktop/src-tauri/src/commands/agents_pending.rs +++ b/desktop/src-tauri/src/commands/agents_pending.rs @@ -20,28 +20,51 @@ use crate::{app_state::AppState, managed_agents::ManagedAgentRecord}; /// [`agent_event_content`] projection — the retention upsert's content-equality /// guard compares this projection, so an operational start/stop that mutates /// only runtime fields produces an identical row and never re-enqueues a -/// publish. Best-effort: a failure here is logged and swallowed so a retention -/// hiccup never blocks the disk-authoritative write. +/// publish. Unique-name mode propagates failures because boot reconciliation +/// intentionally holds unregistered identities. Unrestricted mode remains best-effort. pub(crate) fn retain_managed_agent_pending( app: &AppHandle, state: &AppState, record: &ManagedAgentRecord, -) { - use crate::managed_agents::{reconcile::retain_agent_record, retention::open_retention_db}; +) -> Result<(), String> { + use crate::managed_agents::retention::open_retention_db; + let policy = crate::managed_agents::device_policy::active(app)?; let result = (|| -> Result<(), String> { let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; let conn = open_retention_db(&scope.db_path)?; - // Shared engine with the boot-time reconcile: projection content diff - // (no republish for runtime-only churn) + monotonic created_at bump - // past the retained head (NIP-AP step 3). - retain_agent_record(&conn, &scope.owner_keys, record).map(|_| ()) + retain_managed_agent_with_policy(&conn, &scope.owner_keys, record, &policy) })(); - if let Err(e) = result { - eprintln!("buzz-desktop: agent-retain: {e}"); + if policy.unique_names { + result.map_err(|error| format!("Agent saved locally, but synchronization failed: {error}. Retry saving this agent.")) + } else { + if let Err(error) = result { + eprintln!("buzz-desktop: agent-retain: {error}"); + } + Ok(()) } } +fn retain_managed_agent_with_policy( + conn: &rusqlite::Connection, + keys: &nostr::Keys, + record: &ManagedAgentRecord, + policy: &crate::managed_agents::device_policy::model::DeviceAgentPolicy, +) -> Result<(), String> { + use crate::managed_agents::{device_policy::sync, reconcile::retain_agent_record}; + policy.require_local_agent( + &record.name, + Some(&record.pubkey), + record.persona_id.as_deref(), + )?; + let transaction = conn.unchecked_transaction().map_err(|e| e.to_string())?; + if policy.unique_names { + sync::register(&transaction, &record.pubkey)?; + } + retain_agent_record(&transaction, keys, record)?; + transaction.commit().map_err(|e| e.to_string()) +} + /// Purge a deleted agent's pending row and enqueue a NIP-09 tombstone, both /// inside the `managed_agents_store_lock`-held delete body and NEVER across an /// `.await`. @@ -436,3 +459,7 @@ mod tests { ); } } + +#[cfg(test)] +#[path = "agents_pending_policy_tests.rs"] +mod policy_tests; diff --git a/desktop/src-tauri/src/commands/agents_pending_policy_tests.rs b/desktop/src-tauri/src/commands/agents_pending_policy_tests.rs new file mode 100644 index 00000000000..a7347a37cac --- /dev/null +++ b/desktop/src-tauri/src/commands/agents_pending_policy_tests.rs @@ -0,0 +1,148 @@ +use super::*; +use crate::managed_agents::{ + device_policy::{model::DeviceAgentPolicy, sync}, + retention::{get_pending_sync, open_retention_db}, +}; + +fn record() -> ManagedAgentRecord { + serde_json::from_value(serde_json::json!({ + "pubkey": nostr::Keys::generate().public_key().to_hex(), + "name": "Laptop Agent", "relay_url": "https://relay.example", + "acp_command": "buzz-acp", "agent_command": "goose", "agent_args": [], + "mcp_command": "", "turn_timeout_seconds": 320, "system_prompt": "Test", + "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z" + })) + .unwrap() +} + +fn policy() -> DeviceAgentPolicy { + DeviceAgentPolicy { + unique_names: true, + ..Default::default() + } +} + +#[test] +fn deleting_preexisting_local_identity_leaves_both_effects_eligible_after_reopen() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("retention.db"); + let keys = nostr::Keys::generate(); + let record = record(); + let conn = open_retention_db(&path).unwrap(); + assert!(sync::registered(&conn).unwrap().is_empty()); + // The deletion command uses this same retention seam before removing the record. + let mut records = vec![record.clone()]; + super::super::run_managed_agent_deletion( + dir.path(), + &record.pubkey, + &mut records, + |record| retain_managed_agent_with_policy(&conn, &keys, record, &policy()), + |records| { + records.clear(); + Ok(()) + }, + ) + .unwrap(); + assert!(records.is_empty()); + drop(conn); + tombstone_managed_agent_at(&path, &keys, &record.pubkey).unwrap(); + let conn = open_retention_db(&path).unwrap(); + let registered = sync::registered(&conn).unwrap(); + let pending = get_pending_sync(&conn).unwrap(); + assert_eq!(pending.len(), 2); + for event in pending { + assert!(sync::allows_coordinate( + ®istered, + event.kind, + &event.d_tag + )); + } + assert!(!sync::allows_coordinate( + ®istered, + 9035, + "unrelated-old-identity" + )); +} + +#[test] +fn registry_failure_propagates_and_retry_retains_the_local_edit() { + let dir = tempfile::tempdir().unwrap(); + let conn = open_retention_db(&dir.path().join("retention.db")).unwrap(); + let keys = nostr::Keys::generate(); + let record = record(); + sync::registered(&conn).unwrap(); + conn.execute_batch( + "CREATE TRIGGER deny_registration BEFORE INSERT ON device_local_agent_keys + BEGIN SELECT RAISE(ABORT, 'registration blocked'); END;", + ) + .unwrap(); + let error = retain_managed_agent_with_policy(&conn, &keys, &record, &policy()).unwrap_err(); + assert!(error.contains("registration blocked")); + assert!(get_pending_sync(&conn).unwrap().is_empty()); + conn.execute_batch("DROP TRIGGER deny_registration") + .unwrap(); + retain_managed_agent_with_policy(&conn, &keys, &record, &policy()).unwrap(); + assert_eq!(get_pending_sync(&conn).unwrap().len(), 1); + assert!(sync::registered(&conn).unwrap().contains(&record.pubkey)); +} + +#[test] +fn failed_retention_does_not_release_an_old_backlog() { + let dir = tempfile::tempdir().unwrap(); + let conn = open_retention_db(&dir.path().join("retention.db")).unwrap(); + let keys = nostr::Keys::generate(); + let record = record(); + conn.execute_batch( + "CREATE TRIGGER deny_retention BEFORE INSERT ON persona_events + BEGIN SELECT RAISE(ABORT, 'retention blocked'); END;", + ) + .unwrap(); + assert!(retain_managed_agent_with_policy(&conn, &keys, &record, &policy()).is_err()); + assert!(sync::registered(&conn).unwrap().is_empty()); +} + +#[test] +fn protected_identity_never_enrolls_for_deletion() { + let dir = tempfile::tempdir().unwrap(); + let conn = open_retention_db(&dir.path().join("retention.db")).unwrap(); + let keys = nostr::Keys::generate(); + let record = record(); + let mut policy = policy(); + policy.preferred_agents.push( + crate::managed_agents::device_policy::model::PreferredAgent { + relay_url: record.relay_url.clone(), + owner_pubkey: keys.public_key().to_hex(), + name: record.name.clone(), + pubkey: record.pubkey.clone(), + persona_id: None, + }, + ); + assert!(retain_managed_agent_with_policy(&conn, &keys, &record, &policy).is_err()); + assert!(sync::registered(&conn).unwrap().is_empty()); + assert!(get_pending_sync(&conn).unwrap().is_empty()); +} + +#[test] +fn deletion_preparation_failure_preserves_the_only_local_record() { + let dir = tempfile::tempdir().unwrap(); + let conn = open_retention_db(&dir.path().join("retention.db")).unwrap(); + let keys = nostr::Keys::generate(); + let record = record(); + let mut records = vec![record.clone()]; + sync::registered(&conn).unwrap(); + conn.execute_batch( + "CREATE TRIGGER deny_registration BEFORE INSERT ON device_local_agent_keys + BEGIN SELECT RAISE(ABORT, 'registration blocked'); END;", + ) + .unwrap(); + let result = super::super::run_managed_agent_deletion( + dir.path(), + &record.pubkey, + &mut records, + |record| retain_managed_agent_with_policy(&conn, &keys, record, &policy()), + |_records| -> Result<(), String> { panic!("delete must not run after preparation fails") }, + ); + assert!(result.is_err()); + assert_eq!(records.len(), 1); + assert_eq!(records[0].pubkey, record.pubkey); +} diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index 59e04b09ff0..38cec70b68c 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -1,6 +1,54 @@ use super::*; use crate::managed_agents::AgentDefinition; +#[test] +fn device_policy_refuses_native_deploy_payload_before_accessing_secrets() { + use crate::managed_agents::device_policy::model::DeviceAgentPolicy; + use tauri::Manager; + let state = crate::app_state::build_app_state(); + state + .agent_device_policy + .set(Ok(DeviceAgentPolicy { + client_only: true, + ..Default::default() + })) + .unwrap(); + let app = tauri::test::mock_builder() + .manage(state) + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .unwrap(); + let error = build_deploy_payload( + app.handle(), + &app.state(), + &bare_agent_record(None, None, None), + ) + .unwrap_err(); + assert!(error.contains("client-only")); +} + +#[test] +fn unique_name_policy_refuses_deploy_of_renamed_remote_key() { + use crate::managed_agents::device_policy::model::DeviceAgentPolicy; + use tauri::Manager; + let state = crate::app_state::build_app_state(); + let policy: DeviceAgentPolicy = serde_json::from_str(r#"{ + "client_only":false,"unique_names":true,"preferred_agents":[{ + "relay_url":"https://relay.example","owner_pubkey":"owner","name":"Scout","pubkey":"agent"}]}"#).unwrap(); + state.agent_device_policy.set(Ok(policy)).unwrap(); + let app = tauri::test::mock_builder() + .manage(state) + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .unwrap(); + let record = bare_agent_record(None, None, None); + assert!(build_deploy_payload(app.handle(), &app.state(), &record) + .unwrap_err() + .contains("another device")); + assert!(!crate::managed_agents::device_policy::can_host_record( + app.handle(), + &record + )); +} + fn bare_agent_record( persona_id: Option<&str>, model: Option<&str>, @@ -211,9 +259,13 @@ fn production_delete_orchestration_restores_bestie_when_agent_save_fails() { record.pubkey.clone_from(&pubkey); let mut records = vec![record]; - let result = run_managed_agent_deletion(dir.path(), &pubkey, &mut records, |_records| { - Err::<(), _>("injected managed-agent save failure".to_string()) - }); + let result = run_managed_agent_deletion( + dir.path(), + &pubkey, + &mut records, + |_| Ok(()), + |_records| Err::<(), _>("injected managed-agent save failure".to_string()), + ); assert_eq!( result, diff --git a/desktop/src-tauri/src/commands/identity_archive.rs b/desktop/src-tauri/src/commands/identity_archive.rs index bf66b761d32..071117fc724 100644 --- a/desktop/src-tauri/src/commands/identity_archive.rs +++ b/desktop/src-tauri/src/commands/identity_archive.rs @@ -269,6 +269,7 @@ pub async fn archive_identity( app: AppHandle, state: State<'_, AppState>, ) -> Result { + crate::managed_agents::device_policy::require_identity_archive(&app, &req.target_pubkey)?; archive_identity_core(&req, &state, &app).await } @@ -281,6 +282,7 @@ pub async fn unarchive_identity( app: AppHandle, state: State<'_, AppState>, ) -> Result { + crate::managed_agents::device_policy::require_identity_archive(&app, &req.target_pubkey)?; unarchive_identity_core(&req, &state, &app).await } diff --git a/desktop/src-tauri/src/commands/personas/create.rs b/desktop/src-tauri/src/commands/personas/create.rs index 2f19d1256e1..7eb83709d10 100644 --- a/desktop/src-tauri/src/commands/personas/create.rs +++ b/desktop/src-tauri/src/commands/personas/create.rs @@ -20,7 +20,18 @@ pub async fn create_persona( input: CreatePersonaRequest, app: AppHandle, ) -> Result { + crate::managed_agents::device_policy::require_hosting(&app)?; use tauri::Manager; + let state = app.state::(); + let _name_guard = state.agent_name_transition.clone().lock_owned().await; + crate::managed_agents::device_policy::unique_names::preflight( + &app, + &state, + &input.display_name, + None, + None, + ) + .await?; tokio::task::spawn_blocking(move || { let state = app.state::(); let display_name = trim_required(&input.display_name, "Display name")?; diff --git a/desktop/src-tauri/src/commands/personas/inbound.rs b/desktop/src-tauri/src/commands/personas/inbound.rs index fe9fcbe406a..6d38f7321a2 100644 --- a/desktop/src-tauri/src/commands/personas/inbound.rs +++ b/desktop/src-tauri/src/commands/personas/inbound.rs @@ -337,7 +337,7 @@ fn reconcile_inbound_persona_event_blocking( "managed-agent content was not parsed before retention".to_string() })?; let access_changed = apply_inbound_managed_agent(&mut agents, &d_tag, managed_agent); - if access_changed { + if access_changed && !crate::managed_agents::device_policy::pauses_sync(&app) { let record = agents .iter_mut() .find(|record| record.pubkey == d_tag) diff --git a/desktop/src-tauri/src/commands/personas/mod.rs b/desktop/src-tauri/src/commands/personas/mod.rs index ac43a4719ab..9c0ed580236 100644 --- a/desktop/src-tauri/src/commands/personas/mod.rs +++ b/desktop/src-tauri/src/commands/personas/mod.rs @@ -86,6 +86,17 @@ pub async fn list_personas(app: AppHandle) -> Result, Strin .map_err(|error| error.to_string())?; let mut personas = load_personas(&app)?; pending::project_active_persona_sharing(&app, &state, &mut personas); + // Effective local availability only: never persist this projection or + // publish it as a definition edit. Other devices keep their activation. + let policy = crate::managed_agents::device_policy::active(&app)?; + for persona in &mut personas { + if policy + .require_local_agent(&persona.display_name, None, Some(&persona.id)) + .is_err() + { + persona.is_active = false; + } + } Ok(personas) }) .await @@ -147,6 +158,7 @@ fn commit_cascade_agents( #[tauri::command] pub async fn delete_persona(id: String, app: AppHandle) -> Result<(), String> { + crate::managed_agents::device_policy::require_persona(&app, &id)?; use tauri::Manager; tokio::task::spawn_blocking(move || { let state = app.state::(); @@ -159,6 +171,7 @@ pub async fn delete_persona(id: String, app: AppHandle) -> Result<(), String> { .lock() .map_err(|error| error.to_string())?; + crate::managed_agents::device_policy::require_persona(&app, &id)?; // Load and validate the persona before any destructive work. let mut personas = load_personas(&app)?; let persona = personas @@ -295,6 +308,7 @@ pub async fn set_persona_active( active: bool, app: AppHandle, ) -> Result { + crate::managed_agents::device_policy::require_persona(&app, &id)?; use tauri::Manager; tokio::task::spawn_blocking(move || { let state = app.state::(); @@ -308,6 +322,7 @@ pub async fn set_persona_active( .find(|record| record.id == id) .ok_or_else(|| format!("agent {id} not found"))?; + crate::managed_agents::device_policy::require_persona(&app, &id)?; let referenced_by_managed_agent = !active && load_managed_agents(&app)? .iter() diff --git a/desktop/src-tauri/src/commands/personas/sharing.rs b/desktop/src-tauri/src/commands/personas/sharing.rs index 331ec9d0d70..fd22c13c713 100644 --- a/desktop/src-tauri/src/commands/personas/sharing.rs +++ b/desktop/src-tauri/src/commands/personas/sharing.rs @@ -33,6 +33,8 @@ pub async fn set_persona_shared( shared: bool, app: AppHandle, ) -> Result { + crate::managed_agents::device_policy::require_full_hosting(&app)?; + crate::managed_agents::device_policy::require_persona(&app, &id)?; let prepared = tokio::task::spawn_blocking({ let app = app.clone(); move || { @@ -77,6 +79,7 @@ pub async fn update_persona_and_publish( input: crate::managed_agents::UpdatePersonaRequest, app: AppHandle, ) -> Result { + crate::managed_agents::device_policy::require_full_hosting(&app)?; let (_, prepared) = super::update::update_persona_with(input, app.clone(), |app, state, persona| { // Strict path: this command's contract is to report the publication diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index 041a0b91dc9..e0f1421ab0c 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -458,6 +458,7 @@ pub async fn confirm_agent_snapshot_import( app: AppHandle, state: State<'_, AppState>, ) -> Result { + crate::managed_agents::device_policy::require_hosting(&app)?; // ── Phase 1: validate (no writes) ──────────────────────────────────────── // Locked cards unlock only via this machine's exact key endpoints; // anything else fails closed here, before key generation. @@ -478,6 +479,13 @@ pub async fn confirm_agent_snapshot_import( return Err("Snapshot display name is empty.".to_string()); } + let _name_guard = state.agent_name_transition.clone().lock_owned().await; + crate::managed_agents::device_policy::active(&app)?.require_local_agent( + &display_name, + None, + None, + )?; + // ── Resolve behavioral defaults ────────────────────────────────────────── let minted = resolve_snapshot_import_behavior( snapshot.definition.respond_to.as_deref(), @@ -503,6 +511,15 @@ pub async fn confirm_agent_snapshot_import( ) .await?; + crate::managed_agents::device_policy::unique_names::preflight( + &app, + &state, + &display_name, + None, + None, + ) + .await?; + // Wire-format string for the persona definition's respond_to field. // Omit when it is the default (owner-only) to keep definitions clean. let respond_to_wire: Option = if minted.respond_to != RespondTo::default() { @@ -514,7 +531,8 @@ pub async fn confirm_agent_snapshot_import( // ── Phase 2: mint keys + auth tag (sync, outside lock) ─────────────────── let (agent_keys, private_key_nsec, pubkey, auth_tag, owner_pubkey_hex) = { let owner_keys = state.signing_keys()?; - let agent_keys = nostr::Keys::generate(); + let agent_keys = + crate::managed_agents::device_policy::generate_agent_keys(&app, &display_name, None)?; let pubkey = agent_keys.public_key().to_hex(); let private_key_nsec = agent_keys .secret_key() @@ -541,7 +559,7 @@ pub async fn confirm_agent_snapshot_import( }; // ── Phase 3a: create AgentDefinition + ManagedAgentRecord (sync lock) ────── - let (persona, record) = { + let (persona, record, retention_error) = { let _store_guard = state .managed_agents_store_lock .lock() @@ -671,7 +689,7 @@ pub async fn confirm_agent_snapshot_import( // Enqueue the kind:30177 managed-agent event via retention. // (Uses the same pattern as agents.rs::retain_managed_agent_pending // inlined here to avoid cross-module private-fn access.) - retain_agent_pending(&app, &state, &record); + let retention_error = retain_agent_pending(&app, &state, &record).err(); crate::managed_agents::try_regenerate_nest(&app); @@ -679,7 +697,7 @@ pub async fn confirm_agent_snapshot_import( // matching the contract used by other local managed-agent mutations. let _ = app.emit("agents-data-changed", ()); - (persona, record) + (persona, record, retention_error) }; // ── Phase 3b: publish kind:0 profile (async, outside lock) ─────────────── @@ -754,57 +772,19 @@ pub async fn confirm_agent_snapshot_import( memory_written, memory_total, memory_errors, - profile_sync_error, + profile_sync_error: retention_error.or(profile_sync_error), }) } /// Inline retention for the managed-agent kind:30177 event — mirrors /// `agents::retain_managed_agent_pending` without requiring cross-module /// private function access. -fn retain_agent_pending(app: &AppHandle, state: &AppState, record: &ManagedAgentRecord) { - use crate::managed_agents::{ - agent_events::{agent_event_content, build_agent_event}, - persona_events::monotonic_created_at, - retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent}, - }; - use buzz_core_pkg::kind::KIND_MANAGED_AGENT; - use nostr::JsonUtil; - - let result = (|| -> Result<(), String> { - let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; - let conn = open_retention_db(&scope.db_path)?; - let content = serde_json::to_string(&agent_event_content(record)) - .map_err(|e| format!("failed to serialize agent content: {e}"))?; - let (owner_pubkey, event) = { - let keys = &scope.owner_keys; - let owner_pubkey = keys.public_key().to_hex(); - let existing = - get_retained_event(&conn, KIND_MANAGED_AGENT, &owner_pubkey, &record.pubkey)?; - if existing.as_ref().is_some_and(|row| row.content == content) { - return Ok(()); - } - let event = build_agent_event(record)? - .custom_created_at(monotonic_created_at(existing.map(|row| row.created_at))) - .sign_with_keys(keys) - .map_err(|e| format!("failed to sign agent event: {e}"))?; - (owner_pubkey, event) - }; - retain_event( - &conn, - &RetainedEvent { - kind: KIND_MANAGED_AGENT, - pubkey: owner_pubkey, - d_tag: record.pubkey.clone(), - content: event.content.to_string(), - created_at: event.created_at.as_secs() as i64, - raw_event: event.as_json(), - pending_sync: true, - }, - ) - })(); - if let Err(e) = result { - eprintln!("buzz-desktop: snapshot-import retain-agent: {e}"); - } +fn retain_agent_pending( + app: &AppHandle, + state: &AppState, + record: &ManagedAgentRecord, +) -> Result<(), String> { + crate::commands::agents::retain_managed_agent_pending(app, state, record) } /// POST a pre-built signed engram event to the relay, authenticating as the diff --git a/desktop/src-tauri/src/commands/personas/update.rs b/desktop/src-tauri/src/commands/personas/update.rs index 46d0c8a99dc..0c592aa0df4 100644 --- a/desktop/src-tauri/src/commands/personas/update.rs +++ b/desktop/src-tauri/src/commands/personas/update.rs @@ -126,6 +126,7 @@ pub async fn update_persona( input: UpdatePersonaRequest, app: AppHandle, ) -> Result { + crate::managed_agents::device_policy::require_hosting(&app)?; let (persona, ()) = update_persona_with(input, app, |app, state, persona| { retain_persona_pending(app, state, persona); // F2: immediately refresh any shared 30178 heads that include this @@ -152,11 +153,45 @@ pub(super) async fn update_persona_with( ) -> Result<(AgentDefinition, R), String> { use tauri::Manager; + let state = app.state::(); + let _name_guard = state.agent_name_transition.clone().lock_owned().await; + crate::managed_agents::device_policy::require_persona(&app, &input.id)?; + let linked = load_managed_agents(&app)? + .into_iter() + .filter(|record| record.persona_id.as_deref() == Some(&input.id)) + .collect::>(); + for record in &linked { + crate::managed_agents::device_policy::require_record(&app, record)?; + } + let current_name = load_personas(&app)? + .into_iter() + .find(|persona| persona.id == input.id) + .ok_or_else(|| format!("agent {} not found", input.id))? + .display_name; + let existing_key = linked.first().map(|record| record.pubkey.as_str()); + crate::managed_agents::device_policy::active(&app)? + .check_name_update( + ¤t_name, + &input.display_name, + existing_key, + Some(&input.id), + || { + crate::managed_agents::device_policy::unique_names::preflight( + &app, + &state, + &input.display_name, + Some(&input.id), + existing_key, + ) + }, + ) + .await?; // Phase 1: synchronous save (persona record + linked agent avatar updates) - let (result, retained, profile_sync_params) = tokio::task::spawn_blocking({ + let (result, retained, profile_sync_params, retention_error) = tokio::task::spawn_blocking({ let app = app.clone(); - move || -> Result<(AgentDefinition, R, ProfileSyncParams), String> { + move || -> Result<(AgentDefinition, R, ProfileSyncParams, Option), String> { let state = app.state::(); + let mut retention_error = None; let display_name = trim_required(&input.display_name, "Display name")?; let system_prompt = input.system_prompt.clone(); validate_agent_definition_text(&display_name, &system_prompt)?; @@ -170,6 +205,7 @@ pub(super) async fn update_persona_with( .managed_agents_store_lock .lock() .map_err(|error| error.to_string())?; + crate::managed_agents::device_policy::require_persona(&app, &input.id)?; let mut personas = load_personas(&app)?; pending::project_active_persona_sharing(&app, &state, &mut personas); let persona = personas @@ -282,7 +318,11 @@ pub(super) async fn update_persona_with( // Avatar-only edits are excluded — the avatar is not in the // projection, so retaining would be a guaranteed no-op. for record in records.iter().filter(|r| renamed.contains(&r.pubkey)) { - crate::commands::agents::retain_managed_agent_pending(&app, &state, record); + if let Err(error) = crate::commands::agents::retain_managed_agent_pending( + &app, &state, record, + ) { + retention_error = Some(error); + } } } @@ -291,7 +331,7 @@ pub(super) async fn update_persona_with( Vec::new() }; - Ok((result, retained, sync_params)) + Ok((result, retained, sync_params, retention_error)) } }) .await @@ -323,5 +363,8 @@ pub(super) async fn update_persona_with( } } + if let Some(error) = retention_error { + return Err(error); + } Ok((result, retained)) } diff --git a/desktop/src-tauri/src/commands/profile.rs b/desktop/src-tauri/src/commands/profile.rs index da93af673de..817f3236d88 100644 --- a/desktop/src-tauri/src/commands/profile.rs +++ b/desktop/src-tauri/src/commands/profile.rs @@ -267,6 +267,64 @@ pub async fn search_users( limit: Option, cursor: Option, state: State<'_, AppState>, + app: tauri::AppHandle, +) -> Result { + let policy = crate::managed_agents::device_policy::active(&app)?; + let relay_url = crate::relay::relay_api_base_url_with_override(&state); + let keys = state.signing_keys()?; + let response = search_users_unfiltered( + query.clone(), + limit, + preferred_search::base_cursor(cursor.as_deref()), + &state, + &relay_url, + &keys, + ) + .await?; + let response = preferred_search::complete_search( + &policy, + &relay_url, + &query, + limit, + cursor.as_deref(), + response, + |authors| { + let state = &state; + let relay_url = &relay_url; + let keys = &keys; + async move { + query_relay_at_with_keys( + state, + relay_url, + &[serde_json::json!({ + "kinds": [0], "authors": authors, "limit": 500, + })], + keys, + None, + ) + .await + } + }, + ) + .await?; + if current_pubkey_hex(&state)? != keys.public_key().to_hex() + || crate::relay::relay_api_base_url_with_override(&state) != relay_url + { + return Err("Account or community changed during user search; retry the search".into()); + } + Ok(response) +} + +#[path = "profile_preferred_search.rs"] +mod preferred_search; + +async fn search_users_unfiltered( + query: String, + limit: Option, + cursor: Option, + state: &AppState, + relay_url: &str, + keys: &nostr::Keys, ) -> Result { let trimmed = query.trim(); let max = limit.unwrap_or(8).min(500) as usize; @@ -284,13 +342,16 @@ pub async fn search_users( } if trimmed.is_empty() { - let events = query_relay( - &state, + let events = query_relay_at_with_keys( + state, + relay_url, &[serde_json::json!({ "kinds": [0], "limit": max, "page": page, })], + keys, + None, ) .await?; @@ -325,7 +386,14 @@ pub async fn search_users( // the relay runs whole-word `websearch_to_tsquery` matching and "tyl" // returns zero results for "Tyler". Same bridge-only extension the topbar // message search uses (see `build_search_messages_filter`). - let events = query_relay(&state, &[build_user_search_filter(trimmed, max, page)]).await?; + let events = query_relay_at_with_keys( + state, + relay_url, + &[build_user_search_filter(trimmed, max, page)], + keys, + None, + ) + .await?; let mut response = nostr_convert::rank_user_search_results(&events, trimmed, max); if events.len() >= max { diff --git a/desktop/src-tauri/src/commands/profile_preferred_search.rs b/desktop/src-tauri/src/commands/profile_preferred_search.rs new file mode 100644 index 00000000000..ae163f5cd85 --- /dev/null +++ b/desktop/src-tauri/src/commands/profile_preferred_search.rs @@ -0,0 +1,117 @@ +use crate::{managed_agents::device_policy::model::DeviceAgentPolicy, models::SearchUsersResponse}; + +// A preferred first-page profile can displace a base result. Requery that +// same bounded page and drain its remaining results before advancing the relay +// cursor; clients treat this cursor as opaque. +pub(super) fn base_cursor(cursor: Option<&str>) -> Option { + cursor.map(|value| { + if value.starts_with("preferred:") { + "1".into() + } else { + value.into() + } + }) +} + +pub(super) async fn complete_search( + policy: &DeviceAgentPolicy, + relay_url: &str, + query: &str, + limit: Option, + cursor: Option<&str>, + mut response: SearchUsersResponse, + fetch: F, +) -> Result +where + F: FnOnce(Vec) -> Fut, + Fut: std::future::Future, String>>, +{ + let max = limit.unwrap_or(8).min(500) as usize; + if max == 0 || nostr::PublicKey::parse(query.trim()).is_ok() { + return Ok(response); + } + let first_page = cursor.and_then(|c| c.parse::().ok()).unwrap_or(1) <= 1; + let normalized_query = query.trim().to_lowercase(); + let preferred: Vec<_> = policy + .preferred_agents + .iter() + .filter(|p| { + p.relay_url.trim_end_matches('/') == relay_url.trim_end_matches('/') + && p.name.trim().to_lowercase().contains(&normalized_query) + }) + .take(500) + .collect(); + // Explicit author lookup prevents old identities from exhausting the name + // search page before the preferred identity is even considered. + if first_page && !preferred.is_empty() { + let missing: Vec = preferred + .iter() + .filter(|p| { + !response.users.iter().any(|u| { + u.pubkey == p.pubkey + && u.owner_pubkey.as_deref() == Some(p.owner_pubkey.as_str()) + && u.display_name + .as_deref() + .is_some_and(|n| n.trim().eq_ignore_ascii_case(p.name.trim())) + }) + }) + .map(|p| p.pubkey.clone()) + .collect(); + if !missing.is_empty() { + let events = fetch(missing).await?; + let valid: Vec<_> = events + .into_iter() + .take(500) + .filter(|event| event.kind == nostr::Kind::Metadata && event.verify().is_ok()) + .collect(); + for user in crate::nostr_convert::list_user_search_results(&valid, 500).users { + if preferred.iter().any(|p| { + user.pubkey == p.pubkey + && user.owner_pubkey.as_deref() == Some(p.owner_pubkey.as_str()) + && user + .display_name + .as_deref() + .is_some_and(|n| n.trim().eq_ignore_ascii_case(p.name.trim())) + }) { + response + .users + .retain(|existing| existing.pubkey != user.pubkey); + response.users.push(user); + } + } + } + // Keep canonical results inside the caller's cap even when the base + // page also contains unrelated people with matching names. + response + .users + .sort_by_key(|user| !preferred.iter().any(|p| user.pubkey == p.pubkey)); + } + response.users.retain(|user| { + !user.is_agent + || policy.allows_identity( + relay_url, + user.owner_pubkey.as_deref(), + user.display_name.as_deref().unwrap_or(""), + &user.pubkey, + ) + }); + if !first_page { + response + .users + .retain(|user| !preferred.iter().any(|p| user.pubkey == p.pubkey)); + } + let offset = cursor + .and_then(|c| c.strip_prefix("preferred:")) + .and_then(|value| value.parse::().ok()) + .unwrap_or(0) + .min(1000); + if first_page && response.users.len() > offset + max { + response.next_cursor = Some(format!("preferred:{}", offset + max)); + } + response.users = response.users.into_iter().skip(offset).take(max).collect(); + Ok(response) +} + +#[cfg(test)] +#[path = "profile_preferred_search_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/commands/profile_preferred_search_tests.rs b/desktop/src-tauri/src/commands/profile_preferred_search_tests.rs new file mode 100644 index 00000000000..bf5b69a4b69 --- /dev/null +++ b/desktop/src-tauri/src/commands/profile_preferred_search_tests.rs @@ -0,0 +1,220 @@ +use super::*; +use crate::{managed_agents::device_policy::model::PreferredAgent, nostr_convert}; +use nostr::{Event, EventBuilder, Keys, Kind, Tag}; + +fn profile(owner: &Keys, agent: &Keys, name: &str) -> Event { + let auth = buzz_sdk_pkg::nip_oa::compute_auth_tag(owner, &agent.public_key(), "").unwrap(); + let values: Vec = serde_json::from_str(&auth).unwrap(); + EventBuilder::new( + Kind::Metadata, + serde_json::json!({"name": name}).to_string(), + ) + .tags([Tag::parse(values).unwrap()]) + .sign_with_keys(agent) + .unwrap() +} + +fn fixture() -> (DeviceAgentPolicy, Event, SearchUsersResponse) { + let owner = Keys::generate(); + let agent = Keys::generate(); + let preferred = profile(&owner, &agent, "Scout"); + let policy = DeviceAgentPolicy { + preferred_agents: vec![PreferredAgent { + relay_url: "https://relay.example".into(), + owner_pubkey: owner.public_key().to_hex(), + name: "Scout".into(), + pubkey: agent.public_key().to_hex(), + persona_id: None, + }], + ..Default::default() + }; + let duplicates = (0..8) + .map(|_| profile(&owner, &Keys::generate(), "Scout")) + .collect::>(); + let mut response = nostr_convert::list_user_search_results(&duplicates, 8); + response.next_cursor = Some("2".into()); + (policy, preferred, response) +} + +#[tokio::test] +async fn preferred_identity_outside_first_page_is_fetched_before_filtering() { + let (policy, preferred, response) = fixture(); + let key = preferred.pubkey.to_hex(); + let expected = key.clone(); + let result = complete_search( + &policy, + "https://relay.example", + "Sco", + Some(8), + None, + response, + |authors| async move { + assert_eq!(authors, vec![expected]); + Ok(vec![preferred]) + }, + ) + .await + .unwrap(); + assert_eq!(result.users.len(), 1); + assert_eq!(result.users[0].pubkey, key); + assert_eq!(result.next_cursor.as_deref(), Some("2")); +} + +#[tokio::test] +async fn failed_preferred_lookup_is_not_reported_as_an_empty_directory() { + let (policy, _, response) = fixture(); + let result = complete_search( + &policy, + "https://relay.example", + "Scout", + None, + None, + response, + |_| async { Err("offline".into()) }, + ) + .await; + assert!(matches!(result, Err(error) if error.contains("offline"))); +} + +#[tokio::test] +async fn explicit_keys_and_other_communities_do_not_fetch_or_filter() { + for (relay, query) in [ + ("https://other.example", "Scout".to_string()), + ( + "https://relay.example", + Keys::generate().public_key().to_hex(), + ), + ] { + let (policy, _, response) = fixture(); + let result = complete_search(&policy, relay, &query, None, None, response, |_| async { + panic!("unexpected preferred query") + }) + .await + .unwrap(); + assert_eq!(result.users.len(), 8); + } +} + +#[tokio::test] +async fn wrong_owner_and_tampered_profiles_cannot_supply_the_preference() { + for tampered in [false, true] { + let (mut policy, mut preferred, mut response) = fixture(); + if tampered { + preferred.content = "{\"name\":\"Other\"}".into(); + } else { + policy.preferred_agents[0].owner_pubkey = Keys::generate().public_key().to_hex(); + response.users.clear(); + } + let result = complete_search( + &policy, + "https://relay.example", + "Scout", + None, + None, + response, + |_| async { Ok(vec![preferred]) }, + ) + .await + .unwrap(); + assert!(result.users.is_empty()); + } +} + +#[tokio::test] +async fn preferred_result_stays_inside_limit_and_is_not_duplicated() { + let (policy, preferred, mut response) = fixture(); + let key = preferred.pubkey.to_hex(); + response.users = vec![nostr_convert::user_search_result_from_event(&profile( + &Keys::generate(), + &Keys::generate(), + "Scout", + ))]; + let result = complete_search( + &policy, + "https://relay.example", + "Scout", + Some(1), + None, + response, + |_| async { Ok(vec![preferred.clone(), preferred]) }, + ) + .await + .unwrap(); + assert_eq!(result.users.len(), 1); + assert_eq!(result.users[0].pubkey, key); +} + +#[tokio::test] +async fn zero_limit_and_later_pages_do_not_fetch() { + for (limit, cursor) in [(Some(0), None), (None, Some("2"))] { + let (policy, _, mut response) = fixture(); + if limit == Some(0) { + response.users.clear(); + } + let result = complete_search( + &policy, + "https://relay.example", + "Scout", + limit, + cursor, + response, + |_| async { panic!("unexpected preferred query") }, + ) + .await + .unwrap(); + assert!(result.users.is_empty()); + } +} + +#[tokio::test] +async fn pagination_preserves_displaced_people_and_does_not_repeat_preferred() { + let (policy, preferred, _) = fixture(); + let unrelated = profile(&Keys::generate(), &Keys::generate(), "Scout"); + let page = || { + let mut response = + nostr_convert::list_user_search_results(std::slice::from_ref(&unrelated), 1); + response.next_cursor = Some("2".into()); + response + }; + let first = complete_search( + &policy, + "https://relay.example", + "Scout", + Some(1), + None, + page(), + |_| async { Ok(vec![preferred.clone()]) }, + ) + .await + .unwrap(); + assert_eq!(first.users[0].pubkey, preferred.pubkey.to_hex()); + assert_eq!( + base_cursor(first.next_cursor.as_deref()).as_deref(), + Some("1") + ); + let next = complete_search( + &policy, + "https://relay.example", + "Scout", + Some(1), + first.next_cursor.as_deref(), + page(), + |_| async { Ok(vec![preferred.clone()]) }, + ) + .await + .unwrap(); + assert_eq!(next.users[0].pubkey, unrelated.pubkey.to_hex()); + assert_eq!(next.next_cursor.as_deref(), Some("2")); + let later = complete_search( + &policy, + "https://relay.example", + "Scout", + Some(1), + Some("2"), + nostr_convert::list_user_search_results(&[preferred], 1), + |_| async { panic!("unexpected query") }, + ) + .await + .unwrap(); + assert!(later.users.is_empty()); +} diff --git a/desktop/src-tauri/src/commands/team_snapshot.rs b/desktop/src-tauri/src/commands/team_snapshot.rs index 9c57ce12b53..5d1988288e5 100644 --- a/desktop/src-tauri/src/commands/team_snapshot.rs +++ b/desktop/src-tauri/src/commands/team_snapshot.rs @@ -511,6 +511,7 @@ pub async fn confirm_team_snapshot_import( app: AppHandle, state: State<'_, AppState>, ) -> Result { + crate::managed_agents::device_policy::require_full_hosting(&app)?; // ── Phase 1: validate (no I/O) ─────────────────────────────────────────── let snapshot = decode_team_snapshot_from_bytes(&input.file_bytes)?; let now = now_iso(); @@ -536,7 +537,11 @@ pub async fn confirm_team_snapshot_import( let (agent_keys, private_key_nsec, pubkey, auth_tag) = { let owner_keys = state.signing_keys()?; - let agent_keys = nostr::Keys::generate(); + let agent_keys = crate::managed_agents::device_policy::generate_agent_keys( + &app, + &display_name, + None, + )?; let pubkey = agent_keys.public_key().to_hex(); let private_key_nsec = { use nostr::ToBech32; diff --git a/desktop/src-tauri/src/commands/teams/adopt.rs b/desktop/src-tauri/src/commands/teams/adopt.rs index 8b1e25cd551..781c3c4680d 100644 --- a/desktop/src-tauri/src/commands/teams/adopt.rs +++ b/desktop/src-tauri/src/commands/teams/adopt.rs @@ -59,6 +59,7 @@ pub async fn add_team_from_catalog( input: AddTeamFromCatalogRequest, app: AppHandle, ) -> Result { + crate::managed_agents::device_policy::require_full_hosting(&app)?; let source = TeamCatalogSource { owner_pubkey: input.owner_pubkey, team_d_tag: input.team_d_tag, diff --git a/desktop/src-tauri/src/commands/teams/mod.rs b/desktop/src-tauri/src/commands/teams/mod.rs index 208ac3a7117..e2f3ea875d7 100644 --- a/desktop/src-tauri/src/commands/teams/mod.rs +++ b/desktop/src-tauri/src/commands/teams/mod.rs @@ -211,6 +211,9 @@ pub(crate) fn refresh_team_catalog_heads_for_persona( state: &AppState, persona_id: &str, ) { + if crate::managed_agents::device_policy::pauses_sync(app) { + return; + } pending::refresh_shared_team_catalog_heads_for_persona(app, state, persona_id); } @@ -227,6 +230,9 @@ pub(crate) fn refresh_team_catalog_head( team: &TeamRecord, personas: &[AgentDefinition], ) { + if crate::managed_agents::device_policy::pauses_sync(app) { + return; + } pending::refresh_shared_team_catalog_head_resolving(app, state, team, personas); } @@ -241,6 +247,9 @@ pub(crate) fn tombstone_team_catalog_head( state: &AppState, d_tag: &str, ) { + if crate::managed_agents::device_policy::pauses_sync(app) { + return; + } pending::tombstone_team_catalog_pending(app, state, d_tag); } @@ -419,6 +428,7 @@ pub async fn list_teams(app: AppHandle) -> Result, String> { #[tauri::command] pub async fn create_team(input: CreateTeamRequest, app: AppHandle) -> Result { + crate::managed_agents::device_policy::require_hosting(&app)?; use tauri::Manager; tokio::task::spawn_blocking(move || { let state = app.state::(); @@ -433,6 +443,9 @@ pub async fn create_team(input: CreateTeamRequest, app: AppHandle) -> Result Result Result { + crate::managed_agents::device_policy::require_team(&app, &input.id)?; use tauri::Manager; tokio::task::spawn_blocking(move || { let state = app.state::(); @@ -482,8 +496,12 @@ pub async fn update_team(input: UpdateTeamRequest, app: AppHandle) -> Result Result Result<(), String> { + crate::managed_agents::device_policy::require_team(&app, &id)?; use tauri::Manager; tokio::task::spawn_blocking(move || { let state = app.state::(); @@ -521,6 +540,7 @@ pub async fn delete_team(id: String, app: AppHandle) -> Result<(), String> { .managed_agents_store_lock .lock() .map_err(|error| error.to_string())?; + crate::managed_agents::device_policy::require_team(&app, &id)?; let cascaded_persona_d_tags = delete_team_with_cascade(&app, &id)?; // delete_team_with_cascade rejects built-in teams via validate_team_deletion, // so reaching here means this team was owner-published — tombstone it. The diff --git a/desktop/src-tauri/src/commands/teams/sharing.rs b/desktop/src-tauri/src/commands/teams/sharing.rs index 08aeba0e95c..dd05860d703 100644 --- a/desktop/src-tauri/src/commands/teams/sharing.rs +++ b/desktop/src-tauri/src/commands/teams/sharing.rs @@ -49,6 +49,8 @@ pub async fn set_team_shared( shared: bool, app: AppHandle, ) -> Result { + crate::managed_agents::device_policy::require_full_hosting(&app)?; + crate::managed_agents::device_policy::require_team(&app, &id)?; let prepared = tokio::task::spawn_blocking({ let app = app.clone(); move || { diff --git a/desktop/src-tauri/src/egress_guard_tests.rs b/desktop/src-tauri/src/egress_guard_tests.rs index 29e74cfb506..e0fc35f0ba0 100644 --- a/desktop/src-tauri/src/egress_guard_tests.rs +++ b/desktop/src-tauri/src/egress_guard_tests.rs @@ -272,6 +272,8 @@ const EVENTS_INVENTORY: &[(&str, usize, usize)] = &[ // Test-only fixtures — no production egress, no guard: ("src/relay_admission.rs", 1, 0), ("src/archive/mod_tests.rs", 1, 0), + // Selective-flush loopback receiver; production uses guarded boundary 1. + ("src/managed_agents/persona_events.rs", 1, 0), ("src/managed_agents/persona_events/tests.rs", 1, 0), ("src/commands/team_snapshot/tests.rs", 1, 0), // Mock-relay route in its in-file tests; production publish goes through diff --git a/desktop/src-tauri/src/event_sync.rs b/desktop/src-tauri/src/event_sync.rs index ed5b9510952..fc45db8a3aa 100644 --- a/desktop/src-tauri/src/event_sync.rs +++ b/desktop/src-tauri/src/event_sync.rs @@ -18,6 +18,9 @@ pub fn run_event_sync( owner_keys: &nostr::Keys, db_path: &Path, ) -> Result<(), String> { + if crate::managed_agents::device_policy::pauses_sync(app) { + return Ok(()); + } // Persona and agent legs stay best-effort: they log and swallow, and their // failure does not undo the boot team-membership repair. The team leg is // fatal — it establishes the superseding local head (a monotonic diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 12082a2a82e..2f7fb7702fe 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -719,6 +719,8 @@ pub fn run() { get_baked_build_env_keys, get_baked_build_env, put_agent_session_config, + managed_agents::device_policy::get_agent_device_policy, + managed_agents::device_policy::set_agent_device_policy, get_global_agent_config, set_global_agent_config, mesh_start_node, diff --git a/desktop/src-tauri/src/managed_agents/device_policy.rs b/desktop/src-tauri/src/managed_agents/device_policy.rs new file mode 100644 index 00000000000..8d49c23132d --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/device_policy.rs @@ -0,0 +1,207 @@ +//! Host/observer separation belongs to the device, never synchronized definitions. + +pub(crate) mod model; +pub(crate) mod sync; +pub(crate) mod unique_names; +use model::{active_policy, load_policy, DeviceAgentPolicy}; +use serde::Serialize; +use tauri::{AppHandle, Manager}; + +use super::storage::{atomic_write_json_restricted, managed_agents_base_dir}; +use crate::app_state::AppState; + +/// Return the policy fixed for this Desktop process, including cached read errors. +pub(crate) fn active(app: &AppHandle) -> Result { + let state = app.state::(); + active_policy(&state.agent_device_policy, || { + let path = managed_agents_base_dir(app)?.join("agent-device-policy.json"); + let policy = load_policy(&path)?; + tracing::info!( + client_only = policy.client_only, + unique_names = policy.unique_names, + preferred_identities = policy.preferred_agents.len(), + "loaded device agent policy" + ); + Ok(policy) + }) + .cloned() +} + +/// Invalid policy disables automatic management while leaving chat available. +pub(crate) fn is_client_only(app: &AppHandle) -> bool { + active(app).map_or(true, |policy| policy.client_only) +} + +/// Retain old queues and keep this device's templates local in unique-name mode. +pub(crate) fn pauses_sync(app: &AppHandle) -> bool { + active(app).map_or(true, |policy| policy.client_only || policy.unique_names) +} + +/// Check both identity and name before local management or execution. +pub(crate) fn require_record( + app: &AppHandle, + record: &super::ManagedAgentRecord, +) -> Result<(), String> { + active(app)?.require_local_agent( + &record.name, + Some(&record.pubkey), + record.persona_id.as_deref(), + ) +} + +/// A presentation filter only; callers retain the complete persistent store. +pub(crate) fn can_host_record( + app: &AppHandle, + record: &super::ManagedAgentRecord, +) -> bool { + require_record(app, record).is_ok() +} + +/// Protect a stable remote definition as well as its current name. +pub(crate) fn require_persona( + app: &AppHandle, + id: &str, +) -> Result<(), String> { + let policy = active(app)?; + let personas = super::load_personas(app)?; + let name = personas + .iter() + .find(|p| p.id == id) + .map(|p| p.display_name.as_str()) + .unwrap_or(""); + policy.require_local_agent(name, None, Some(id))?; + for record in super::load_managed_agents(app)? + .iter() + .filter(|record| record.persona_id.as_deref() == Some(id)) + { + require_record(app, record)?; + } + Ok(()) +} + +/// Team edits cannot cascade into protected remote definitions. +pub(crate) fn require_team(app: &AppHandle, id: &str) -> Result<(), String> { + require_hosting(app)?; + if let Some(team) = super::load_teams(app)?.iter().find(|team| team.id == id) { + for persona_id in &team.persona_ids { + require_persona(app, persona_id)?; + } + if team.source_dir.is_some() { + let key = super::team_persona_key(team); + for persona in super::load_personas(app)? + .iter() + .filter(|p| p.source_team.as_deref() == Some(key)) + { + require_persona(app, &persona.id)?; + } + } + } + Ok(()) +} + +/// Generate an agent key only on a hosting device. The identity login/pairing +/// keys use their independent paths and are unaffected. +pub(crate) fn generate_agent_keys( + app: &AppHandle, + name: &str, + persona_id: Option<&str>, +) -> Result { + active(app)?.require_local_agent(name, None, persona_id)?; + Ok(nostr::Keys::generate()) +} + +/// Native guard shared by all key creation and execution paths. +pub(crate) fn require_hosting(app: &AppHandle) -> Result<(), String> { + active(app)?.require_hosting() +} + +/// Profile archive actions must preserve the protected remote identity too. +pub(crate) fn require_identity_archive( + app: &AppHandle, + target: &str, +) -> Result<(), String> { + let policy = active(app)?; + if !policy.client_only && !policy.unique_names { + return Ok(()); + } + let key = nostr::PublicKey::parse(target) + .map_err(|e| e.to_string())? + .to_hex(); + if policy + .preferred_agents + .iter() + .any(|agent| agent.pubkey.eq_ignore_ascii_case(&key)) + { + return Err("Manage this protected identity on the device that hosts it.".into()); + } + Ok(()) +} + +/// Batch catalog imports can rename whole teams; unique-name mode imports individually. +pub(crate) fn require_full_hosting(app: &AppHandle) -> Result<(), String> { + let policy = active(app)?; + policy.require_hosting()?; + if policy.unique_names { + return Err("This device keeps agent definitions local. Import agents individually with unique names; catalog sharing and team imports require unrestricted hosting.".into()); + } + Ok(()) +} + +/// Saved preference and effective policy are separate until Desktop restarts. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DeviceAgentPolicyStatus { + active_client_only: bool, + active_unique_names: bool, + saved: DeviceAgentPolicy, + restart_required: bool, + load_error: Option, +} + +/// Read this device's hosting setting without changing any agent definition. +#[tauri::command] +pub fn get_agent_device_policy(app: AppHandle) -> Result { + let current = active(&app); + let saved = load_policy(&managed_agents_base_dir(&app)?.join("agent-device-policy.json")); + let load_error = current.as_ref().err().or(saved.as_ref().err()).cloned(); + let active_client_only = current.as_ref().map_or(true, |policy| policy.client_only); + let active_unique_names = current.as_ref().is_ok_and(|policy| policy.unique_names); + let saved = saved.unwrap_or_else(|_| DeviceAgentPolicy { + client_only: true, + ..Default::default() + }); + let restart_required = current.as_ref() != Ok(&saved); + Ok(DeviceAgentPolicyStatus { + active_client_only, + active_unique_names, + saved, + restart_required, + load_error, + }) +} + +/// Persist an atomic, device-only preference. Execution changes after restart. +#[tauri::command] +pub fn set_agent_device_policy( + policy: DeviceAgentPolicy, + app: AppHandle, +) -> Result { + // Freeze the old policy before saving, even if no execution path has run yet. + // A malformed old file remains fail-closed until restart, but is recoverable here. + let current = active(&app); + if current.as_ref().is_ok_and(|old| { + old.unique_names && policy.unique_names && old.preferred_agents != policy.preferred_agents + }) { + return Err("The protected remote identities cannot be removed while unique-name hosting is enabled.".into()); + } + let bytes = serde_json::to_vec_pretty(&policy).map_err(|error| error.to_string())?; + if bytes.len() > 65_536 { + return Err("Agent device policy exceeds 64 KiB".into()); + } + let path = managed_agents_base_dir(&app)?.join("agent-device-policy.json"); + atomic_write_json_restricted(&path, &bytes)?; + get_agent_device_policy(app) +} + +#[cfg(test)] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/device_policy/model.rs b/desktop/src-tauri/src/managed_agents/device_policy/model.rs new file mode 100644 index 00000000000..f7ebf397046 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/device_policy/model.rs @@ -0,0 +1,369 @@ +//! Device-local agent execution policy. This file is never relay-synchronized. + +use serde::{Deserialize, Serialize}; +use std::{io::Read, path::Path, sync::OnceLock}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +/// Preferences belonging to one Desktop installation, never an agent definition. +pub struct DeviceAgentPolicy { + /// Prevent this installation from minting or executing agent identities. + pub client_only: bool, + /// Allow local hosting with distinct names while retaining remote definitions locally. + #[serde(default)] + pub unique_names: bool, + /// Exact existing identities preferred in discovery for a given owner/name. + #[serde(default)] + pub preferred_agents: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +/// A discovery preference, not an identity alias or an authorization grant. +pub struct PreferredAgent { + /// Canonical HTTP community URL. + pub relay_url: String, + /// Verified owner of the identity. + pub owner_pubkey: String, + /// Display name whose older instances are omitted from discovery. + pub name: String, + /// Exact preferred public key. Explicit historical links remain exact. + pub pubkey: String, + /// Stable definition hosted elsewhere, even if its display name changes. + #[serde(default)] + pub persona_id: Option, +} + +impl DeviceAgentPolicy { + /// Guard both identities on every edit; consult the directory only for a + /// new name. Unchanged-name local configuration edits remain usable offline. + pub async fn check_name_update( + &self, + current_name: &str, + requested_name: &str, + pubkey: Option<&str>, + persona_id: Option<&str>, + lookup: F, + ) -> Result<(), String> + where + F: FnOnce() -> Fut, + Fut: std::future::Future>, + { + self.require_local_agent(current_name, pubkey, persona_id)?; + self.require_local_agent(requested_name, pubkey, persona_id)?; + if self.unique_names + && !current_name + .trim() + .eq_ignore_ascii_case(requested_name.trim()) + { + lookup().await?; + } + Ok(()) + } + + /// Validate local management of one named identity. + pub fn require_local_agent( + &self, + name: &str, + pubkey: Option<&str>, + persona_id: Option<&str>, + ) -> Result<(), String> { + self.require_hosting()?; + if self.unique_names + && self.preferred_agents.iter().any(|agent| { + agent.name.trim().eq_ignore_ascii_case(name.trim()) + || pubkey.is_some_and(|key| key.eq_ignore_ascii_case(&agent.pubkey)) + || persona_id.is_some_and(|id| agent.persona_id.as_deref() == Some(id)) + }) + { + return Err(format!("{name} belongs to an agent hosted on another device. Use that existing identity, or choose a different name for a new local agent.")); + } + Ok(()) + } + + /// Refuse before generating keys, writing records, or spawning processes. + pub fn require_hosting(&self) -> Result<(), String> { + if self.client_only { + return Err("This device is in client-only mode. Use an existing agent from the relay, or change Agent hosting in Settings and restart Buzz.".into()); + } + Ok(()) + } + + /// Narrow discovery only within the explicitly configured owner/community. + pub fn allows_identity( + &self, + relay_url: &str, + owner: Option<&str>, + name: &str, + pubkey: &str, + ) -> bool { + self.preferred_agents.iter().all(|preferred| { + preferred.relay_url.trim_end_matches('/') != relay_url.trim_end_matches('/') + || owner != Some(preferred.owner_pubkey.as_str()) + || !preferred.name.trim().eq_ignore_ascii_case(name.trim()) + || preferred.pubkey.eq_ignore_ascii_case(pubkey) + }) + } +} + +/// Read a bounded policy file. Only a missing file inherits the historical default. +pub fn load_policy(path: &Path) -> Result { + let file = match std::fs::File::open(path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(DeviceAgentPolicy::default()) + } + Err(error) => return Err(format!("Cannot read agent device policy: {error}")), + }; + let mut bytes = Vec::new(); + file.take(65_537) + .read_to_end(&mut bytes) + .map_err(|error| format!("Cannot read agent device policy: {error}"))?; + if bytes.len() > 65_536 { + return Err("Agent device policy exceeds 64 KiB".into()); + } + let mut policy: DeviceAgentPolicy = serde_json::from_slice(&bytes) + .map_err(|error| format!("Invalid agent device policy: {error}"))?; + for (index, preferred) in policy.preferred_agents.iter_mut().enumerate() { + for (field, value) in [ + ("pubkey", &mut preferred.pubkey), + ("owner_pubkey", &mut preferred.owner_pubkey), + ] { + *value = nostr::PublicKey::from_hex(value.as_str()) + .map_err(|_| format!("Invalid agent device policy: preferred_agents[{index}].{field} must be a 64-character hexadecimal public key"))? + .to_hex(); + } + } + Ok(policy) +} + +/// Freeze execution policy for this application lifetime so a preference change +/// cannot race a suspended create/deploy operation. Changes require a restart. +pub fn active_policy( + cache: &OnceLock>, + loader: impl FnOnce() -> Result, +) -> Result<&DeviceAgentPolicy, String> { + cache.get_or_init(loader).as_ref().map_err(Clone::clone) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn local_configuration_edit_does_not_depend_on_an_online_name_directory() { + let policy = DeviceAgentPolicy { + unique_names: true, + ..Default::default() + }; + for requested in ["Notebook", " notebook "] { + policy + .check_name_update("Notebook", requested, Some("local-key"), None, || async { + Err("relay is offline".into()) + }) + .await + .unwrap(); + } + assert_eq!( + policy + .check_name_update("Notebook", "Different", Some("local-key"), None, || async { + Err("relay is offline".into()) + }) + .await, + Err("relay is offline".into()) + ); + } + + #[tokio::test] + async fn unchanged_names_still_enforce_protected_identity_guards() { + let mut policy = DeviceAgentPolicy { + unique_names: true, + ..Default::default() + }; + policy.preferred_agents.push(PreferredAgent { + relay_url: "https://relay.example".into(), + owner_pubkey: "owner".into(), + name: "Scout".into(), + pubkey: "remote-key".into(), + persona_id: Some("remote-definition".into()), + }); + for (before, after, key, persona) in [ + ("Scout", "Scout", "other-key", None), + ("Local", "Local", "remote-key", None), + ("Local", "Local", "other-key", Some("remote-definition")), + ("Scout", "Renamed", "other-key", None), + ("Local", "Scout", "other-key", None), + ] { + assert!(policy + .check_name_update(before, after, Some(key), persona, || async { Ok(()) }) + .await + .is_err()); + } + } + + #[test] + fn unique_names_allows_new_agents_but_protects_remote_names_keys_and_definitions() { + let policy: DeviceAgentPolicy = serde_json::from_str( + r#"{ + "client_only":false,"unique_names":true, + "preferred_agents":[{"relay_url":"https://relay.example","owner_pubkey":"owner", + "name":"Scout","pubkey":"remote-key","persona_id":"remote-definition"}] + }"#, + ) + .unwrap(); + assert!(policy.require_local_agent("Notebook", None, None).is_ok()); + assert!(policy.require_local_agent(" sCoUt ", None, None).is_err()); + assert!(policy + .require_local_agent("Renamed", Some("remote-key"), None) + .is_err()); + assert!(policy + .require_local_agent("Renamed", None, Some("remote-definition")) + .is_err()); + assert!(policy + .require_local_agent("Notebook", Some("local-key"), Some("local-definition")) + .is_ok()); + } + + #[test] + fn settings_changes_and_read_errors_stay_frozen_until_restart() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("policy.json"); + std::fs::write(&path, r#"{"client_only":true}"#).unwrap(); + let cache = OnceLock::new(); + assert!( + active_policy(&cache, || load_policy(&path)) + .unwrap() + .client_only + ); + std::fs::write(&path, r#"{"client_only":false}"#).unwrap(); + assert!( + active_policy(&cache, || load_policy(&path)) + .unwrap() + .client_only + ); + assert!( + !active_policy(&OnceLock::new(), || load_policy(&path)) + .unwrap() + .client_only + ); + let failed_cache = OnceLock::new(); + assert!(active_policy(&failed_cache, || Err("read failed".into())).is_err()); + assert!(active_policy(&failed_cache, || load_policy(&path)).is_err()); + } + + #[test] + fn client_only_refuses_execution_even_without_presence() { + let policy = DeviceAgentPolicy { + client_only: true, + ..Default::default() + }; + assert!(policy.require_hosting().is_err()); + } + + #[test] + fn saved_client_only_policy_survives_reopen() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("agent-device-policy.json"); + std::fs::write(&path, r#"{"client_only":true}"#).unwrap(); + assert!(load_policy(&path).unwrap().require_hosting().is_err()); + assert!(load_policy(&path).unwrap().require_hosting().is_err()); + } + + #[test] + fn unreadable_or_invalid_policy_never_enables_hosting() { + let dir = tempfile::tempdir().unwrap(); + assert!(load_policy(dir.path()).is_err()); + let path = dir.path().join("policy.json"); + for bytes in ["broken", "{}", r#"{"client_only":"true"}"#] { + std::fs::write(&path, bytes).unwrap(); + assert!(load_policy(&path).is_err(), "accepted {bytes}"); + } + } + + #[test] + fn policy_load_rejects_malformed_preferred_keys_and_normalizes_valid_hex() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("policy.json"); + let owner = nostr::Keys::generate().public_key().to_hex(); + let agent = nostr::Keys::generate().public_key().to_hex(); + for (field, value) in [ + ("pubkey", "typo"), + ("owner_pubkey", "typo"), + ("pubkey", ""), + ("pubkey", "gggg"), + ] { + let mut input = serde_json::json!({"client_only": false, "preferred_agents": [{ + "relay_url": "https://relay.example", "name": "Scout", + "owner_pubkey": owner, "pubkey": agent + }]}); + input["preferred_agents"][0][field] = value.into(); + std::fs::write(&path, serde_json::to_vec(&input).unwrap()).unwrap(); + assert!(load_policy(&path).is_err(), "accepted invalid {field}"); + } + let input = serde_json::json!({"client_only": false, "preferred_agents": [{ + "relay_url": "https://relay.example", "name": "Scout", + "owner_pubkey": owner.to_uppercase(), "pubkey": agent.to_uppercase() + }]}); + std::fs::write(&path, serde_json::to_vec(&input).unwrap()).unwrap(); + let loaded = load_policy(&path).unwrap(); + assert_eq!(loaded.preferred_agents[0].pubkey, agent); + assert_eq!(loaded.preferred_agents[0].owner_pubkey, owner); + } + + #[test] + fn unconfigured_devices_keep_existing_hosting_behavior() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("absent.json"); + assert!(load_policy(&path).unwrap().require_hosting().is_ok()); + std::fs::write(&path, r#"{"client_only":false}"#).unwrap(); + assert!(load_policy(&path).unwrap().require_hosting().is_ok()); + } + + #[test] + fn preferred_identity_is_stable_and_scoped_to_owner_and_community() { + let policy = DeviceAgentPolicy { + client_only: true, + unique_names: false, + preferred_agents: vec![PreferredAgent { + relay_url: "https://relay.example".into(), + owner_pubkey: "a".repeat(64), + name: "Scout".into(), + pubkey: "b".repeat(64), + persona_id: None, + }], + }; + let owner = "a".repeat(64); + // There is deliberately no presence input: offline does not mean replaceable. + assert!(policy.allows_identity( + "https://relay.example", + Some(&owner), + "Scout", + &"b".repeat(64) + )); + assert!(!policy.allows_identity( + "https://relay.example", + Some(&owner), + "Scout", + &"c".repeat(64) + )); + assert!(policy.allows_identity( + "https://other.example", + Some(&owner), + "Scout", + &"c".repeat(64) + )); + assert!(policy.allows_identity( + "https://relay.example", + Some(&"d".repeat(64)), + "Scout", + &"c".repeat(64) + )); + assert!(policy.allows_identity("https://relay.example", None, "Scout", &"c".repeat(64))); + assert!(policy.allows_identity( + "https://relay.example", + Some(&owner), + "Unrelated", + &"c".repeat(64) + )); + } +} diff --git a/desktop/src-tauri/src/managed_agents/device_policy/sync.rs b/desktop/src-tauri/src/managed_agents/device_policy/sync.rs new file mode 100644 index 00000000000..0e49d283939 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/device_policy/sync.rs @@ -0,0 +1,119 @@ +//! Release only lifecycle coordinates explicitly authored for a local identity. +use std::collections::HashSet; + +/// Decide whether a pending coordinate belongs to a registered local identity. +pub(crate) fn allows_coordinate(keys: &HashSet, kind: u32, d_tag: &str) -> bool { + match kind { + 30177 | 9035 => keys.contains(d_tag), + 5 => d_tag + .strip_prefix("30177:") + .is_some_and(|key| keys.contains(key)), + _ => false, + } +} + +fn ensure_table(conn: &rusqlite::Connection) -> Result<(), String> { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS device_local_agent_keys (pubkey TEXT PRIMARY KEY NOT NULL)", + ) + .map_err(|e| format!("Cannot initialize local agent sync permissions: {e}")) +} + +/// Called only by the explicit local lifecycle retention path, after its record +/// is saved. Inbound replay and old queue scans never enroll identities here. +pub(crate) fn register(conn: &rusqlite::Connection, pubkey: &str) -> Result<(), String> { + ensure_table(conn)?; + let inserted = conn + .execute( + "INSERT OR IGNORE INTO device_local_agent_keys (pubkey) + SELECT ?1 WHERE (SELECT COUNT(*) FROM device_local_agent_keys) < 5000", + [pubkey], + ) + .map_err(|e| format!("Cannot retain local agent sync permission: {e}"))?; + if inserted == 0 { + let exists: bool = conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM device_local_agent_keys WHERE pubkey = ?1)", + [pubkey], + |row| row.get(0), + ) + .map_err(|e| format!("Cannot read local agent sync permission: {e}"))?; + if !exists { + return Err("Local agent sync permission limit reached (5000 identities)".into()); + } + } + Ok(()) +} + +/// Durable across deletion and restart so a failed local archive can retry. +pub(crate) fn registered(conn: &rusqlite::Connection) -> Result, String> { + ensure_table(conn)?; + let mut statement = conn + .prepare("SELECT pubkey FROM device_local_agent_keys LIMIT 5001") + .map_err(|e| e.to_string())?; + let keys: HashSet = statement + .query_map([], |row| row.get(0)) + .map_err(|e| e.to_string())? + .collect::>() + .map_err(|e| e.to_string())?; + if keys.len() > 5000 { + return Err("Local agent sync permission limit exceeded".into()); + } + Ok(keys) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn full_registry_refuses_new_keys_but_keeps_existing_publication_working() { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + ensure_table(&conn).unwrap(); + conn.execute_batch( + "WITH RECURSIVE keys(n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM keys WHERE n < 5000) + INSERT INTO device_local_agent_keys SELECT printf('key-%d', n) FROM keys;", + ) + .unwrap(); + register(&conn, "key-1").unwrap(); + assert!(register(&conn, "overflow").is_err()); + let keys = registered(&conn).unwrap(); + assert_eq!(keys.len(), 5000); + assert!(allows_coordinate(&keys, 9035, "key-1")); + } + #[test] + fn local_agent_publication_never_releases_old_deletions_or_runnable_templates() { + let keys = HashSet::from(["new-local-key".into()]); + assert!(allows_coordinate(&keys, 30177, "new-local-key")); + assert!(allows_coordinate(&keys, 5, "30177:new-local-key")); + assert!(allows_coordinate(&keys, 9035, "new-local-key")); + for (kind, coordinate) in [ + (30177, "remote-key"), + (5, "30177:remote-key"), + (9035, "remote-key"), + (30175, "new-local-key"), + (30176, "new-local-key"), + (30178, "new-local-key"), + (5, "30175:new-local-key"), + ] { + assert!( + !allows_coordinate(&keys, kind, coordinate), + "released {kind}:{coordinate}" + ); + } + } + + #[test] + fn only_registered_local_keys_survive_database_reopen_for_retry() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("retention.sqlite"); + let conn = rusqlite::Connection::open(&path).unwrap(); + assert!(registered(&conn).unwrap().is_empty()); + register(&conn, "local-key").unwrap(); + drop(conn); + let conn = rusqlite::Connection::open(path).unwrap(); + let keys = registered(&conn).unwrap(); + assert!(allows_coordinate(&keys, 9035, "local-key")); + assert!(!allows_coordinate(&keys, 5, "30177:historical-key")); + } +} diff --git a/desktop/src-tauri/src/managed_agents/device_policy/tests.rs b/desktop/src-tauri/src/managed_agents/device_policy/tests.rs new file mode 100644 index 00000000000..42eb282b48a --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/device_policy/tests.rs @@ -0,0 +1,108 @@ +use super::*; +use crate::app_state::build_app_state; +use std::sync::atomic::Ordering; + +fn app_with_policy( + policy: Result, +) -> tauri::App { + let state = build_app_state(); + state.agent_device_policy.set(policy).unwrap(); + // A missing guard must fail before filesystem/network access in these tests. + state.keyring_locked.store(true, Ordering::Release); + tauri::test::mock_builder() + .manage(state) + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .unwrap() +} + +#[test] +fn client_only_native_key_generation_refuses_before_minting() { + let app = app_with_policy(Ok(DeviceAgentPolicy { + client_only: true, + ..Default::default() + })); + let error = generate_agent_keys(app.handle(), "Notebook", None).unwrap_err(); + assert!(error.contains("client-only")); +} + +#[test] +fn invalid_policy_cannot_fall_back_to_key_generation() { + let app = app_with_policy(Err("policy unreadable".into())); + assert_eq!( + generate_agent_keys(app.handle(), "Notebook", None).unwrap_err(), + "policy unreadable" + ); + assert!(is_client_only(app.handle())); +} + +#[test] +fn hosting_native_key_generation_still_works() { + let app = app_with_policy(Ok(DeviceAgentPolicy::default())); + assert!(generate_agent_keys(app.handle(), "Notebook", None).is_ok()); +} + +#[test] +fn archive_guard_protects_remote_hex_and_npub_but_allows_other_identities() { + use nostr::ToBech32; + let remote = nostr::Keys::generate().public_key(); + let mut policy = DeviceAgentPolicy { + unique_names: true, + ..Default::default() + }; + policy.preferred_agents.push(model::PreferredAgent { + name: "Scout".into(), + pubkey: remote.to_hex(), + owner_pubkey: "owner".into(), + relay_url: "https://relay.example".into(), + persona_id: None, + }); + let app = app_with_policy(Ok(policy)); + assert!(require_identity_archive(app.handle(), &remote.to_hex()).is_err()); + assert!(require_identity_archive(app.handle(), &remote.to_bech32().unwrap()).is_err()); + assert!( + require_identity_archive(app.handle(), &nostr::Keys::generate().public_key().to_hex()) + .is_ok() + ); +} + +#[test] +fn unique_name_native_mint_allows_new_name_and_refuses_reserved_identity() { + let policy: DeviceAgentPolicy = serde_json::from_str( + r#"{ + "client_only":false,"unique_names":true,"preferred_agents":[{ + "relay_url":"https://relay.example","owner_pubkey":"owner","name":"Scout", + "pubkey":"remote-key","persona_id":"remote-persona"}]}"#, + ) + .unwrap(); + let app = app_with_policy(Ok(policy)); + assert!(generate_agent_keys(app.handle(), "Notebook", None).is_ok()); + assert!(generate_agent_keys(app.handle(), " scout ", None) + .unwrap_err() + .contains("another device")); + assert!(generate_agent_keys(app.handle(), "Renamed", Some("remote-persona")).is_err()); + assert!(!is_client_only(app.handle())); + assert!(pauses_sync(app.handle())); +} + +#[tokio::test] +async fn client_only_skips_actual_pending_flush_without_requesting_signing_keys() { + let app = app_with_policy(Ok(DeviceAgentPolicy { + client_only: true, + ..Default::default() + })); + let result = crate::managed_agents::persona_events::flush_active_pending_events( + app.handle(), + &app.state(), + ) + .await; + assert_eq!(result, Ok(0)); + let host = app_with_policy(Ok(DeviceAgentPolicy::default())); + assert!( + crate::managed_agents::persona_events::flush_active_pending_events( + host.handle(), + &host.state() + ) + .await + .is_err() + ); +} diff --git a/desktop/src-tauri/src/managed_agents/device_policy/unique_names.rs b/desktop/src-tauri/src/managed_agents/device_policy/unique_names.rs new file mode 100644 index 00000000000..11cd8016e6d --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/device_policy/unique_names.rs @@ -0,0 +1,144 @@ +//! Name checks for explicit local creation. Presence never makes a name reusable. +use crate::{ + app_state::AppState, + managed_agents::{load_managed_agents, load_personas}, +}; +use tauri::AppHandle; + +/// Refuse a second identity with the same name, permitting updates of the exact key. +pub(crate) fn reject_collision<'a>( + name: &str, + except: Option<&str>, + entries: impl IntoIterator, +) -> Result<(), String> { + if entries.into_iter().any(|(existing, id)| { + existing.trim().eq_ignore_ascii_case(name.trim()) && except != Some(id) + }) { + return Err(format!("An agent named {name} already exists. Use its existing identity or choose a unique name.")); + } + Ok(()) +} + +/// Check the current owner/community before minting or renaming. Callers hold the +/// device's async name-transition lock across this check and their final persist. +/// A full or unavailable directory cannot prove uniqueness and fails closed. +pub(crate) async fn preflight( + app: &AppHandle, + state: &AppState, + name: &str, + persona_id: Option<&str>, + existing_pubkey: Option<&str>, +) -> Result<(), String> { + let policy = super::active(app)?; + policy.require_local_agent(name, existing_pubkey, persona_id)?; + if !policy.unique_names { + return Ok(()); + } + let records = load_managed_agents(app)?; + reject_collision( + name, + existing_pubkey, + records.iter().map(|r| (r.name.as_str(), r.pubkey.as_str())), + )?; + let personas = load_personas(app)?; + reject_collision( + name, + persona_id, + personas + .iter() + .map(|p| (p.display_name.as_str(), p.id.as_str())), + )?; + let keys = state.signing_keys()?; + let owner = keys.public_key().to_hex(); + let relay = crate::relay::relay_api_base_url_with_override(state); + // Owner-authored instance records cover exact names that full-text search + // cannot tokenize (including punctuation and emoji). Never infer vacancy + // from an incomplete owner directory. + let owned = crate::relay::query_relay_at_with_keys( + state, + &relay, + &[serde_json::json!({ + "kinds":[30177], "authors":[owner], "limit":500 + })], + &keys, + None, + ) + .await?; + if owned.len() >= 500 { + return Err("Cannot verify uniqueness: the owned-agent directory is incomplete.".into()); + } + for event in owned { + if event.pubkey != keys.public_key() || event.kind.as_u16() != 30177 { + continue; + } + event + .verify() + .map_err(|e| format!("Invalid owned-agent directory entry: {e}"))?; + let content: serde_json::Value = + serde_json::from_str(&event.content).map_err(|e| e.to_string())?; + let existing_name = content + .get("name") + .and_then(serde_json::Value::as_str) + .unwrap_or(""); + let existing_key = event + .tags + .iter() + .find_map(|tag| { + let values = tag.as_slice(); + (values.first().map(String::as_str) == Some("d")) + .then(|| values.get(1).map(String::as_str)) + .flatten() + }) + .unwrap_or(""); + reject_collision(name, existing_pubkey, [(existing_name, existing_key)])?; + } + let events = crate::relay::query_relay_at_with_keys( + state, + &relay, + &[serde_json::json!({ + "kinds":[0], "search":name.trim(), "search_mode":"prefix", "limit":500 + })], + &keys, + None, + ) + .await?; + if events.len() >= 500 { + return Err("Cannot verify a unique agent name: too many matching profiles. Choose a more distinctive name.".into()); + } + for event in events { + if crate::nostr_convert::profile_valid_oa_owner_pubkey(&event).as_deref() != Some(&owner) { + continue; + } + let profile = crate::nostr_convert::user_search_result_from_event(&event); + reject_collision( + name, + existing_pubkey, + [( + profile.display_name.as_deref().unwrap_or(""), + profile.pubkey.as_str(), + )], + )?; + } + crate::relay::assert_expected_relay_scope( + Some(&relay), + &crate::relay::relay_api_base_url_with_override(state), + )?; + crate::relay::assert_expected_signer( + Some(&owner), + &state.signing_keys()?.public_key().to_hex(), + )?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_the_exact_existing_identity_can_keep_a_name() { + assert!(reject_collision(" Scout ", None, [("sCoUt", "old-key")]).is_err()); + assert!(reject_collision("Scout", Some("new-key"), [("Scout", "old-key")]).is_err()); + assert!(reject_collision("Scout", Some("old-key"), [("Scout", "old-key")]).is_ok()); + assert!(reject_collision("Notebook", None, [("Scout", "old-key")]).is_ok()); + } +} diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index a66f9c75ba2..aa5b2277569 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -16,6 +16,7 @@ pub(crate) mod claude_config; pub(crate) mod config_bridge; pub(crate) mod custom_harnesses; mod definition_validation; +pub(crate) mod device_policy; mod discovery; pub(crate) mod effective_config; mod env_vars; diff --git a/desktop/src-tauri/src/managed_agents/persona_events.rs b/desktop/src-tauri/src/managed_agents/persona_events.rs index fa80b456a07..de87644699c 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events.rs @@ -293,11 +293,35 @@ pub async fn flush_pending_events( /// The scope snapshots its relay, owner keys, and database path together /// before network work starts. Switching communities during the flush cannot /// redirect rows from the old scope into the new relay. -pub async fn flush_active_pending_events( - app: &tauri::AppHandle, +pub async fn flush_active_pending_events( + app: &tauri::AppHandle, state: &AppState, ) -> Result { + if super::device_policy::is_client_only(app) { + return Ok(0); + } let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + let policy = super::device_policy::active(app)?; + if policy.unique_names { + let mut keys = { + let conn = crate::managed_agents::retention::open_retention_db(&scope.db_path)?; + super::device_policy::sync::registered(&conn)? + }; + keys.retain(|key| { + !policy + .preferred_agents + .iter() + .any(|agent| agent.pubkey.eq_ignore_ascii_case(key)) + }); + return flush_pending_events_selected( + &scope.db_path, + state, + &scope.relay_url, + &scope.owner_keys, + Some(keys), + ) + .await; + } flush_pending_events_at(&scope.db_path, state, &scope.relay_url, &scope.owner_keys).await } @@ -321,6 +345,146 @@ pub(crate) async fn flush_pending_events_at( state: &AppState, relay_url: &str, owner_keys: &nostr::Keys, +) -> Result { + flush_pending_events_selected(db_path, state, relay_url, owner_keys, None).await +} + +#[cfg(test)] +mod unique_name_sync_tests { + use super::*; + + #[tokio::test] + async fn selective_publisher_delivers_local_lifecycle_and_preserves_old_backlog() { + use crate::managed_agents::retention::{ + get_pending_sync, open_retention_db, retain_event, RetainedEvent, + }; + use nostr::JsonUtil; + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("queue.sqlite"); + let keys = nostr::Keys::generate(); + let target = nostr::Keys::generate().public_key().to_hex(); + let old_target = nostr::Keys::generate().public_key().to_hex(); + let state = crate::app_state::build_app_state(); + *state.keys.lock().unwrap() = keys.clone(); + let received = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let received_by_server = received.clone(); + let server = axum::Router::new().route("/events", axum::routing::post(move |body: String| { + let received = received_by_server.clone(); + async move { + let event: nostr::Event = nostr::Event::from_json(body).unwrap(); + event.verify().unwrap(); + received.lock().unwrap().push(event.kind.as_u16() as u32); + axum::Json(serde_json::json!({"event_id":event.id.to_hex(),"accepted":true,"message":""})) + } + })); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let server_task = tokio::spawn(async move { + axum::serve(listener, server).await.unwrap(); + }); + { + let conn = open_retention_db(&path).unwrap(); + for (kind, d_tag) in [ + (30177, target.clone()), + (5, format!("30177:{target}")), + (9035, target.clone()), + (30177, old_target.clone()), + (5, format!("30177:{old_target}")), + (30175, "local-template".into()), + ] { + let event = nostr::EventBuilder::new(nostr::Kind::Custom(kind as u16), "{}") + .tags([nostr::Tag::parse(["d", &d_tag]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + retain_event( + &conn, + &RetainedEvent { + kind, + pubkey: keys.public_key().to_hex(), + d_tag, + content: event.content.clone(), + created_at: event.created_at.as_secs() as i64, + raw_event: event.as_json(), + pending_sync: true, + }, + ) + .unwrap(); + } + } + let result = flush_pending_events_selected( + &path, + &state, + &url, + &keys, + Some(std::collections::HashSet::from([target])), + ) + .await; + server_task.abort(); + assert_eq!(result, Ok(3)); + let mut kinds = received.lock().unwrap().clone(); + kinds.sort(); + assert_eq!(kinds, vec![5, 9035, 30177]); + let remaining = get_pending_sync(&open_retention_db(&path).unwrap()).unwrap(); + assert_eq!(remaining.len(), 3); + assert!(remaining.iter().any(|row| row.d_tag == old_target)); + assert!(remaining.iter().any(|row| row.kind == 30175)); + } + + #[tokio::test] + async fn selective_publisher_leaves_unregistered_backlog_and_templates_untouched() { + use crate::managed_agents::retention::{ + get_pending_sync, open_retention_db, retain_event, RetainedEvent, + }; + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("queue.sqlite"); + let keys = nostr::Keys::generate(); + let state = crate::app_state::build_app_state(); + { + let conn = open_retention_db(&path).unwrap(); + for (kind, d_tag) in [ + (30177, "old-remote"), + (5, "30177:old-remote"), + (30175, "new-local"), + ] { + retain_event( + &conn, + &RetainedEvent { + kind, + pubkey: keys.public_key().to_hex(), + d_tag: d_tag.into(), + content: "old backlog".into(), + created_at: 1, + raw_event: "must never be parsed or submitted".into(), + pending_sync: true, + }, + ) + .unwrap(); + } + } + let result = flush_pending_events_selected( + &path, + &state, + "http://127.0.0.1:1", + &keys, + Some(std::collections::HashSet::from(["new-local".into()])), + ) + .await; + assert_eq!(result, Ok(0)); + assert_eq!( + get_pending_sync(&open_retention_db(&path).unwrap()) + .unwrap() + .len(), + 3 + ); + } +} + +async fn flush_pending_events_selected( + db_path: &std::path::Path, + state: &AppState, + relay_url: &str, + owner_keys: &nostr::Keys, + local_keys: Option>, ) -> Result { use crate::managed_agents::retention::{ deferred_behind_failed_tombstone, get_pending_sync, get_retained_event, mark_synced, @@ -352,6 +516,11 @@ pub(crate) async fn flush_pending_events_at( let mut failed_tombstones: std::collections::HashSet<(String, String)> = std::collections::HashSet::new(); for row in pending { + if local_keys.as_ref().is_some_and(|keys| { + !super::device_policy::sync::allows_coordinate(keys, row.kind, &row.d_tag) + }) { + continue; + } if row.pubkey != owner_pubkey { continue; } diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index 5b79ccac27f..dc31a03f4ee 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -86,6 +86,16 @@ pub fn backfill_persona_snapshots(app: &tauri::AppHandle) -> Result<(), String> Ok(()) } +// Keep cleanup and the restore decision in one production seam so client-only +// startup cannot bypass stale-process reaping after a prior Desktop crash. +fn prepare_restore_housekeeping( + client_only: bool, + housekeeping: impl FnOnce() -> Result, +) -> Result, String> { + let state = housekeeping()?; + Ok((!client_only).then_some(state)) +} + /// Restore managed agents that were running before the app was closed. /// /// Split into three phases to minimise lock contention with the frontend: @@ -96,6 +106,7 @@ pub async fn restore_managed_agents_on_launch( app: &tauri::AppHandle, shutdown_started: &AtomicBool, ) -> Result<(), String> { + let client_only = super::device_policy::is_client_only(app); if shutdown_started.load(Ordering::SeqCst) { return Ok(()); } @@ -124,56 +135,70 @@ pub async fn restore_managed_agents_on_launch( .managed_agent_processes .lock() .map_err(|error| error.to_string())?; - let (mut changed, _exited) = sync_managed_agent_processes( - &mut records, - &mut runtimes, - &super::current_instance_id(app), - ); - changed |= - kill_stale_tracked_processes(&mut records, &runtimes, &super::current_instance_id(app)); - - let tracked_pids: Vec = runtimes - .values() - .map(|runtime| runtime.child.id()) - .chain( - super::read_all_agent_runtime_receipts(app) - .into_iter() - .filter_map(|(path, receipt)| { - super::valid_agent_runtime_receipt( - &path, - &receipt, - &super::current_instance_id(app), - ) - .then_some(receipt.pid) - }), - ) - .collect(); - super::sweep_orphaned_agent_processes(app, &tracked_pids); - - // System-wide sweep: enumerate all user processes and kill any known - // agent binaries not tracked by this session. Catches orphans whose - // PID files were already cleaned up (e.g. agent workers in their own - // process group whose parent harness exited). - super::sweep_system_agent_processes(&super::current_instance_id(app), &tracked_pids); - - // Dead-instance reaping: find agents belonging to Buzz instances - // whose desktop process is no longer running and reap them. - super::reap_dead_instance_agents(&super::current_instance_id(app), &tracked_pids); - - // Exact-path sweep: kill any buzz-acp process whose executable path - // matches this bundle's harness binary but is not in the tracked set. - // Complements the env-var sweep above — catches orphans that predate - // BUZZ_MANAGED_AGENT injection or lost their PID-file receipt. - // - // TODO: the three sweeps above each walk the PID table independently. - // A future consolidation should collect a single shared process snapshot - // at the top of this block and thread it through all sweep functions, - // replacing the three separate kernel enumerations. - super::sweep_untracked_bundle_harnesses(&tracked_pids); + let Some(mut changed) = prepare_restore_housekeeping(client_only, || { + let (mut changed, _exited) = sync_managed_agent_processes( + &mut records, + &mut runtimes, + &super::current_instance_id(app), + ); + changed |= kill_stale_tracked_processes( + &mut records, + &runtimes, + &super::current_instance_id(app), + ); + + let tracked_pids: Vec = runtimes + .values() + .map(|runtime| runtime.child.id()) + .chain( + super::read_all_agent_runtime_receipts(app) + .into_iter() + .filter_map(|(path, receipt)| { + super::valid_agent_runtime_receipt( + &path, + &receipt, + &super::current_instance_id(app), + ) + .then_some(receipt.pid) + }), + ) + .collect(); + super::sweep_orphaned_agent_processes(app, &tracked_pids); + + // System-wide sweep: enumerate all user processes and kill any known + // agent binaries not tracked by this session. Catches orphans whose + // PID files were already cleaned up (e.g. agent workers in their own + // process group whose parent harness exited). + super::sweep_system_agent_processes(&super::current_instance_id(app), &tracked_pids); + + // Dead-instance reaping: find agents belonging to Buzz instances + // whose desktop process is no longer running and reap them. + super::reap_dead_instance_agents(&super::current_instance_id(app), &tracked_pids); + + // Exact-path sweep: kill any buzz-acp process whose executable path + // matches this bundle's harness binary but is not in the tracked set. + // Complements the env-var sweep above — catches orphans that predate + // BUZZ_MANAGED_AGENT injection or lost their PID-file receipt. + // + // TODO: the three sweeps above each walk the PID table independently. + // A future consolidation should collect a single shared process snapshot + // at the top of this block and thread it through all sweep functions, + // replacing the three separate kernel enumerations. + super::sweep_untracked_bundle_harnesses(&tracked_pids); + + if client_only && changed { + save_managed_agents(app, &records)?; + } + Ok(changed) + })? + else { + return Ok(()); + }; let candidates: Vec = records .iter() .filter(|record| record.start_on_app_launch && record.backend == BackendKind::Local) + .filter(|record| super::device_policy::can_host_record(app, record)) .map(|record| record.pubkey.clone()) .collect(); @@ -499,6 +524,9 @@ fn profile_reconcile_completed(outcome: crate::commands::ProfileReconcileOutcome } pub(crate) fn spawn_pending_profile_reconciliations(app: &tauri::AppHandle, workspace_relay: &str) { + if super::device_policy::pauses_sync(app) { + return; + } let state = app.state::(); if !state .managed_agent_profile_reconcile_enabled() @@ -578,3 +606,42 @@ fn persist_restore_error( record.last_error = Some(error); save_managed_agents(app, &records) } + +#[cfg(test)] +mod device_policy_restore_tests { + use super::prepare_restore_housekeeping; + use std::cell::Cell; + + #[test] + fn client_only_restore_runs_housekeeping_before_suppressing_spawns() { + let cleaned = Cell::new(false); + let result = prepare_restore_housekeeping(true, || { + cleaned.set(true); + Ok(vec!["would-be-started-agent"]) + }) + .unwrap(); + assert!(cleaned.get(), "client-only mode skipped crash cleanup"); + assert!( + result.is_none(), + "client-only mode released restore candidates" + ); + } + + #[test] + fn cleanup_errors_propagate_in_both_hosting_modes() { + for client_only in [true, false] { + assert_eq!( + prepare_restore_housekeeping::<()>(client_only, || Err("cleanup failed".into())), + Err("cleanup failed".into()) + ); + } + } + + #[test] + fn hosting_restoration_receives_cleaned_state() { + assert_eq!( + prepare_restore_housekeeping(false, || Ok(42)).unwrap(), + Some(42) + ); + } +} diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index b8d586b32af..83f76b09cdf 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -452,6 +452,7 @@ pub fn spawn_agent_child( owner_hex: Option<&str>, replay_floor_unix: Option, ) -> Result { + super::device_policy::require_record(app, record)?; if let Some(error) = spawn_key_refusal(record) { return Err(error); } diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands.rs b/desktop/src-tauri/src/managed_agents/runtime_commands.rs index ba0f91c9f7a..4d2ffafb5c9 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_commands.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_commands.rs @@ -142,6 +142,9 @@ pub fn put_managed_agent_runtime_lifecycle( pub async fn list_managed_agent_runtimes( app: AppHandle, ) -> Result, String> { + if super::device_policy::is_client_only(&app) { + return Ok(Vec::new()); + } tokio::task::spawn_blocking(move || { // This command is polled whenever the members sidebar opens and refetched // on every status event — load the per-row status inputs once, outside @@ -248,6 +251,7 @@ fn start_pair( expected_updated_at: Option<&str>, app: AppHandle, ) -> Result { + super::device_policy::require_hosting(&app)?; let state = app.state::(); let _transition = state .managed_agent_runtime_transition @@ -262,6 +266,7 @@ fn start_pair( .map_err(|e| e.to_string())?; let mut records = load_managed_agents(&app)?; let record = find_managed_agent_mut(&mut records, &pubkey)?; + super::device_policy::require_record(&app, record)?; if record.backend != BackendKind::Local { return Err("managed runtime pairs require a local agent".into()); } @@ -332,6 +337,7 @@ pub fn stop_managed_agent_runtime( .map_err(|e| e.to_string())?; let mut records = load_managed_agents(&app)?; let record = find_managed_agent_mut(&mut records, &pubkey)?; + super::device_policy::require_record(&app, record)?; let key = ManagedAgentRuntimeKey::new(pubkey, &relay_url)?; let mut runtimes = state .managed_agent_processes @@ -468,6 +474,9 @@ pub async fn reconcile_managed_agent_runtimes( app: AppHandle, ) -> Result, String> { use futures_util::{stream, StreamExt}; + if super::device_policy::is_client_only(&app) { + return Ok(Vec::new()); + } let records = load_managed_agents(&app)?; let mut jobs = Vec::new(); @@ -475,6 +484,7 @@ pub async fn reconcile_managed_agent_runtimes( for record in records .iter() .filter(|record| record.start_on_app_launch && record.backend == BackendKind::Local) + .filter(|record| super::device_policy::can_host_record(&app, record)) // The legacy per-record relay pin is deliberately ignored here — see // `effective_agent_relay_url`. Every local auto-start agent fans out // to every configured community. diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index dfb9c0ed494..152d8e9d9b0 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -308,6 +308,29 @@ with a TypeScript lookup table or an id comparison in a component. 17. **Databricks model discovery has one shared catalog authority.** Desktop and ACP call the shared `buzz-agent` discovery library; Desktop passes the effective merged `DATABRICKS_MODEL_FILTER` explicitly, and the library applies it to raw workspace endpoint IDs and Unity Catalog model-service FQNs after the additive union. A successful filtered-empty catalog is authoritative: it stays empty, disables switching, and never falls through to configured or known-model fallback. UC FQNs are catalog data and always use the MLflow Chat Completions route, regardless of family-looking text in their components. Global Defaults preserves the discovered model ID as the selected value while its closed trigger renders the provider-scoped display label; do not force the raw persisted ID over that label. +## Device-only hosting policy + +Client-only mode is an installation preference, never a synchronized definition +field. Native mint, import, execute, deploy and definition-management boundaries +enforce it. Local runnable inventory is empty; `list_personas` projects inactive +definitions without saving them. Do not infer permission to host from relay +presence or create a secretless managed record for a remotely hosted identity. +Automatic control-plane publication pauses while inbound public state still +applies. Preferred exact identities narrow discovery within one owner/community; +explicit historical keys remain exact. See `docs/agent-device-policy.md` at the +repository root. Configuration edits to these rules must preserve the native +guards and the restart boundary. + +Unique-name hosting separates execution from automatic control-plane sync: +`client_only: false, unique_names: true` permits distinct local names while +protecting remote names, keys and definition IDs. Apply the guard to the old +identity before edits, to proposed names, and to indirect persona/team cascades. +Only explicit local lifecycle retention can register keys for selective 30177, +deletion and archive publication. Inbound replay and old queue scans cannot +register keys. Runnable templates and the old backlog remain local. Never reuse +the execution predicate to resume the whole queue. Discovery visibility controls +must not remove the bindings used for execution protection. + ## Channel-only runtime controls Desktop observer controls identify a channel, not a thread session. The harness diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index a31fec44c49..287783b1a2a 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -1,3 +1,4 @@ +import { useDeviceAgentPolicy } from "@/features/agents/useDeviceAgentPolicy"; import * as React from "react"; import { EllipsisVertical, OctagonX, Settings2 } from "lucide-react"; import { @@ -38,6 +39,7 @@ import { PageHeader } from "@/shared/ui/PageHeader"; import { getInheritedAgentDefaults } from "./bakedEnvHelpers"; export function AgentsView() { + const devicePolicy = useDeviceAgentPolicy(); const { openPersonaProfilePanel, openProfilePanel } = useProfilePanel(); const { globalConfig } = useGlobalAgentConfig(); const { data: bakedEnv } = useBakedBuildEnvQuery({ enabled: true }); @@ -143,6 +145,27 @@ export function AgentsView() { className="mx-auto w-full max-w-6xl space-y-8 [container-type:inline-size]" data-testid="agents-page-content" > + {devicePolicy.data?.activeClientOnly && ( +

+ Client-only mode is active. Use existing agents through the relay + and manage their configuration on the device that hosts them. + Change this device's mode in Settings → Agents. +

+ )} + {devicePolicy.data?.activeUniqueNames && + !devicePolicy.data.activeClientOnly && ( +

+ This device can host agents with unique names. Protected remote + agents use their existing identities and stay on their hosting + device. +

+ )} diff --git a/desktop/src/features/agents/useDeviceAgentPolicy.ts b/desktop/src/features/agents/useDeviceAgentPolicy.ts new file mode 100644 index 00000000000..98ac2d164fb --- /dev/null +++ b/desktop/src/features/agents/useDeviceAgentPolicy.ts @@ -0,0 +1,40 @@ +import { useQuery } from "@tanstack/react-query"; +import { invokeTauri } from "@/shared/api/tauri"; + +export type DeviceAgentPolicy = { + client_only: boolean; + unique_names?: boolean; + preferred_agents: { + relay_url: string; + owner_pubkey: string; + name: string; + pubkey: string; + persona_id?: string | null; + }[]; +}; + +export type DeviceAgentPolicyStatus = { + activeClientOnly: boolean; + activeUniqueNames?: boolean; + saved: DeviceAgentPolicy; + restartRequired: boolean; + loadError: string | null; +}; + +export const deviceAgentPolicyQueryKey = ["agent-device-policy"] as const; + +export function useDeviceAgentPolicy() { + return useQuery({ + queryKey: deviceAgentPolicyQueryKey, + queryFn: () => + invokeTauri("get_agent_device_policy"), + staleTime: Number.POSITIVE_INFINITY, + retry: false, + }); +} + +export function saveDeviceAgentPolicy(policy: DeviceAgentPolicy) { + return invokeTauri("set_agent_device_policy", { + policy, + }); +} diff --git a/desktop/src/features/settings/ui/AgentHostingSettingsCard.test.mjs b/desktop/src/features/settings/ui/AgentHostingSettingsCard.test.mjs new file mode 100644 index 00000000000..9a643db6ddc --- /dev/null +++ b/desktop/src/features/settings/ui/AgentHostingSettingsCard.test.mjs @@ -0,0 +1,180 @@ +import assert from "node:assert/strict"; +import { afterEach, before, test } from "node:test"; +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); +Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + MutationObserver: dom.window.MutationObserver, + localStorage: dom.window.localStorage, + IS_REACT_ACT_ENVIRONMENT: true, +}); +Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: dom.window.navigator, +}); +const preferred = [ + { + relay_url: "https://relay.example", + owner_pubkey: "a".repeat(64), + name: "Scout", + pubkey: "b".repeat(64), + }, +]; +let state, calls, rejectSave; +dom.window.__TAURI_INTERNALS__ = { + invoke: async (command, payload) => { + calls.push({ command, payload }); + if (command === "get_agent_device_policy") return state; + if (command === "set_agent_device_policy") { + if (rejectSave) throw "Disk is not writable"; + state = { ...state, saved: payload.policy, restartRequired: true }; + return state; + } + throw new Error(`Unexpected side effect: ${command}`); + }, +}; +let React, + render, + screen, + fireEvent, + waitFor, + cleanup, + QueryClient, + QueryClientProvider, + Card; +before(async () => { + React = await import("react"); + ({ render, screen, fireEvent, waitFor, cleanup } = await import( + "@testing-library/react" + )); + ({ QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" + )); + ({ AgentHostingSettingsCard: Card } = await import( + "./AgentHostingSettingsCard.tsx" + )); +}); +afterEach(() => cleanup()); +function mount(overrides = {}) { + state = { + activeClientOnly: false, + saved: { client_only: false, preferred_agents: preferred }, + restartRequired: false, + loadError: null, + ...overrides, + }; + calls = []; + rejectSave = false; + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0 }, + mutations: { retry: false, gcTime: 0 }, + }, + }); + render( + React.createElement( + QueryClientProvider, + { client }, + React.createElement(Card), + ), + ); +} + +test("unique-name mode discloses when no existing identities are protected", async () => { + mount({ + activeUniqueNames: true, + saved: { client_only: false, unique_names: true, preferred_agents: [] }, + }); + await screen.findByText(/No existing agent identities are protected/); + await screen.findByText(/does not stop existing local agents/); +}); + +test("unique-name hosting enables local agents without clearing the remote bindings", async () => { + mount({ + activeClientOnly: true, + saved: { client_only: true, preferred_agents: preferred }, + }); + const toggle = await screen.findByRole("switch", { + name: "Unique agent names", + }); + await waitFor(() => assert.equal(toggle.disabled, false)); + fireEvent.click(toggle); + await screen.findByText(/Restart Buzz to apply/); + assert.deepEqual(state.saved, { + client_only: false, + unique_names: true, + preferred_agents: preferred, + }); + assert.equal(state.activeClientOnly, true); +}); + +test("discovery visibility cannot remove protected identities in unique-name mode", async () => { + mount({ + activeUniqueNames: true, + saved: { + client_only: false, + unique_names: true, + preferred_agents: preferred, + }, + }); + await screen.findByText(/Preferred existing agents/); + assert.equal( + screen.queryByRole("button", { name: "Show all existing identities" }), + null, + ); +}); + +test("client-only Save preserves exact preferred identities and discloses restart", async () => { + mount(); + const toggle = await screen.findByRole("switch", { + name: "Client-only mode", + }); + await waitFor(() => assert.equal(toggle.disabled, false)); + fireEvent.click(toggle); + await screen.findByText(/Restart Buzz to apply/); + assert.equal(toggle.getAttribute("aria-checked"), "true"); + assert.deepEqual( + calls.filter((c) => c.command.startsWith("set_")).map((c) => c.payload), + [{ policy: { client_only: true, preferred_agents: preferred } }], + ); + assert.equal( + state.activeClientOnly, + false, + "the current process must not claim the new policy yet", + ); +}); + +test("failed Save leaves the old setting visible and retryable", async () => { + mount(); + rejectSave = true; + const toggle = await screen.findByRole("switch", { + name: "Client-only mode", + }); + await waitFor(() => assert.equal(toggle.disabled, false)); + fireEvent.click(toggle); + await screen.findByText("Disk is not writable"); + assert.equal(toggle.getAttribute("aria-checked"), "false"); + rejectSave = false; + fireEvent.click(toggle); + await screen.findByText(/Restart Buzz to apply/); + assert.equal(toggle.getAttribute("aria-checked"), "true"); +}); + +test("clearing discovery preferences preserves client hosting policy and never deletes agents", async () => { + mount(); + const button = await screen.findByRole("button", { + name: "Show all existing identities", + }); + fireEvent.click(button); + await screen.findByText(/Restart Buzz to apply/); + assert.deepEqual(state.saved, { client_only: false, preferred_agents: [] }); + assert.deepEqual( + calls.map((c) => c.command), + ["get_agent_device_policy", "set_agent_device_policy"], + ); +}); diff --git a/desktop/src/features/settings/ui/AgentHostingSettingsCard.tsx b/desktop/src/features/settings/ui/AgentHostingSettingsCard.tsx new file mode 100644 index 00000000000..8c2dec60f3e --- /dev/null +++ b/desktop/src/features/settings/ui/AgentHostingSettingsCard.tsx @@ -0,0 +1,145 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { + deviceAgentPolicyQueryKey, + saveDeviceAgentPolicy, + useDeviceAgentPolicy, +} from "@/features/agents/useDeviceAgentPolicy"; +import { Button } from "@/shared/ui/button"; +import { Switch } from "@/shared/ui/switch"; +import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup"; + +export function AgentHostingSettingsCard() { + const query = useDeviceAgentPolicy(); + const queryClient = useQueryClient(); + const save = useMutation({ + mutationFn: saveDeviceAgentPolicy, + onSuccess: (status) => + queryClient.setQueryData(deviceAgentPolicyQueryKey, status), + }); + const status = query.data; + const error = save.error ?? query.error ?? status?.loadError; + return ( + + +
+ +

+ Use agents running on another device. This device cannot create, + start or deploy agents in client-only mode. +

+
+ { + if (status) + save.mutate({ ...status.saved, client_only: clientOnly }); + }} + /> +
+ +
+ +

+ Host new agents here with distinct names. Protected remote agents + stay on their hosting device. Local agent definitions stay on this + device; the agents remain available through the relay. +

+
+ { + if (status) + save.mutate({ + ...status.saved, + unique_names: uniqueNames, + client_only: uniqueNames ? false : status.saved.client_only, + }); + }} + /> +
+
+ {status && status.saved.preferred_agents.length === 0 && ( +

+ No existing agent identities are protected. Checking new names does + not stop existing local agents. Use client-only mode to prevent all + local starts, or configure protected identities in this device's + agent policy. +

+ )} + {status?.activeClientOnly && ( +

Client-only mode is active on this device.

+ )} + {status?.activeUniqueNames && !status.activeClientOnly && ( +

Unique-name hosting is active on this device.

+ )} + {status?.restartRequired && ( +

+ Restart Buzz to apply this change. + {(status.activeClientOnly || status.activeUniqueNames) && + !status.saved.client_only && + !status.saved.unique_names + ? " Enabling hosting resumes any retained local agent changes or deletions. Review this device's agent data before restarting." + : " Your agents and their identities are preserved."} +

+ )} + {error != null && ( +

+ {String(error instanceof Error ? error.message : error)} +

+ )} + {status && status.saved.preferred_agents.length > 0 && ( + <> +

+ Preferred existing agents:{" "} + {status.saved.preferred_agents + .map((agent) => agent.name) + .join(", ")} + . +

+ {!status.saved.unique_names && !status.activeUniqueNames && ( + + )} + + )} + {query.isError && ( + + )} + {status?.loadError && ( + + )} +
+
+ ); +} diff --git a/desktop/src/features/settings/ui/AgentsSettingsPanel.tsx b/desktop/src/features/settings/ui/AgentsSettingsPanel.tsx index b2fde755ff2..a4dbd427966 100644 --- a/desktop/src/features/settings/ui/AgentsSettingsPanel.tsx +++ b/desktop/src/features/settings/ui/AgentsSettingsPanel.tsx @@ -1,4 +1,5 @@ import { AgentDefaultsSettingsCard } from "./AgentDefaultsSettingsCard"; +import { AgentHostingSettingsCard } from "./AgentHostingSettingsCard"; import { setKeepMentionedAgentsPinned, useKeepMentionedAgentsPinned, @@ -48,6 +49,7 @@ export function AgentsSettingsPanel() { /> + diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 6ddc1111b03..5749fc3a35c 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -587,6 +587,17 @@ type E2eConfig = { model: string | null; preferred_runtime?: string | null; }; + /** Per-device hosting preference; active mode stays fixed until restart. */ + agentDevicePolicy?: { + client_only: boolean; + unique_names?: boolean; + preferred_agents: Array<{ + relay_url: string; + owner_pubkey: string; + name: string; + pubkey: string; + }>; + }; /** Explicit owner-only agent-access capability; independent of baked defaults. */ ownerOnlyAccessBuild?: boolean; /** File-layer config returned by runtime id. */ @@ -8601,6 +8612,26 @@ let installCallCount = 0; const installCallCountByRuntime: Record = {}; let addChannelMembersCallCount = 0; let setGlobalAgentConfigCallCount = 0; +let mockAgentDevicePolicy: { + client_only: boolean; + unique_names?: boolean; + preferred_agents: Array<{ + relay_url: string; + owner_pubkey: string; + name: string; + pubkey: string; + }>; +} = { + client_only: false, + preferred_agents: [] as Array<{ + relay_url: string; + owner_pubkey: string; + name: string; + pubkey: string; + }>, +}; +let mockActiveClientOnly = false; +let mockActiveUniqueNames = false; let mockGlobalAgentConfig: { env_vars: Record; provider: string | null; @@ -11363,6 +11394,14 @@ export function maybeInstallE2eTauriMocks() { return queued.length; }; window.__BUZZ_E2E_USERS_BATCH_PENDING__ = () => heldUsersBatchReleases.length; + mockAgentDevicePolicy = structuredClone( + config.mock?.agentDevicePolicy ?? { + client_only: false, + preferred_agents: [], + }, + ); + mockActiveClientOnly = mockAgentDevicePolicy.client_only; + mockActiveUniqueNames = mockAgentDevicePolicy.unique_names ?? false; mockGlobalAgentConfig = config.mock?.globalAgentConfig ? { ...config.mock.globalAgentConfig } : null; @@ -14130,6 +14169,27 @@ export function maybeInstallE2eTauriMocks() { if (!runtimeId) return null; return config.mock?.runtimeFileConfigs?.[runtimeId] ?? null; } + case "get_agent_device_policy": + return { + activeClientOnly: mockActiveClientOnly, + activeUniqueNames: mockActiveUniqueNames, + saved: mockAgentDevicePolicy, + restartRequired: + mockActiveClientOnly !== mockAgentDevicePolicy.client_only, + loadError: null, + }; + case "set_agent_device_policy": { + mockAgentDevicePolicy = structuredClone( + (payload as { policy: typeof mockAgentDevicePolicy }).policy, + ); + return { + activeClientOnly: mockActiveClientOnly, + activeUniqueNames: mockActiveUniqueNames, + saved: mockAgentDevicePolicy, + restartRequired: true, + loadError: null, + }; + } case "get_global_agent_config": { // Return the mutable persisted mock value, seeded from the test config. return ( diff --git a/desktop/tests/e2e/client-only-agents.spec.ts b/desktop/tests/e2e/client-only-agents.spec.ts new file mode 100644 index 00000000000..99f77100281 --- /dev/null +++ b/desktop/tests/e2e/client-only-agents.spec.ts @@ -0,0 +1,168 @@ +import { expect, test } from "@playwright/test"; +import { installMockBridge } from "../helpers/bridge"; +import { waitForAnimations } from "../helpers/animations"; + +const OWNER = "deadbeef".repeat(8); +const REMOTE = "ed".repeat(32); +const OLD = "ec".repeat(32); + +for (const uniqueNames of [false, true]) { + test(`${uniqueNames ? "unique-name hosting" : "client-only"} keeps the offline remote identity stable`, async ({ + page, + }, testInfo) => { + // These are the native policy's projected read results. The Rust tests + // independently exercise the key-generation, deploy and queue-flush gates. + await installMockBridge(page, { + agentDevicePolicy: { + client_only: !uniqueNames, + unique_names: uniqueNames, + preferred_agents: [ + { + relay_url: "https://mock.relay", + owner_pubkey: OWNER, + name: "RemoteScout", + pubkey: REMOTE, + }, + ], + }, + managedAgents: [], + personas: [ + ...(uniqueNames + ? [ + { + id: "local-notebook", + displayName: "Notebook", + systemPrompt: "Local test agent", + isActive: true, + }, + ] + : []), + { + id: "shared-scout", + displayName: "RemoteScout", + systemPrompt: "Existing shared definition", + isActive: false, + }, + ], + searchProfiles: [REMOTE, OLD].map((pubkey) => ({ + pubkey, + displayName: "RemoteScout", + ownerPubkey: OWNER, + isAgent: true, + })), + relayAgents: [ + { + pubkey: REMOTE, + name: "RemoteScout", + ownerPubkey: OWNER, + respondTo: "owner-only", + channelNames: [], + status: "offline", + }, + ], + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + for (const suffix of ["first", "again"]) { + const input = page.getByTestId("message-input"); + await input.fill("@Remote"); + const candidate = page.getByTestId(`mention-suggestion-${REMOTE}`); + await expect(candidate).toBeVisible(); + await expect(page.getByTestId(`mention-suggestion-${OLD}`)).toHaveCount( + 0, + ); + await expect( + page.getByTestId("mention-suggestion-persona-shared-scout"), + ).toHaveCount(0); + await candidate.locator("button").first().click(); + await page.keyboard.type(suffix); + await page.getByTestId("send-message").click(); + if (suffix === "first") { + await page.getByRole("button", { name: "Invite", exact: true }).click(); + } + await expect + .poll(() => + page.evaluate( + (suffix) => + (window.__BUZZ_E2E_SIGNED_EVENTS__ ?? []) + .filter((event) => event.content === `@RemoteScout ${suffix}`) + .map((event) => + event.tags + .filter((tag) => tag[0] === "p") + .map((tag) => tag[1]), + ), + suffix, + ), + ) + .toEqual([[REMOTE]]); + } + const commands = await page.evaluate( + () => window.__BUZZ_E2E_COMMAND_LOG__ ?? [], + ); + for (const command of [ + "create_managed_agent", + "start_managed_agent", + "start_managed_agent_runtime", + "confirm_agent_snapshot_import", + ]) { + expect(commands.some((call) => call.command === command)).toBe(false); + } + const adds = commands.filter( + (call) => call.command === "add_channel_members", + ); + expect(adds).toHaveLength(1); + expect(adds[0].payload).toMatchObject({ pubkeys: [REMOTE], role: "bot" }); + if (uniqueNames) { + const input = page.getByTestId("message-input"); + await input.fill("@Notebook"); + await expect( + page.getByTestId("mention-suggestion-persona-local-notebook"), + ).toBeVisible(); + await input.press("Enter"); + await page.keyboard.type(" local hello"); + await page.getByTestId("send-message").click(); + await expect + .poll(() => + page.evaluate( + () => + (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter( + (c) => c.command === "create_managed_agent", + ).length, + ), + ) + .toBe(1); + await expect + .poll(() => + page.evaluate( + () => + (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter( + (c) => c.command === "start_managed_agent", + ).length, + ), + ) + .toBe(1); + const created = await page.evaluate(() => + (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).find( + (c) => c.command === "create_managed_agent", + ), + ); + expect(created?.payload).toMatchObject({ input: { name: "Notebook" } }); + await expect + .poll(() => + page.evaluate(() => + (window.__BUZZ_E2E_SIGNED_EVENTS__ ?? []) + .filter((e) => e.content.includes("local hello")) + .flatMap((e) => + e.tags.filter((t) => t[0] === "p").map((t) => t[1]), + ), + ), + ) + .not.toEqual([]); + } + await waitForAnimations(page); + await page.screenshot({ + path: testInfo.outputPath("client-only-stable-recipient.png"), + }); + }); +} diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index c3f4ed69f4c..6ed3cb313f8 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -521,6 +521,16 @@ type MockBridgeOptions = { model: string | null; preferred_runtime?: string | null; }; + agentDevicePolicy?: { + client_only: boolean; + unique_names?: boolean; + preferred_agents: Array<{ + relay_url: string; + owner_pubkey: string; + name: string; + pubkey: string; + }>; + }; ownerOnlyAccessBuild?: boolean; /** File-layer config returned by runtime id. */ runtimeFileConfigs?: Record< diff --git a/docs/agent-device-policy.md b/docs/agent-device-policy.md new file mode 100644 index 00000000000..53b3093a953 --- /dev/null +++ b/docs/agent-device-policy.md @@ -0,0 +1,102 @@ +# Agent hosting across desktops + +Settings → Agents → Agent hosting → Unique agent names lets this device host +new agents while protecting existing identities hosted elsewhere. With +`unique_names: true` and `client_only: false`, the preferred remote names, +public keys and optional `persona_id` bindings cannot be created, started, +renamed, deleted or managed locally. An unrelated name can be created and run. +The ordinary Client-only switch still disables all local hosting. + +Hosting reservations apply across this installation's local agent catalog, +including after switching accounts or communities. A protected name remains +reserved locally, and a copied protected public key or definition ID cannot be +started by changing the active community. The `relay_url` and `owner_pubkey` +fields below scope discovery preferences; they do not create separate local +hosting namespaces. Hosting unrelated same-name agents in different communities +would require a separate change to the local catalog's collision rules. + +Existing-identity protection requires explicit `preferred_agents` bindings in +the device policy file described below. Settings does not infer which computer +should host a copied record. An empty binding list still checks new names for +collisions, but **does not stop existing local agents or copied autostart +records**; Settings discloses this state. Use client-only mode for a secondary +device until its protected identities have been configured. To configure +bindings, quit Buzz, preserve the current policy file, and set the existing +host's exact public keys and definition IDs in `preferred_agents`. Do not copy +private keys or delete shared definitions to configure this policy. + +New identity creation and renames are serialized on this device. Checks reject +an existing local instance, a different same-name definition, or an exact +same-name profile with verified ownership on the active relay. An unavailable +or incomplete relay lookup refuses the operation; offline presence does not +free a name. This is not an atomic cross-device reservation: simultaneous +creation by another unconfigured client still requires relay-side coordination. +Edits that keep the same name still enforce the protected-identity guards but +do not require an online directory lookup; local credential, prompt and model +configuration can therefore be saved while the relay is unavailable. + +Unique-name mode keeps runnable definitions and team templates local, including +their old pending backlog. Only lifecycle records for explicitly authored local +identities are published: kind:30177, its deletion and its archive request. A +durable key registry in the scoped retention database permits those operations +to retry after deletion/restart without releasing unrelated queued events. +Public agent profiles and ownership policies remain visible to the other client +for channel invitations and mentions. Individual agent imports are supported; +team imports and catalog publication require unrestricted hosting. + +The discovery-clear control is unavailable while unique-name protection is +enabled. The native settings command also refuses removing protected bindings +while retaining unique-name mode. Disabling both restrictions resumes normal +synchronization after restart, including retained edits/deletions. + +A desktop can use the same account as an agent host while acting as a chat +client. Settings → Agents → Agent hosting → Client-only mode is local to that +installation. It takes effect after restarting Buzz. The hosting desktop keeps +its default setting. + +In client-only mode, Desktop refuses agent key generation, instance imports, +starts, deployment and agent/team definition management. It exposes no local +runnable inventory and projects definitions as inactive without saving that +projection. Existing relay identities remain available for messages and invites. +An offline host does not permit creating a replacement agent. + +Automatic outgoing agent/persona/team reconciliation and queue flushing are +paused. Existing queues and records are retained. Inbound signed changes still +update local projections, without catalog echoes or runtime refreshes. This +prevents a stale local deletion from being published when an observer reconnects. +Review retained local state before returning a previously repaired observer to +hosting mode; reenabling restores normal synchronization after restart. + +The preference lives in `agents/agent-device-policy.json` under this desktop's +app data directory, independently of `managed-agents.json` and relay sync: + +```json +{ + "client_only": true, + "unique_names": false, + "preferred_agents": [] +} +``` + +Optional `preferred_agents` entries contain `relay_url` (canonical HTTP URL), +`owner_pubkey`, `name`, `pubkey`, and optionally `persona_id`. They select an existing identity within +that owner/community for name-based discovery. They neither mint keys nor grant +access. Older same-name identities are omitted from new selections; their +history, profiles, explicit public-key links, and send-time authorization remain +exact. When unique-name protection is disabled, Settings can clear discovery +preferences without deleting agents. + +The active policy, including read errors, is fixed for the process lifetime. +The native boundary refuses execution on a malformed/unreadable policy; Settings +can reset it to client-only mode and apply that recovery after restart. A missing +file preserves existing hosting behavior. The file is bounded to 64 KiB and +written atomically with restricted permissions. + +This is a device policy, not a relay-wide lease: another unconfigured desktop +can still create agents. Installations that must only observe must use this +client feature. Preserve the device policy across application upgrades; a client +version predating this feature does not enforce it. + +Regression coverage: `managed_agents/device_policy` native tests; native +deployment payload refusal; `AgentHostingSettingsCard.test.mjs`; and +`client-only-agents.spec.ts` with the upstream remote mention and invite suites.