mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Tyler Longwell
parent
5bca0227f5
commit
4434d39766
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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`. */
|
||||
|
||||
@@ -10356,6 +10356,9 @@ export function maybeInstallE2eTauriMocks() {
|
||||
memoryEntryCount: 0,
|
||||
hasSourceAllowlist: false,
|
||||
sourceAllowlistCount: 0,
|
||||
sourceAllowlist: [],
|
||||
manifestJson: "{}",
|
||||
locked: false,
|
||||
};
|
||||
}
|
||||
case "confirm_agent_snapshot_import": {
|
||||
|
||||
Reference in New Issue
Block a user