feat(desktop): explicitly add an existing owned agent identity

Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-09-08 17:30:08 -04:00
parent 8b4f9d6d3f
commit d8b6dc3c30
9 changed files with 693 additions and 0 deletions

View 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;

View 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
);
}

View File

@@ -1,3 +1,5 @@
mod existing_agent;
pub use existing_agent::*;
mod agent_access;
mod agent_auth;
mod agent_config;

View File

@@ -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,

View File

@@ -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],

View File

@@ -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.

View 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();
}
});

View 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>
</>
);
}

View File

@@ -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"