feat(desktop): Lock card toggle in mint dialog + locked import disclosure

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 <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
2026-07-28 01:45:18 -04:00
co-authored by Tyler Longwell
parent 5bca0227f5
commit 4434d39766
6 changed files with 95 additions and 4 deletions
@@ -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<MintedAgentCard | null>(null);
const [recipients, setRecipients] = React.useState<UserSearchResult[]>([]);
@@ -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({
</DialogTitle>
<DialogDescription>
{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."}
</DialogDescription>
</DialogHeader>
@@ -191,6 +206,25 @@ export function AgentCardMintDialog({
rows={3}
value={styleNotes}
/>
<div className="flex items-start justify-between gap-3 rounded-md border border-border p-3">
<div className="flex flex-col gap-0.5">
<span className="flex items-center gap-1.5 text-sm font-medium">
<Lock className="h-3.5 w-3.5" />
Lock card
</span>
<span className="text-xs text-muted-foreground">
{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."}
</span>
</div>
<Switch
checked={canLock && lockCard}
data-testid="agent-card-lock-toggle"
disabled={isMinting || !canLock}
onCheckedChange={setLockCard}
/>
</div>
<div className="flex justify-end">
<Button
disabled={isMinting}
@@ -1,5 +1,5 @@
import * as React from "react";
import { AlertCircle, Upload } from "lucide-react";
import { AlertCircle, Lock, Upload } from "lucide-react";
import type {
AgentSnapshotImportPreview,
@@ -159,6 +159,22 @@ export function PreviewBody({
<p className="text-sm font-medium">{preview.displayName}</p>
</div>
{/* Locked-card provenance: this file was encrypted to this machine's
keys and has been unlocked for review. */}
{preview.locked ? (
<div
className="flex items-start gap-2 rounded-md border border-border bg-muted/40 px-3 py-2 text-sm"
data-testid="agent-snapshot-import-locked-notice"
>
<Lock className="mt-0.5 h-4 w-4 shrink-0" />
<p>
This card is <strong>locked</strong> — its agent is encrypted to the
original owner and agent keys. Your keys unlocked it; the full
decrypted payload is shown below.
</p>
</div>
) : null}
{/* Portable behavior — never hide executable configuration behind a summary. */}
<section
className="space-y-2 rounded-md border border-border p-3"
@@ -82,6 +82,7 @@ function makePreview(overrides = {}) {
sourceAllowlistCount: 2,
sourceAllowlist: ["a".repeat(64), "b".repeat(64)],
manifestJson: '{\n "format": "buzz-agent-snapshot"\n}',
locked: false,
...overrides,
};
}
@@ -113,6 +114,25 @@ test("preview_body_discloses_prompt_allowlist_and_full_manifest", () => {
);
});
// ── 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", () => {
@@ -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({
<AgentCardMintDialog
agentId={cardMintTarget.id}
agentName={cardMintTarget.name}
canLock={cardMintTarget.canLock}
onOpenChange={(open) => {
if (!open) setCardMintTarget(null);
}}
+14
View File
@@ -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<MintedAgentCard> {
return invokeTauri<MintedAgentCard>("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`. */
+3
View File
@@ -10356,6 +10356,9 @@ export function maybeInstallE2eTauriMocks() {
memoryEntryCount: 0,
hasSourceAllowlist: false,
sourceAllowlistCount: 0,
sourceAllowlist: [],
manifestJson: "{}",
locked: false,
};
}
case "confirm_agent_snapshot_import": {