mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
parent
a4d82ec722
commit
4d58deafaa
@@ -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(¤t.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'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({
|
||||
|
||||
Reference in New Issue
Block a user