Archive managed agents when deleted (#2135)

Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Tyler
2026-07-20 08:03:57 -07:00
committed by GitHub
co-authored by npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
parent a4d82ec722
commit 4d58deafaa
9 changed files with 274 additions and 7 deletions
+93
View File
@@ -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,
@@ -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(())
@@ -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.
@@ -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);
@@ -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()
@@ -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
@@ -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.
@@ -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.");
});
@@ -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,
@@ -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>
@@ -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."
@@ -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);
@@ -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({