diff --git a/desktop/src-tauri/src/commands/existing_agent.rs b/desktop/src-tauri/src/commands/existing_agent.rs new file mode 100644 index 00000000000..5f0f7055203 --- /dev/null +++ b/desktop/src-tauri/src/commands/existing_agent.rs @@ -0,0 +1,220 @@ +//! Deliberate local credential provisioning, separate from mint/import snapshots. +use crate::{app_state::AppState, managed_agents as agents, nostr_convert, relay}; +use nostr::{Event, Keys, ToBech32}; +use serde::Deserialize; +use tauri::{AppHandle, Emitter, State}; +use zeroize::Zeroize; + +/// Explicit user-supplied secret and selected owner/community/agent coordinate. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AddExistingAgentRequest { + owner: String, + community: String, + pubkey: String, + private_key: String, +} + +impl Drop for AddExistingAgentRequest { + fn drop(&mut self) { + self.private_key.zeroize(); + } +} + +fn check_scope(state: &AppState, input: &AddExistingAgentRequest) -> Result<(), String> { + if state.signing_keys()?.public_key().to_hex() != input.owner + || relay::relay_ws_url_with_override(state).trim_end_matches('/') != input.community + { + return Err("Identity or community changed; reopen Add existing agent".into()); + } + Ok(()) +} + +fn verified_record( + input: &AddExistingAgentRequest, + profiles: &[Event], + policies: &[Event], +) -> Result { + // Parse only a secret key (not Keys::parse's public-key interpretations). + let secret = nostr::SecretKey::parse(input.private_key.trim()) + .map_err(|_| "Invalid agent private key")?; + let keys = Keys::new(secret); + if keys.public_key().to_hex() != input.pubkey { + return Err("Private key does not match the selected agent".into()); + } + if nostr_convert::verified_agent_owners_from_profiles(profiles).get(&input.pubkey) + != Some(&input.owner) + { + return Err("Selected profile is not verified as owned by your identity".into()); + } + let policy = policies + .iter() + .filter(|event| { + event.kind == nostr::Kind::Custom(30177) + && event.pubkey.to_hex() == input.owner + && event + .tags + .iter() + .filter(|tag| tag.as_slice().first().is_some_and(|v| v == "d")) + .map(|tag| tag.as_slice()) + .collect::>() + == vec![&["d".to_string(), input.pubkey.clone()][..]] + }) + .max_by(|a, b| { + a.created_at + .cmp(&b.created_at) + .then_with(|| b.id.cmp(&a.id)) + }) + .ok_or("Owner-signed agent profile is unavailable")?; + policy + .verify() + .map_err(|_| "Invalid owner-signed agent profile")?; + let content = agents::agent_events::managed_agent_content_from_event(policy) + .map_err(|_| "Invalid owner-signed agent profile")?; + let persona = content + .persona_id + .as_deref() + .filter(|v| !v.trim().is_empty()) + .ok_or("The existing agent must have a linked persona")?; + agents::validate_managed_agent_definition_text(&content.name, Some(persona), None)?; + if !(1..=32).contains(&content.parallelism) { + return Err("Invalid agent parallelism".into()); + } + let allowlist = agents::validate_respond_to_allowlist(&content.respond_to_allowlist)?; + if content.respond_to == agents::RespondTo::Allowlist && allowlist.is_empty() { + return Err("Invalid agent access policy".into()); + } + let avatar_url = profiles + .iter() + .filter(|p| p.pubkey.to_hex() == input.pubkey) + .max_by(|a, b| { + a.created_at + .cmp(&b.created_at) + .then_with(|| b.id.cmp(&a.id)) + }) + .and_then(|p| serde_json::from_str::(&p.content).ok()) + .and_then(|p| p.get("picture").and_then(|v| v.as_str()).map(str::to_owned)); + let now = chrono::Utc::now().to_rfc3339(); + // Local execution configuration is deliberately unset. The SAME persona + // supplies defaults at normal launch; no source runtime or credentials move. + serde_json::from_value(serde_json::json!({ + "pubkey": input.pubkey, "name": content.name, "persona_id": persona, + "avatar_url": avatar_url, + "private_key_nsec": keys.secret_key().to_bech32().map_err(|_| "Cannot encode agent key")?, + "relay_url": input.community, "acp_command": agents::DEFAULT_ACP_COMMAND, "agent_command": "", + "agent_args": [], "mcp_command": "", "turn_timeout_seconds": 0, + "system_prompt": null, "parallelism": content.parallelism, + "respond_to": content.respond_to, "respond_to_allowlist": allowlist, + "start_on_app_launch": false, "auto_restart_on_config_change": false, + "created_at": now, "updated_at": now, "last_started_at": null, + "last_stopped_at": null, "last_exit_code": null, "last_error": null + })) + .map_err(|_| "Cannot prepare local agent identity".into()) +} + +/// Add the exact owned relay identity without minting, publishing, or starting it. +#[tauri::command] +pub async fn add_existing_agent( + app: AppHandle, + state: State<'_, AppState>, + input: AddExistingAgentRequest, +) -> Result<(), String> { + // Workspace apply holds this same async authority. Identity can change + // during queries; it is revalidated under identity_mutation before writing. + if input.pubkey.len() != 64 + || input.owner.len() != 64 + || input.private_key.len() > 128 + || input.community.len() > 2048 + { + return Err("Invalid existing-agent input".into()); + } + let _workspace = state.workspace_apply_lock.lock().await; + check_scope(&state, &input)?; + let profiles = relay::query_relay( + &state, + &[serde_json::json!({ + "kinds": [0], "authors": [&input.pubkey], "limit": 1 + })], + ) + .await + .map_err(|_| "Cannot fetch the existing agent profile")?; + let policies = relay::query_relay( + &state, + &[serde_json::json!({ + "kinds": [30177], "authors": [&input.owner], "#d": [&input.pubkey], "limit": 1 + })], + ) + .await + .map_err(|_| "Cannot fetch the owner-signed agent profile")?; + let record = verified_record(&input, &profiles, &policies)?; + commit_verified(&app, &state, &input, record) +} + +fn commit_verified( + app: &AppHandle, + state: &AppState, + input: &AddExistingAgentRequest, + mut record: agents::ManagedAgentRecord, +) -> Result<(), String> { + let _identity = state + .identity_mutation + .lock() + .map_err(|_| "Identity lock unavailable")?; + let _store = state + .managed_agents_store_lock + .lock() + .map_err(|_| "Agent store unavailable")?; + check_scope(state, input)?; + let personas: Vec<_> = agents::storage::load_agent_definitions(app)? + .iter() + .filter_map(|record| record.to_definition_view()) + .collect(); + if !personas + .iter() + .any(|p| Some(&p.id) == record.persona_id.as_ref() && p.is_active) + { + return Err("The linked persona must already be available on this Desktop".into()); + } + if let Some(saved) = agents::load_managed_agents(app)? + .iter() + .find(|r| r.pubkey == record.pubkey) + { + if saved.persona_id != record.persona_id + || (!saved.relay_url.is_empty() && saved.relay_url != record.relay_url) + { + return Err("Existing agent profile or community does not match".into()); + } + // The saved owner link must be usable by the exact current owner + // BEFORE any duplicate or repair effect. Launch preparation enforces + // this same verifier, so an absent/foreign/invalid attestation is + // refused here instead of reporting a healthy duplicate or a repaired + // key for a record that later refuses to run as unowned. No write, no + // silent re-attestation or owner migration. + agents::runtime_configurations::verify_owner(saved, &input.owner).map_err(|_| { + "Saved agent ownership is missing, invalid, or belongs to a different identity" + .to_string() + })?; + if nostr::SecretKey::parse(&saved.private_key_nsec) + .ok() + .is_some_and(|key| Keys::new(key).public_key().to_hex() == input.pubkey) + { + return Ok(()); // Healthy duplicate: zero writes, no lifecycle changes. + } + } + let owner = state.signing_keys()?; + let compat_owner = nostr::Keys::parse(&owner.secret_key().to_secret_hex()) + .map_err(|_| "Cannot attest agent ownership")?; + let agent = + nostr::PublicKey::from_hex(&input.pubkey).map_err(|_| "Invalid agent public key")?; + record.auth_tag = Some( + buzz_sdk_pkg::nip_oa::compute_auth_tag(&compat_owner, &agent, "") + .map_err(|_| "Cannot attest agent ownership")?, + ); + agents::storage::import_existing_agent_key(app, record) + .map_err(|_| "Could not save local agent identity; retry Add existing agent")?; + let _ = app.emit("agents-data-changed", ()); + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/desktop/src-tauri/src/commands/existing_agent/tests.rs b/desktop/src-tauri/src/commands/existing_agent/tests.rs new file mode 100644 index 00000000000..efe97bd4be2 --- /dev/null +++ b/desktop/src-tauri/src/commands/existing_agent/tests.rs @@ -0,0 +1,306 @@ +use super::*; +use nostr::{EventBuilder, Kind, Tag}; + +fn fixture() -> (AddExistingAgentRequest, Vec, Vec) { + let owner = Keys::parse(&"01".repeat(32)).unwrap(); + let agent = Keys::parse(&"02".repeat(32)).unwrap(); + let auth = buzz_sdk_pkg::nip_oa::compute_auth_tag(&owner, &agent.public_key(), "").unwrap(); + let profile = EventBuilder::new( + Kind::Metadata, + r#"{"name":"Exact agent","picture":"https://fixture.example/avatar.png"}"#, + ) + .tags([buzz_sdk_pkg::nip_oa::parse_auth_tag(&auth).unwrap()]) + .sign_with_keys(&agent) + .unwrap(); + let input = AddExistingAgentRequest { + owner: owner.public_key().to_hex(), + community: "wss://fixture.example".into(), + pubkey: agent.public_key().to_hex(), + private_key: agent.secret_key().to_bech32().unwrap(), + }; + let policy = EventBuilder::new( + Kind::Custom(30177), + serde_json::json!({ + "name": "Exact agent", "persona_id": "existing-persona", "parallelism": 1, + "respond_to": "owner-only" + }) + .to_string(), + ) + .tags([Tag::parse(["d", &input.pubkey]).unwrap()]) + .sign_with_keys(&owner) + .unwrap(); + (input, vec![profile], vec![policy]) +} + +#[test] +fn exact_key_and_persona_no_autostart() { + let (input, profiles, policies) = fixture(); + let record = verified_record(&input, &profiles, &policies).unwrap(); + assert_eq!(record.pubkey, input.pubkey); + assert_eq!( + record.avatar_url.as_deref(), + Some("https://fixture.example/avatar.png") + ); + assert_eq!(record.persona_id.as_deref(), Some("existing-persona")); + assert_eq!(record.private_key_nsec, input.private_key); + assert_eq!(record.relay_url, input.community); + assert!(!record.start_on_app_launch); + assert!(!record.auto_restart_on_config_change); + assert!(record.runtime_pid.is_none()); +} + +#[test] +fn wrong_key_foreign_owner_and_unsigned_policy_are_refused() { + let (mut input, profiles, mut policies) = fixture(); + input.private_key = "03".repeat(32); + assert!(verified_record(&input, &profiles, &policies).is_err()); + let (mut input, _, _) = fixture(); + input.owner = Keys::parse(&"03".repeat(32)).unwrap().public_key().to_hex(); + assert!(verified_record(&input, &profiles, &policies).is_err()); + let (input, _, _) = fixture(); + policies[0].content.push(' '); + assert!(verified_record(&input, &profiles, &policies).is_err()); + assert!(verified_record(&input, &[], &policies).is_err()); +} + +#[test] +fn conflicting_oa_and_duplicate_coordinates_are_refused() { + let (input, profiles, policies) = fixture(); + let agent = Keys::parse(&input.private_key).unwrap(); + let other = Keys::parse(&"03".repeat(32)).unwrap(); + let auth = buzz_sdk_pkg::nip_oa::compute_auth_tag(&other, &agent.public_key(), "").unwrap(); + let mut tags = profiles[0].tags.clone().to_vec(); + tags.push(buzz_sdk_pkg::nip_oa::parse_auth_tag(&auth).unwrap()); + let profile = EventBuilder::new(Kind::Metadata, "{}") + .tags(tags) + .sign_with_keys(&agent) + .unwrap(); + assert!(verified_record(&input, &[profile], &policies).is_err()); + let owner = Keys::parse(&"01".repeat(32)).unwrap(); + let policy = EventBuilder::new(Kind::Custom(30177), &policies[0].content) + .tags([ + Tag::parse(["d", &input.pubkey]).unwrap(), + Tag::parse(["d", &input.pubkey]).unwrap(), + ]) + .sign_with_keys(&owner) + .unwrap(); + assert!(verified_record(&input, &profiles, &[policy]).is_err()); +} + +/// Compute a NIP-OA attestation exactly as the production commit does. +#[cfg(all(unix, not(feature = "system-keyring")))] +fn owner_tag(owner: &Keys, agent_pubkey: &str) -> String { + buzz_sdk_pkg::nip_oa::compute_auth_tag( + owner, + &nostr::PublicKey::from_hex(agent_pubkey).unwrap(), + "", + ) + .unwrap() +} + +// Never run filesystem/credential fixtures against the real system keyring. +#[cfg(all(unix, not(feature = "system-keyring")))] +struct TempHomeEnv(Vec<(&'static str, Option)>); + +#[cfg(all(unix, not(feature = "system-keyring")))] +impl Drop for TempHomeEnv { + fn drop(&mut self) { + for (name, value) in &self.0 { + match value { + Some(v) => std::env::set_var(name, v), + None => std::env::remove_var(name), + } + } + } +} + +/// Point HOME/XDG_DATA_HOME at `temp` and restore them on drop. Pair with +/// `lock_path_mutex` so parallel env-mutating tests stay exclusive. +#[cfg(all(unix, not(feature = "system-keyring")))] +fn isolate_agent_home(temp: &std::path::Path) -> TempHomeEnv { + TempHomeEnv( + ["HOME", "XDG_DATA_HOME"] + .into_iter() + .map(|name| { + let old = std::env::var_os(name); + std::env::set_var(name, temp); + (name, old) + }) + .collect(), + ) +} + +// Never run filesystem/credential fixtures against the real system keyring. +#[cfg(all(unix, not(feature = "system-keyring")))] +#[test] +fn explicit_file_commit_repair_and_ordinary_save_f6() { + use agents::storage; + let _guard = agents::lock_path_mutex(); + let temp = tempfile::tempdir().unwrap(); + let _env = isolate_agent_home(temp.path()); + let app = tauri::test::mock_builder() + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .unwrap(); + let (input, profiles, policies) = fixture(); + let mut record = verified_record(&input, &profiles, &policies).unwrap(); + assert!(storage::save_managed_agents(app.handle(), std::slice::from_ref(&record)).is_err()); + // Seed the store exactly as the production commit leaves it: record, + // credential, and the current owner's attestation in one restricted write. + record.auth_tag = Some(owner_tag( + &Keys::parse(&"01".repeat(32)).unwrap(), + &input.pubkey, + )); + storage::import_existing_agent_key(app.handle(), record.clone()).unwrap(); + let path = agents::managed_agents_store_path(app.handle()).unwrap(); + let state = crate::app_state::build_app_state(); + *state.keys.lock().unwrap() = Keys::parse(&"01".repeat(32)).unwrap(); + *state.relay_url_override.lock().unwrap() = Some(input.community.clone()); + let mut persona = agents::load_personas(app.handle()).unwrap().remove(0); + persona.id = "existing-persona".into(); + persona.is_active = true; + agents::save_personas(app.handle(), &[persona]).unwrap(); + let original = std::fs::read(&path).unwrap(); + // Bind duplicate and post-await scope fences to the production commit seam. + commit_verified(app.handle(), &state, &input, record.clone()).unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), original); + *state.relay_url_override.lock().unwrap() = Some("wss://changed.example".into()); + assert!(commit_verified(app.handle(), &state, &input, record.clone()).is_err()); + assert_eq!(std::fs::read(&path).unwrap(), original); + *state.relay_url_override.lock().unwrap() = Some(input.community.clone()); + *state.keys.lock().unwrap() = Keys::parse(&"03".repeat(32)).unwrap(); + assert!(commit_verified(app.handle(), &state, &input, record.clone()).is_err()); + assert_eq!(std::fs::read(&path).unwrap(), original); + *state.keys.lock().unwrap() = Keys::parse(&"01".repeat(32)).unwrap(); + let mut wrong = record.clone(); + wrong.persona_id = Some("wrong".into()); + assert!(storage::import_existing_agent_key(app.handle(), wrong).is_err()); + assert_eq!(std::fs::read(&path).unwrap(), original); + let mut raw = storage::load_agent_store(app.handle()).unwrap(); + let saved = raw.iter_mut().find(|r| r.pubkey == input.pubkey).unwrap(); + saved.private_key_nsec.clear(); + saved.relay_url.clear(); // Legacy records inherit the active community. + saved.runtime_pid = Some(42); + saved.agent_command = "preserved".into(); + std::fs::write(&path, serde_json::to_vec(&raw).unwrap()).unwrap(); + storage::save_managed_agents(app.handle(), std::slice::from_ref(&record)).unwrap(); + assert!(storage::load_managed_agents(app.handle()).unwrap()[0] + .private_key_nsec + .is_empty()); + // Restore the synthetic running/config state before explicit key-only repair. + std::fs::write(&path, serde_json::to_vec(&raw).unwrap()).unwrap(); + commit_verified(app.handle(), &state, &input, record.clone()).unwrap(); + let repaired = storage::load_managed_agents(app.handle()) + .unwrap() + .remove(0); + assert_eq!(repaired.private_key_nsec, input.private_key); + assert_eq!(repaired.runtime_pid, Some(42)); + assert_eq!(repaired.agent_command, "preserved"); + // Fail during atomic-file preparation, AFTER all admission and reads. + // Credential and config bytes remain unchanged, then exact retry succeeds. + let before = std::fs::read(&path).unwrap(); + let parent = path.parent().unwrap(); + let permissions = std::fs::metadata(parent).unwrap().permissions(); + std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o500)).unwrap(); + let failed = storage::import_existing_agent_key(app.handle(), record.clone()); + std::fs::set_permissions(parent, permissions).unwrap(); + assert!(failed.is_err()); + assert_eq!(std::fs::read(&path).unwrap(), before); + storage::import_existing_agent_key(app.handle(), record).unwrap(); + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o600 + ); +} + +// The saved owner link gates BOTH commit outcomes: a legacy record whose saved +// attestation is absent, foreign, or invalid must be refused with zero writes, +// whatever the key state — never a healthy duplicate or repaired key for a +// record that later refuses to run (runtime_configurations::verify_owner). +#[cfg(all(unix, not(feature = "system-keyring")))] +#[test] +fn saved_owner_link_gates_duplicate_and_repair() { + use agents::storage; + let _guard = agents::lock_path_mutex(); + let temp = tempfile::tempdir().unwrap(); + let _env = isolate_agent_home(temp.path()); + let app = tauri::test::mock_builder() + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .unwrap(); + let (input, profiles, policies) = fixture(); + let record = verified_record(&input, &profiles, &policies).unwrap(); + let state = crate::app_state::build_app_state(); + *state.keys.lock().unwrap() = Keys::parse(&"01".repeat(32)).unwrap(); + *state.relay_url_override.lock().unwrap() = Some(input.community.clone()); + let mut persona = agents::load_personas(app.handle()).unwrap().remove(0); + persona.id = "existing-persona".into(); + persona.is_active = true; + agents::save_personas(app.handle(), &[persona]).unwrap(); + // Production creation commit: record + credential + owner tag in one write. + commit_verified(app.handle(), &state, &input, record.clone()).unwrap(); + let path = agents::managed_agents_store_path(app.handle()).unwrap(); + let created = std::fs::read(&path).unwrap(); + // Positive controls first: this exact fixture must admit both outcomes the + // guard protects, so every refusal below varies only the saved owner link. + commit_verified(app.handle(), &state, &input, record.clone()).unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), created); // Healthy duplicate. + let mut raw = storage::load_agent_store(app.handle()).unwrap(); + raw.iter_mut() + .find(|r| r.pubkey == input.pubkey) + .unwrap() + .private_key_nsec + .clear(); + std::fs::write(&path, serde_json::to_vec(&raw).unwrap()).unwrap(); + commit_verified(app.handle(), &state, &input, record.clone()).unwrap(); // Key repair. + assert_eq!( + storage::load_agent_store(app.handle()) + .unwrap() + .into_iter() + .find(|r| r.pubkey == input.pubkey) + .unwrap() + .private_key_nsec, + input.private_key + ); + let foreign = owner_tag(&Keys::parse(&"03".repeat(32)).unwrap(), &input.pubkey); + // Structurally valid, current owner embedded, but signed over a different + // agent's preimage — only signature verification rejects this one. + let invalid = owner_tag( + &Keys::parse(&"01".repeat(32)).unwrap(), + &Keys::parse(&"04".repeat(32)).unwrap().public_key().to_hex(), + ); + for (name, tag, healthy_key) in [ + ("absent attestation, healthy key", None, true), + ("absent attestation, missing key", None, false), + ("foreign attestation, healthy key", Some(&foreign), true), + ("foreign attestation, missing key", Some(&foreign), false), + ("invalid attestation, healthy key", Some(&invalid), true), + ("invalid attestation, missing key", Some(&invalid), false), + ] { + let mut raw = storage::load_agent_store(app.handle()).unwrap(); + let saved = raw.iter_mut().find(|r| r.pubkey == input.pubkey).unwrap(); + saved.auth_tag = tag.cloned(); + saved.private_key_nsec = if healthy_key { + input.private_key.clone() + } else { + String::new() + }; + std::fs::write(&path, serde_json::to_vec(&raw).unwrap()).unwrap(); + let before = std::fs::read(&path).unwrap(); + let error = commit_verified(app.handle(), &state, &input, record.clone()).expect_err(name); + assert_eq!( + error, "Saved agent ownership is missing, invalid, or belongs to a different identity", + "{name}" + ); + assert_eq!(std::fs::read(&path).unwrap(), before, "{name}: no writes"); + let persisted = storage::load_agent_store(app.handle()) + .unwrap() + .into_iter() + .find(|r| r.pubkey == input.pubkey) + .unwrap(); + assert_eq!( + persisted.private_key_nsec.is_empty(), + !healthy_key, + "{name}: key state unchanged" + ); + } +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 1abb3861fd0..34d227a7d79 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -1,3 +1,5 @@ +mod existing_agent; +pub use existing_agent::*; mod agent_access; mod agent_auth; mod agent_config; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index bb59c405c25..4c8a0c4f41a 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -720,6 +720,7 @@ pub fn run() { reconcile_managed_agent_runtimes, put_managed_agent_runtime_lifecycle, create_managed_agent, + add_existing_agent, start_managed_agent, stop_managed_agent, set_agent_managed_profiles, diff --git a/desktop/src-tauri/src/managed_agents/runtime_configurations.rs b/desktop/src-tauri/src/managed_agents/runtime_configurations.rs index 2d505e752c8..252c7296845 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_configurations.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_configurations.rs @@ -398,7 +398,12 @@ pub(crate) fn preflight( Ok(()) } -fn verify_owner(record: &ManagedAgentRecord, owner: &str) -> Result<(), String> { +/// Ownership authority shared by launch preparation and explicit +/// existing-agent admission: a local record must carry a saved NIP-OA +/// attestation that verifies for the exact agent pubkey and resolves to +/// `owner`. One predicate fences both, so admission can never report success +/// for a record that later refuses to run as unowned. +pub(crate) fn verify_owner(record: &ManagedAgentRecord, owner: &str) -> Result<(), String> { let verified = record.auth_tag.as_deref().and_then(|tag| { let agent = nostr::PublicKey::from_hex(&record.pubkey).ok()?; buzz_sdk_pkg::nip_oa::verify_auth_tag(tag, &agent).ok() diff --git a/desktop/src-tauri/src/managed_agents/storage.rs b/desktop/src-tauri/src/managed_agents/storage.rs index b34a1fc7fed..52d3769ce0e 100644 --- a/desktop/src-tauri/src/managed_agents/storage.rs +++ b/desktop/src-tauri/src/managed_agents/storage.rs @@ -378,6 +378,29 @@ pub(crate) fn save_managed_agents_with_new_keys( save_agent_edits(app, records, true) } +/// Explicit user-supplied key import. Caller holds workspace/identity/store locks +/// and verifies ownership. Commit credential + record in one restricted atomic +/// file write, without a preceding keyring side effect. A failed write is safely +/// retryable; ordinary saves still cannot restore credentials. +pub(crate) fn import_existing_agent_key( + app: &AppHandle, + record: ManagedAgentRecord, +) -> Result<(), String> { + let mut current = load_agent_store(app)?; + if let Some(saved) = current.iter_mut().find(|r| r.pubkey == record.pubkey) { + if saved.persona_id != record.persona_id + || (!saved.relay_url.is_empty() && saved.relay_url != record.relay_url) + { + return Err("Existing agent profile or community does not match".into()); + } + saved.private_key_nsec = record.private_key_nsec; + } else { + current.push(record); + } + let (definitions, instances) = current.into_iter().partition(|r| r.pubkey.is_empty()); + write_agent_store(app, definitions, instances) +} + fn save_agent_edits( app: &AppHandle, records: &[ManagedAgentRecord], diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 2e5a016cbf2..c3ee813128c 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -245,9 +245,11 @@ pub struct ManagedAgentRecord { /// storage layer blanks this before writing JSON once the key is safely in /// the keyring, and re-hydrates it from the keyring on load. /// - /// It is only serialized inline (the `0o600` JSON fallback) when the - /// keyring is unreachable — `skip_serializing_if` keeps it out of JSON in - /// the normal keyring-backed case. `default` also lets an old build parse a + /// It is serialized inline (the `0o600` JSON fallback) when the keyring is + /// unreachable, and deliberately by the explicit existing-agent import, + /// which provisions the user-supplied key in the restricted local file in + /// one atomic write — `skip_serializing_if` keeps it out of JSON in the + /// normal keyring-backed case. `default` also lets an old build parse a /// store whose inline key was already migrated out and blanked. #[serde(default, skip_serializing_if = "String::is_empty")] pub private_key_nsec: String, diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index ad4e45451e2..6c851e5cc0c 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -482,3 +482,21 @@ and disconnected/retired receivers cannot resume a later destination Start. Regression seams: desktopLifecycle.test.mjs, mounted DesktopLifecycleControl.test.mjs, core desktop_lifecycle/protocol_tests.rs and native placement/tests.rs. Mock IPC passing is not evidence of a native successful launch or two-Desktop switching. + +## Explicit existing-agent identity import + +Agents → Add existing agent is local provisioning, not snapshot import. It binds +an explicitly supplied private key to the exact signed kind:0 owned identity and +independently owner-signed kind:30177 persona link in the current community. The +linked definition must already exist; it is never cloned. Workspace apply, +identity mutation and agent store locks fence the commit. Healthy duplicates do +not write; explicit repair changes only the credential, not runtime/config state. +A saved record's ownership attestation must verify for the exact current owner +and agent before either outcome — the same verifier launch preparation applies +— so a missing, foreign, or invalid saved owner link is refused without writes +instead of reporting success for a record that later refuses to run. +This operation deliberately stores the user-supplied key in the restricted local +agent file in one atomic write with the record, avoiding a keyring-first partial +commit. The dialog discloses that storage choice. Ordinary save/Move/restore and +snapshot mint semantics remain unchanged; no lifecycle path gains provisioning +permission and import never starts an agent. diff --git a/desktop/src/features/agents/ui/AddExistingAgent.test.mjs b/desktop/src/features/agents/ui/AddExistingAgent.test.mjs new file mode 100644 index 00000000000..6d5eab97f41 --- /dev/null +++ b/desktop/src/features/agents/ui/AddExistingAgent.test.mjs @@ -0,0 +1,108 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import React from "react"; +import { JSDOM } from "jsdom"; + +test("mounted explicit import keeps exact scope/key, clears secret on failure and retries without starting", async () => { + const dom = new JSDOM("
", { + url: "https://desktop.test", + }); + for (const key of [ + "window", + "document", + "HTMLElement", + "Element", + "Node", + "NodeFilter", + "MutationObserver", + "CustomEvent", + "Event", + "HTMLInputElement", + ]) + globalThis[key] = + key === "window" + ? dom.window + : key === "document" + ? dom.window.document + : dom.window[key]; + globalThis.getComputedStyle = dom.window.getComputedStyle; + globalThis.localStorage = dom.window.localStorage; + dom.window.matchMedia = () => ({ + matches: false, + addEventListener() {}, + removeEventListener() {}, + }); + const { ThemeProvider } = await import("@/shared/theme/ThemeProvider"); + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + const { ExistingAgentDialog } = await import("./AddExistingAgent.tsx"); + const { createRoot } = await import("react-dom/client"); + const { fireEvent } = await import("@testing-library/react"); + const calls = []; + let reject = true, + saved = 0; + window.__TAURI_INTERNALS__ = { + invoke: async (command, args) => { + if (command !== "add_existing_agent") return; + calls.push({ command, args }); + if (reject) throw new Error("synthetic-secret-must-not-render"); + }, + }; + const root = createRoot(document.getElementById("root")); + const scope = { owner: "owner", community: "wss://one.example" }; + try { + await React.act(async () => + root.render( + React.createElement( + ThemeProvider, + null, + React.createElement(ExistingAgentDialog, { + scope, + onSaved: () => saved++, + }), + ), + ), + ); + await React.act(async () => document.querySelector("button").click()); + const inputs = document.querySelectorAll("input"); + assert.equal(inputs.length, 2); + assert.equal(inputs[1].type, "password"); + assert.equal(inputs[1].autocomplete, "off"); + await React.act(async () => { + fireEvent.change(inputs[0], { target: { value: "exact-public-key" } }); + fireEvent.change(inputs[1], { target: { value: "synthetic-secret" } }); + }); + await React.act(async () => + fireEvent.submit(document.querySelector("form")), + ); + assert.deepEqual(calls, [ + { + command: "add_existing_agent", + args: { + input: { + ...scope, + pubkey: "exact-public-key", + privateKey: "synthetic-secret", + }, + }, + }, + ]); + assert.equal(inputs[1].value, ""); + assert.ok(document.querySelector("[role=alert]")); + assert.ok(!document.body.textContent.includes("synthetic-secret")); + assert.equal(saved, 0); + reject = false; + await React.act(async () => + fireEvent.change(inputs[1], { target: { value: "synthetic-secret" } }), + ); + await React.act(async () => + fireEvent.submit(document.querySelector("form")), + ); + assert.equal(saved, 1); + assert.equal(calls.length, 2); + assert.deepEqual(calls[0], calls[1]); + assert.equal(document.querySelector("input[type=password]"), null); + } finally { + await React.act(async () => root.unmount()); + dom.window.close(); + } +}); diff --git a/desktop/src/features/agents/ui/AddExistingAgent.tsx b/desktop/src/features/agents/ui/AddExistingAgent.tsx new file mode 100644 index 00000000000..07cd7d4e513 --- /dev/null +++ b/desktop/src/features/agents/ui/AddExistingAgent.tsx @@ -0,0 +1,150 @@ +import { useId, useRef, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import { useCommunities } from "@/features/communities/useCommunities"; +import { useManagedAgentsQuery } from "../hooks"; +import { Button } from "@/shared/ui/button"; +import { Input } from "@/shared/ui/input"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; + +type Scope = { owner: string; community: string }; + +/** Explicit local-only identity import; never the snapshot mint path. */ +export function AddExistingAgent() { + const owner = useIdentityQuery().data?.pubkey; + const { activeCommunity } = useCommunities(); + const community = activeCommunity?.relayUrl + .trim() + .replace(/^http/, "ws") + .replace(/\/+$/, ""); + const { refetch } = useManagedAgentsQuery(); + if (!owner || !community) return null; + return ( + { + void refetch(); + }} + /> + ); +} + +export function ExistingAgentDialog({ + scope, + onSaved, +}: { + scope: Scope; + onSaved: () => void; +}) { + const id = useId(); + const [open, setOpen] = useState(false); + const [pubkey, setPubkey] = useState(""); + const [privateKey, setPrivateKey] = useState(""); + const [pending, setPending] = useState(false); + const [error, setError] = useState(false); + const submitting = useRef(false); + return ( + <> + + { + if (submitting.current) return; + setOpen(next); + setPrivateKey(""); + setError(false); + }} + > + + + Add existing agent + + Keep the same agent identity and linked persona. The persona must + already be available here. Nothing starts automatically. The + supplied key stays on this computer, in its owner-only local agent + file (not the OS keyring). Adding it again repairs a missing key + without changing runtime settings. + + +
{ + event.preventDefault(); + if (submitting.current) return; + submitting.current = true; + setPending(true); + setError(false); + const secret = privateKey; + setPrivateKey(""); + try { + await invoke("add_existing_agent", { + input: { + ...scope, + pubkey: pubkey.trim(), + privateKey: secret, + }, + }); + setOpen(false); + onSaved(); + } catch { + // Raw IPC exceptions may contain request data. Never display/log them. + setError(true); + } finally { + submitting.current = false; + setPending(false); + } + }} + > + + + {error && ( +

+ Could not add this identity. Check the key, ownership, linked + persona and community, then enter the key again to retry. +

+ )} + +
+
+
+ + ); +} diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index 5c879e70913..e3e05b37134 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -1,3 +1,4 @@ +import { AddExistingAgent } from "./AddExistingAgent"; import { RuntimeConfigurations } from "./RuntimeConfigurations"; import { KnownDesktops } from "./KnownDesktops"; import * as React from "react"; @@ -148,6 +149,7 @@ export function AgentsView() { +