From 4434d3976621dc4663367bde781039d3671776d4 Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Date: Tue, 28 Jul 2026 01:45:18 -0400 Subject: [PATCH] feat(desktop): Lock card toggle in mint dialog + locked import disclosure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI half of the optional locked-card feature: - AgentCardMintDialog: Lock switch (canLock-gated — enabled only when the persona has a linked agent instance/pubkey; disabled with a 'start this agent once' explanation for bare definitions, per Wren's discoverability preference), locked-specific post-mint copy. - UserProfilePanel: canLock = Boolean(managedAgent?.pubkey) threaded into the mint target. - tauriPersonas: mintAgentCard(id, styleNotes?, lock?); MintedAgentCard and AgentSnapshotImportPreview gain locked. - AgentSnapshotImportDialog: locked-card provenance notice rendered only when preview.locked (unlocked by local keys, full decrypted payload disclosed as usual) + rendering test. - e2eBridge preview mock updated with the new preview fields. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- .../agents/ui/AgentCardMintDialog.tsx | 40 +++++++++++++++++-- .../agents/ui/AgentSnapshotImportDialog.tsx | 18 ++++++++- .../ui/agentSnapshotImportDialog.test.mjs | 20 ++++++++++ .../features/profile/ui/UserProfilePanel.tsx | 4 ++ desktop/src/shared/api/tauriPersonas.ts | 14 +++++++ desktop/src/testing/e2eBridge.ts | 3 ++ 6 files changed, 95 insertions(+), 4 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentCardMintDialog.tsx b/desktop/src/features/agents/ui/AgentCardMintDialog.tsx index c35f3048d..1f4b835d3 100644 --- a/desktop/src/features/agents/ui/AgentCardMintDialog.tsx +++ b/desktop/src/features/agents/ui/AgentCardMintDialog.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { Download, RefreshCw, Send, Sparkles } from "lucide-react"; +import { Download, Lock, RefreshCw, Send, Sparkles } from "lucide-react"; import { useMutation } from "@tanstack/react-query"; import { toast } from "sonner"; @@ -22,6 +22,7 @@ import { DialogHeader, DialogTitle, } from "@/shared/ui/dialog"; +import { Switch } from "@/shared/ui/switch"; import { Textarea } from "@/shared/ui/textarea"; import { PersonaShareRecipients } from "./PersonaShareRecipients"; @@ -43,14 +44,21 @@ function cardBytesFromBase64(b64: string): number[] { export function AgentCardMintDialog({ agentId, agentName, + canLock, onOpenChange, }: { /** Instance pubkey or definition slug — same resolution as snapshot export. */ agentId: string; agentName: string; + /** + * True when the agent has a linked instance (a keypair to lock to). + * Locking is disabled — with an explanation — for bare definitions. + */ + canLock: boolean; onOpenChange: (open: boolean) => void; }) { const [styleNotes, setStyleNotes] = React.useState(""); + const [lockCard, setLockCard] = React.useState(false); const [card, setCard] = React.useState(null); const [recipients, setRecipients] = React.useState([]); @@ -59,7 +67,12 @@ export function AgentCardMintDialog({ const upsertCachedChannel = useUpsertCachedChannel(); const mintMutation = useMutation({ - mutationFn: () => mintAgentCard(agentId, styleNotes.trim() || undefined), + mutationFn: () => + mintAgentCard( + agentId, + styleNotes.trim() || undefined, + canLock && lockCard, + ), onSuccess: (minted) => setCard(minted), onError: (error: Error) => { if (error.message.startsWith(NO_OPENAI_KEY_PREFIX)) { @@ -127,7 +140,9 @@ export function AgentCardMintDialog({ {card - ? "The card carries the agent — anyone who imports this PNG gets a working copy (config only, fresh identity, no memories)." + ? card.locked + ? "This card is locked: only you and the agent can import it. Anyone else sees just the image." + : "The card carries the agent — anyone who imports this PNG gets a working copy (config only, fresh identity, no memories)." : "Mint a collectible trading card that doubles as a shareable, importable copy of this agent."} @@ -191,6 +206,25 @@ export function AgentCardMintDialog({ rows={3} value={styleNotes} /> +
+
+ + + Lock card + + + {canLock + ? "Encrypt the embedded agent so only you and this agent can import it. Anyone else sees just the image." + : "Locking needs a linked agent instance — start this agent once to enable it."} + +
+ +
+ {/* Locked-card provenance: this file was encrypted to this machine's + keys and has been unlocked for review. */} + {preview.locked ? ( +
+ +

+ This card is locked — its agent is encrypted to the + original owner and agent keys. Your keys unlocked it; the full + decrypted payload is shown below. +

+
+ ) : null} + {/* Portable behavior — never hide executable configuration behind a summary. */}
{ ); }); +// ── locked-card provenance notice ───────────────────────────────────────────── + +test("preview_body_shows_locked_notice_only_for_locked_cards", () => { + const lockedNotice = (locked) => + findAll( + PreviewBody({ + preview: makePreview({ locked }), + hasMemory: false, + memoryLevelLabel: "none", + keepAllowlist: false, + onKeepAllowlistChange: () => {}, + }), + (n) => n.props?.["data-testid"] === "agent-snapshot-import-locked-notice", + ); + + assert.equal(lockedNotice(true).length, 1); + assert.equal(lockedNotice(false).length, 0); +}); + // ── memory errors detail list ───────────────────────────────────────────────── test("result_body_renders_memory_errors_list_with_test_id", () => { diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index e89bb01d6..a362ba3bd 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -185,6 +185,7 @@ export function UserProfilePanel({ const [cardMintTarget, setCardMintTarget] = React.useState<{ id: string; name: string; + canLock: boolean; } | null>(null); const personasQuery = usePersonasQuery(); @@ -837,6 +838,8 @@ export function UserProfilePanel({ // persona/definition id (same resolution as export). id: managedAgent?.pubkey ?? resolvedPersona.id, name: resolvedPersona.displayName, + // Locking needs an instance keypair to encrypt to. + canLock: Boolean(managedAgent?.pubkey), }) : undefined } @@ -978,6 +981,7 @@ export function UserProfilePanel({ { if (!open) setCardMintTarget(null); }} diff --git a/desktop/src/shared/api/tauriPersonas.ts b/desktop/src/shared/api/tauriPersonas.ts index f3e27bee5..799ed4370 100644 --- a/desktop/src/shared/api/tauriPersonas.ts +++ b/desktop/src/shared/api/tauriPersonas.ts @@ -177,6 +177,8 @@ export type MintedAgentCard = { fileName: string; /** Designer commentary emitted alongside the image (may be empty). */ designerNotes: string; + /** True when the embedded manifest is encrypted to the (owner, agent) pair. */ + locked: boolean; }; /** Error prefix Rust returns when no OpenAI key is configured. */ @@ -185,14 +187,20 @@ export const NO_OPENAI_KEY_PREFIX = "NO_OPENAI_KEY:"; /** * Mint a trading card for an agent. One long API call (~2–3 minutes). * Reroll = call again; the backend holds no session state. + * + * When `lock` is true, the embedded manifest is NIP-44-encrypted to the + * (owner, agent) pair: only those two keys can import the card. Requires a + * linked agent instance. */ export async function mintAgentCard( id: string, styleNotes?: string, + lock?: boolean, ): Promise { return invokeTauri("mint_agent_card", { id, styleNotes: styleNotes || null, + lock: lock ?? null, }); } @@ -222,6 +230,12 @@ export type AgentSnapshotImportPreview = { sourceAllowlist: string[]; /** Validated, pretty-printed manifest for full payload disclosure. */ manifestJson: string; + /** + * True when the snapshot came from a locked (encrypted) card this machine + * unlocked. Cards that cannot be unlocked fail with a refusal error and + * never reach a preview. + */ + locked: boolean; }; /** Confirmation sent to `confirm_agent_snapshot_import`. */ diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 03c05fe87..e670573f9 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -10356,6 +10356,9 @@ export function maybeInstallE2eTauriMocks() { memoryEntryCount: 0, hasSourceAllowlist: false, sourceAllowlistCount: 0, + sourceAllowlist: [], + manifestJson: "{}", + locked: false, }; } case "confirm_agent_snapshot_import": {