mirror of
https://github.com/block/buzz.git
synced 2026-10-30 22:11:36 +00:00
feat(desktop): explicitly add an existing owned agent identity
Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
210
desktop/src-tauri/src/commands/existing_agent.rs
Normal file
210
desktop/src-tauri/src/commands/existing_agent.rs
Normal file
@@ -0,0 +1,210 @@
|
||||
//! 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<agents::ManagedAgentRecord, String> {
|
||||
// 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<_>>()
|
||||
== 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::<serde_json::Value>(&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<R: tauri::Runtime>(
|
||||
app: &AppHandle<R>,
|
||||
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());
|
||||
}
|
||||
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;
|
||||
183
desktop/src-tauri/src/commands/existing_agent/tests.rs
Normal file
183
desktop/src-tauri/src/commands/existing_agent/tests.rs
Normal file
@@ -0,0 +1,183 @@
|
||||
use super::*;
|
||||
use nostr::{EventBuilder, Kind, Tag};
|
||||
|
||||
fn fixture() -> (AddExistingAgentRequest, Vec<Event>, Vec<Event>) {
|
||||
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());
|
||||
}
|
||||
|
||||
// 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();
|
||||
struct Env(Vec<(&'static str, Option<std::ffi::OsString>)>);
|
||||
impl Drop for Env {
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let _env = Env(["HOME", "XDG_DATA_HOME"]
|
||||
.into_iter()
|
||||
.map(|name| {
|
||||
let old = std::env::var_os(name);
|
||||
std::env::set_var(name, temp.path());
|
||||
(name, old)
|
||||
})
|
||||
.collect());
|
||||
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();
|
||||
assert!(storage::save_managed_agents(app.handle(), std::slice::from_ref(&record)).is_err());
|
||||
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
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
mod existing_agent;
|
||||
pub use existing_agent::*;
|
||||
mod agent_access;
|
||||
mod agent_auth;
|
||||
mod agent_config;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -378,6 +378,29 @@ pub(crate) fn save_managed_agents_with_new_keys<R: tauri::Runtime>(
|
||||
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<R: tauri::Runtime>(
|
||||
app: &AppHandle<R>,
|
||||
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<R: tauri::Runtime>(
|
||||
app: &AppHandle<R>,
|
||||
records: &[ManagedAgentRecord],
|
||||
|
||||
@@ -482,3 +482,17 @@ 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.
|
||||
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.
|
||||
|
||||
108
desktop/src/features/agents/ui/AddExistingAgent.test.mjs
Normal file
108
desktop/src/features/agents/ui/AddExistingAgent.test.mjs
Normal file
@@ -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("<div id='root'></div>", {
|
||||
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();
|
||||
}
|
||||
});
|
||||
150
desktop/src/features/agents/ui/AddExistingAgent.tsx
Normal file
150
desktop/src/features/agents/ui/AddExistingAgent.tsx
Normal file
@@ -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 (
|
||||
<ExistingAgentDialog
|
||||
key={`${owner}:${community}`}
|
||||
scope={{ owner, community }}
|
||||
onSaved={() => {
|
||||
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 (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||
Add existing agent
|
||||
</Button>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (submitting.current) return;
|
||||
setOpen(next);
|
||||
setPrivateKey("");
|
||||
setError(false);
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add existing agent</DialogTitle>
|
||||
<DialogDescription>
|
||||
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.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={async (event) => {
|
||||
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);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<label htmlFor={`${id}-public`} className="block space-y-1">
|
||||
<span>Existing agent public key (hex)</span>
|
||||
<Input
|
||||
id={`${id}-public`}
|
||||
value={pubkey}
|
||||
onChange={(e) => setPubkey(e.target.value)}
|
||||
disabled={pending}
|
||||
required
|
||||
autoComplete="off"
|
||||
/>
|
||||
</label>
|
||||
<label htmlFor={`${id}-secret`} className="block space-y-1">
|
||||
<span>Agent private key (nsec or hex)</span>
|
||||
<Input
|
||||
id={`${id}-secret`}
|
||||
type="password"
|
||||
value={privateKey}
|
||||
onChange={(e) => setPrivateKey(e.target.value)}
|
||||
disabled={pending}
|
||||
required
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
autoCorrect="off"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
</label>
|
||||
{error && (
|
||||
<p role="alert">
|
||||
Could not add this identity. Check the key, ownership, linked
|
||||
persona and community, then enter the key again to retry.
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={pending || !pubkey.trim() || !privateKey.trim()}
|
||||
>
|
||||
{pending ? "Adding…" : "Add existing agent"}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
<PageHeader
|
||||
action={
|
||||
<>
|
||||
<AddExistingAgent />
|
||||
<div className="flex flex-wrap justify-end gap-2 [@container(max-width:40rem)]:hidden">
|
||||
<Button
|
||||
data-testid="agent-defaults-button"
|
||||
|
||||
Reference in New Issue
Block a user