Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions desktop/src-tauri/src/commands/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,95 @@ pub(super) fn tombstone_managed_agent_pending(
}
}

/// Build and sign the NIP-IA `kind:9035` archive request enqueued when an
/// agent is deleted. Pure given the keys — unit-testable without an
/// `AppHandle`. Reuses the same wire builder as the GUI's Archive action
/// (`events::build_archive_identity_request`); the machine-readable reason is
/// `retired` (NIP-IA suggested code for a deliberately decommissioned key).
///
/// The owner auth tag is minted locally from the same keys used to sign the
/// request, avoiding a network fetch while the managed-agent store lock is
/// held. The relay still independently verifies it against the agent's live
/// kind:0.
pub(super) fn build_agent_archive_request(
keys: &nostr::Keys,
agent_pubkey: &str,
) -> Result<nostr::Event, String> {
let auth_tag = if keys
.public_key()
.to_hex()
.eq_ignore_ascii_case(agent_pubkey)
{
None
} else {
let agent = nostr::PublicKey::from_hex(agent_pubkey)
.map_err(|e| format!("invalid agent pubkey: {e}"))?;
let tag_json = buzz_sdk_pkg::nip_oa::compute_auth_tag(keys, &agent, "")
.map_err(|e| format!("failed to build owner auth tag: {e}"))?;
let parts: Vec<String> = serde_json::from_str(&tag_json)
.map_err(|e| format!("failed to parse owner auth tag: {e}"))?;
Some(
<[String; 4]>::try_from(parts)
.map_err(|_| "owner auth tag must have four elements".to_string())?,
)
};
crate::events::build_archive_identity_request(
agent_pubkey,
"",
Some("retired"),
None,
auth_tag.as_ref(),
)?
.sign_with_keys(keys)
.map_err(|e| format!("failed to sign archive request: {e}"))
}

/// Enqueue a NIP-IA `kind:9035` archive request for a deleted agent, retained
/// next to its kind:5 tombstone with `pending_sync = 1`.
///
/// The tombstone removes the agent's 30177 record cross-device, but the
/// agent's `kind:0` and channel membership keep populating member pickers and
/// autocomplete on the relay until the identity is archived. Retaining the
/// request here gives archival the same offline durability as the tombstone;
/// the flush loop is the sole publisher and re-signs the request with a fresh
/// `created_at` at publish time, because the relay enforces a ±120s freshness
/// window on 9035s.
///
/// Same contract as `tombstone_managed_agent_pending`: called inside the
/// `managed_agents_store_lock`-held delete body, never across an `.await`,
/// best-effort — a failure is logged and swallowed so it never blocks the
/// disk-authoritative delete.
pub(super) fn archive_managed_agent_pending(app: &AppHandle, state: &AppState, agent_pubkey: &str) {
use crate::managed_agents::retention::{open_retention_db, retain_event, RetainedEvent};
use buzz_core_pkg::kind::KIND_IA_ARCHIVE_REQUEST;
use nostr::JsonUtil;

let result = (|| -> Result<(), String> {
let (owner_pubkey, event) = {
let keys = state.signing_keys()?;
let owner_pubkey = keys.public_key().to_hex();
let event = build_agent_archive_request(&keys, agent_pubkey)?;
(owner_pubkey, event)
};
let conn = open_retention_db(&managed_agents_base_dir(app)?.join("retention.db"))?;
retain_event(
&conn,
&RetainedEvent {
kind: KIND_IA_ARCHIVE_REQUEST,
pubkey: owner_pubkey,
d_tag: agent_pubkey.to_string(),
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: agent-archive: {e}");
}
}

fn normalize_relay_mesh(
config: Option<&RelayMeshConfig>,
backend: &BackendKind,
Expand Down Expand Up @@ -1134,6 +1223,10 @@ pub async fn delete_managed_agent(
// deployment's relay record. Inside the lock, before the block closes
// (no .await here). Every agent published, so every delete tombstones.
tombstone_managed_agent_pending(&app, &state, &pubkey);
// NIP-IA: archive the deleted agent's identity on the relay so it
// stops appearing in member pickers and autocomplete. Same
// best-effort, inside-the-lock contract as the tombstone above.
archive_managed_agent_pending(&app, &state, &pubkey);
}
try_regenerate_nest(&app);
Ok(())
Expand Down
40 changes: 40 additions & 0 deletions desktop/src-tauri/src/commands/agents_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,46 @@ fn persona_record(id: &str, model: Option<&str>, provider: Option<&str>) -> Agen
}
}

/// Auto-archive uses the same NIP-IA wire builder as the explicit GUI action,
/// attaches owner consent, and marks a deliberate delete as `retired`.
#[test]
fn build_agent_archive_request_attaches_owner_auth_and_retired_reason() {
use nostr::JsonUtil;

let owner = nostr::Keys::generate();
let agent = nostr::Keys::generate();
let event = build_agent_archive_request(&owner, &agent.public_key().to_hex())
.expect("build archive request");
let json: serde_json::Value = serde_json::from_str(&event.as_json()).unwrap();
let tags = json["tags"].as_array().unwrap();

assert_eq!(event.kind.as_u16(), 9035);
assert_eq!(event.pubkey, owner.public_key());
assert!(event.verify_id());
assert!(event.verify_signature());
assert!(tags.iter().any(|tag| {
tag.as_array().is_some_and(|parts| {
parts.first().and_then(serde_json::Value::as_str) == Some("p")
&& parts.get(1).and_then(serde_json::Value::as_str)
== Some(agent.public_key().to_hex().as_str())
})
}));
assert!(tags.iter().any(|tag| {
tag.as_array().is_some_and(|parts| {
parts.first().and_then(serde_json::Value::as_str) == Some("reason")
&& parts.get(1).and_then(serde_json::Value::as_str) == Some("retired")
})
}));
assert!(tags.iter().any(|tag| {
tag.as_array().is_some_and(|parts| {
parts.first().and_then(serde_json::Value::as_str) == Some("auth")
&& parts.get(1).and_then(serde_json::Value::as_str)
== Some(owner.public_key().to_hex().as_str())
&& parts.len() == 4
})
}));
}

/// Deploy-path regression for Fix 1 of Thufir pass-2: a persona-linked
/// provider agent with a stale record snapshot must use the live persona
/// model/provider in the deploy payload, not the stale record values.
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/commands/personas/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,7 @@ pub async fn delete_persona(id: String, app: AppHandle) -> Result<(), String> {
// Remove nsec from keyring after the record is gone.
delete_agent_key(pk);
super::agents::tombstone_managed_agent_pending(&app, &state, pk);
super::agents::archive_managed_agent_pending(&app, &state, pk);
}
tombstone_persona_pending(&app, &state, &d_tag);

Expand Down
37 changes: 37 additions & 0 deletions desktop/src-tauri/src/managed_agents/persona_events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,20 @@ pub async fn flush_pending_events(
let event = nostr::Event::from_json(&current.raw_event)
.map_err(|e| format!("failed to parse retained event '{}': {e}", current.d_tag))?;

// NIP-IA requests are freshness-checked by the relay (±120s on
// `created_at`), so a request retained while the relay was
// unreachable would be permanently stale. Re-sign with a fresh
// timestamp at publish time; kind, tags, and content are preserved,
// and `mark_synced` below still compares against the retained row's
// original `created_at`/`content`, which are untouched.
let is_archive_request =
buzz_core_pkg::kind::is_identity_archive_request_kind(current.kind);
let event = if is_archive_request {
resign_with_fresh_timestamp(&event, state)?
} else {
event
};

if crate::relay::submit_signed_event(&event, state)
.await
.is_err()
Expand All @@ -281,6 +295,29 @@ pub async fn flush_pending_events(
Ok(flushed)
}

/// Re-sign a retained event with the current owner keys and a fresh
/// `created_at`, preserving kind, tags, and content.
///
/// Used for relay-freshness-checked kinds (NIP-IA 9035/9036) that would
/// otherwise go permanently stale sitting in the retention store while the
/// relay is unreachable. `.allow_self_tagging()` mirrors
/// `events::build_archive_identity_request` — nostr strips `p` tags matching
/// the signer by default, which would corrupt a self-targeted request.
///
/// Synchronous; the `state.keys` guard is dropped on return, so callers may
/// `.await` afterwards.
fn resign_with_fresh_timestamp(
event: &nostr::Event,
state: &AppState,
) -> Result<nostr::Event, String> {
let keys = state.signing_keys()?;
nostr::EventBuilder::new(event.kind, event.content.clone())
.tags(event.tags.iter().cloned())
.allow_self_tagging()
.sign_with_keys(&keys)
.map_err(|e| format!("failed to re-sign retained event: {e}"))
}

/// SHA-256 (lowercase hex) of a persona's canonical content JSON.
///
/// The drift indicator compares this digest, not event timestamps, to decide
Expand Down
31 changes: 31 additions & 0 deletions desktop/src-tauri/src/managed_agents/persona_events/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -729,6 +729,37 @@ mod flush_barrier {
.expect("retain test event");
}

#[test]
fn archive_request_resign_refreshes_timestamp_and_preserves_payload() {
use nostr::JsonUtil;

let keys = nostr::Keys::generate();
let target = nostr::Keys::generate().public_key().to_hex();
let stale = crate::events::build_archive_identity_request(
&target,
"agent deleted",
Some("retired"),
None,
None,
)
.unwrap()
.custom_created_at(nostr::Timestamp::from(1))
.sign_with_keys(&keys)
.unwrap();
let state = build_app_state();
*state.keys.lock().unwrap() = keys;

let fresh = resign_with_fresh_timestamp(&stale, &state).unwrap();

assert!(fresh.created_at.as_secs() > stale.created_at.as_secs());
assert_eq!(fresh.kind, stale.kind);
assert_eq!(fresh.content, stale.content);
assert_eq!(fresh.tags, stale.tags);
assert!(fresh.verify_id());
assert!(fresh.verify_signature());
assert_ne!(fresh.as_json(), stale.as_json());
}

/// The mid-sweep barrier: a tombstone the relay rejects must defer its
/// own replacement to the next sweep (still pending, not counted as
/// flushed) while unrelated rows in the same sweep publish normally.
Expand Down
34 changes: 34 additions & 0 deletions desktop/src/features/agents/ui/PersonaDeleteDialog.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import assert from "node:assert/strict";
import test from "node:test";

import { personaDeleteDescription } from "./PersonaDeleteDialog.tsx";

// Regression guard for the persona-cascade consent copy: deleting a persona
// with instances also archives each instance's identity on the relay
// (NIP-IA 9035), a durable externally visible side effect. The confirmation
// dialog must disclose it before the destructive confirm, exactly like the
// direct agent-delete dialog does.

const persona = { displayName: "Scout" };

test("cascade delete discloses relay archival (plural)", () => {
const copy = personaDeleteDescription(persona, 3);
assert.match(copy, /deletes 3 agent instances/);
assert.match(copy, /archives their identities on the relay/);
});

test("cascade delete discloses relay archival (singular)", () => {
const copy = personaDeleteDescription(persona, 1);
assert.match(copy, /deletes 1 agent instance /);
assert.match(copy, /archives its identity on the relay/);
});

test("no instances → no archival claim (nothing is archived)", () => {
const copy = personaDeleteDescription(persona, 0);
assert.equal(copy, "Delete Scout.");
assert.doesNotMatch(copy, /archiv/i);
});

test("null persona keeps the generic fallback", () => {
assert.equal(personaDeleteDescription(null, 2), "Delete this agent.");
});
28 changes: 25 additions & 3 deletions desktop/src/features/agents/ui/PersonaDeleteDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,30 @@ type PersonaDeleteDialogProps = {
onOpenChange: (open: boolean) => void;
};

/**
* Confirmation copy for deleting a persona. Pure so the cascade archival
* disclosure stays unit-testable without a renderer: whenever instances are
* cascade-deleted, each one's identity is also archived on the relay
* (NIP-IA), and that durable side effect must be disclosed before the
* destructive confirm — matching the direct agent-delete dialog.
*/
export function personaDeleteDescription(
persona: AgentPersona | null,
instanceCount: number,
): string {
if (!persona) {
return "Delete this agent.";
}
if (instanceCount === 0) {
return `Delete ${persona.displayName}.`;
}
const cascade =
instanceCount === 1
? "Also deletes 1 agent instance and archives its identity on the relay, so it no longer appears in member lists or mention suggestions."
: `Also deletes ${instanceCount} agent instances and archives their identities on the relay, so they no longer appear in member lists or mention suggestions.`;
return `Delete ${persona.displayName}. ${cascade}`;
}

export function PersonaDeleteDialog({
open,
persona,
Expand All @@ -33,9 +57,7 @@ export function PersonaDeleteDialog({
<AlertDialogHeader>
<AlertDialogTitle>Delete agent?</AlertDialogTitle>
<AlertDialogDescription>
{persona
? `Delete ${persona.displayName}.${instanceCount > 0 ? ` Also deletes ${instanceCount} agent instance${instanceCount === 1 ? "" : "s"}.` : ""}`
: "Delete this agent."}
{personaDeleteDescription(persona, instanceCount)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
Expand Down
4 changes: 4 additions & 0 deletions desktop/src/features/profile/ui/UserProfileAgentActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,10 @@ function AgentDeleteConfirmDialog({
<ul className="list-disc space-y-1.5 pl-5 text-sm text-muted-foreground">
<li>Removes the local management record and saved agent key</li>
<li>Removes the agent from every channel it belongs to</li>
<li>
Archives the agent&apos;s identity on the relay so it no longer
appears in member lists or mention suggestions
</li>
<li>
{isProviderAgent
? "Requests remote deletion; if it is online, Buzz first sends a shutdown command when possible. If the deployment cannot be reached through a channel, the remote process may keep running without local management."
Expand Down
13 changes: 9 additions & 4 deletions desktop/tests/e2e/agent-lifecycle-feedback.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,11 @@ test.describe("agent lifecycle feedback screenshots", () => {
const dialog = page.getByRole("alertdialog");
await expect(dialog).toBeVisible({ timeout: 5_000 });

// Core assertion: the cascade copy shows the correct instance count (plural).
await expect(dialog).toContainText("Also deletes 2 agent instances.");
// Core assertion: the cascade copy shows the correct instance count
// (plural) and discloses the relay-side archival (PR #2135).
await expect(dialog).toContainText(
"Also deletes 2 agent instances and archives their identities on the relay",
);

await waitForAnimations(page);

Expand Down Expand Up @@ -243,8 +246,10 @@ test.describe("agent lifecycle feedback screenshots", () => {
const dialog = page.getByRole("alertdialog");
await expect(dialog).toBeVisible({ timeout: 5_000 });

// Singular copy: "Also deletes 1 agent instance." (not "instances").
await expect(dialog).toContainText("Also deletes 1 agent instance.");
// Singular copy ("instance", "its identity") plus the archival disclosure.
await expect(dialog).toContainText(
"Also deletes 1 agent instance and archives its identity on the relay",
);

await waitForAnimations(page);
await dialog.screenshot({
Expand Down