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 * 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 { useMutation } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/shared/ui/dialog";
|
} from "@/shared/ui/dialog";
|
||||||
|
import { Switch } from "@/shared/ui/switch";
|
||||||
import { Textarea } from "@/shared/ui/textarea";
|
import { Textarea } from "@/shared/ui/textarea";
|
||||||
|
|
||||||
import { PersonaShareRecipients } from "./PersonaShareRecipients";
|
import { PersonaShareRecipients } from "./PersonaShareRecipients";
|
||||||
@@ -43,14 +44,21 @@ function cardBytesFromBase64(b64: string): number[] {
|
|||||||
export function AgentCardMintDialog({
|
export function AgentCardMintDialog({
|
||||||
agentId,
|
agentId,
|
||||||
agentName,
|
agentName,
|
||||||
|
canLock,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: {
|
}: {
|
||||||
/** Instance pubkey or definition slug — same resolution as snapshot export. */
|
/** Instance pubkey or definition slug — same resolution as snapshot export. */
|
||||||
agentId: string;
|
agentId: string;
|
||||||
agentName: 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;
|
onOpenChange: (open: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const [styleNotes, setStyleNotes] = React.useState("");
|
const [styleNotes, setStyleNotes] = React.useState("");
|
||||||
|
const [lockCard, setLockCard] = React.useState(false);
|
||||||
const [card, setCard] = React.useState<MintedAgentCard | null>(null);
|
const [card, setCard] = React.useState<MintedAgentCard | null>(null);
|
||||||
const [recipients, setRecipients] = React.useState<UserSearchResult[]>([]);
|
const [recipients, setRecipients] = React.useState<UserSearchResult[]>([]);
|
||||||
|
|
||||||
@@ -59,7 +67,12 @@ export function AgentCardMintDialog({
|
|||||||
const upsertCachedChannel = useUpsertCachedChannel();
|
const upsertCachedChannel = useUpsertCachedChannel();
|
||||||
|
|
||||||
const mintMutation = useMutation({
|
const mintMutation = useMutation({
|
||||||
mutationFn: () => mintAgentCard(agentId, styleNotes.trim() || undefined),
|
mutationFn: () =>
|
||||||
|
mintAgentCard(
|
||||||
|
agentId,
|
||||||
|
styleNotes.trim() || undefined,
|
||||||
|
canLock && lockCard,
|
||||||
|
),
|
||||||
onSuccess: (minted) => setCard(minted),
|
onSuccess: (minted) => setCard(minted),
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
if (error.message.startsWith(NO_OPENAI_KEY_PREFIX)) {
|
if (error.message.startsWith(NO_OPENAI_KEY_PREFIX)) {
|
||||||
@@ -127,7 +140,9 @@ export function AgentCardMintDialog({
|
|||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{card
|
{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."}
|
: "Mint a collectible trading card that doubles as a shareable, importable copy of this agent."}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -191,6 +206,25 @@ export function AgentCardMintDialog({
|
|||||||
rows={3}
|
rows={3}
|
||||||
value={styleNotes}
|
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">
|
<div className="flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
disabled={isMinting}
|
disabled={isMinting}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { AlertCircle, Upload } from "lucide-react";
|
import { AlertCircle, Lock, Upload } from "lucide-react";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AgentSnapshotImportPreview,
|
AgentSnapshotImportPreview,
|
||||||
@@ -159,6 +159,22 @@ export function PreviewBody({
|
|||||||
<p className="text-sm font-medium">{preview.displayName}</p>
|
<p className="text-sm font-medium">{preview.displayName}</p>
|
||||||
</div>
|
</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. */}
|
{/* Portable behavior — never hide executable configuration behind a summary. */}
|
||||||
<section
|
<section
|
||||||
className="space-y-2 rounded-md border border-border p-3"
|
className="space-y-2 rounded-md border border-border p-3"
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ function makePreview(overrides = {}) {
|
|||||||
sourceAllowlistCount: 2,
|
sourceAllowlistCount: 2,
|
||||||
sourceAllowlist: ["a".repeat(64), "b".repeat(64)],
|
sourceAllowlist: ["a".repeat(64), "b".repeat(64)],
|
||||||
manifestJson: '{\n "format": "buzz-agent-snapshot"\n}',
|
manifestJson: '{\n "format": "buzz-agent-snapshot"\n}',
|
||||||
|
locked: false,
|
||||||
...overrides,
|
...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 ─────────────────────────────────────────────────
|
// ── memory errors detail list ─────────────────────────────────────────────────
|
||||||
|
|
||||||
test("result_body_renders_memory_errors_list_with_test_id", () => {
|
test("result_body_renders_memory_errors_list_with_test_id", () => {
|
||||||
|
|||||||
@@ -185,6 +185,7 @@ export function UserProfilePanel({
|
|||||||
const [cardMintTarget, setCardMintTarget] = React.useState<{
|
const [cardMintTarget, setCardMintTarget] = React.useState<{
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
canLock: boolean;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const personasQuery = usePersonasQuery();
|
const personasQuery = usePersonasQuery();
|
||||||
@@ -837,6 +838,8 @@ export function UserProfilePanel({
|
|||||||
// persona/definition id (same resolution as export).
|
// persona/definition id (same resolution as export).
|
||||||
id: managedAgent?.pubkey ?? resolvedPersona.id,
|
id: managedAgent?.pubkey ?? resolvedPersona.id,
|
||||||
name: resolvedPersona.displayName,
|
name: resolvedPersona.displayName,
|
||||||
|
// Locking needs an instance keypair to encrypt to.
|
||||||
|
canLock: Boolean(managedAgent?.pubkey),
|
||||||
})
|
})
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
@@ -978,6 +981,7 @@ export function UserProfilePanel({
|
|||||||
<AgentCardMintDialog
|
<AgentCardMintDialog
|
||||||
agentId={cardMintTarget.id}
|
agentId={cardMintTarget.id}
|
||||||
agentName={cardMintTarget.name}
|
agentName={cardMintTarget.name}
|
||||||
|
canLock={cardMintTarget.canLock}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) setCardMintTarget(null);
|
if (!open) setCardMintTarget(null);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -177,6 +177,8 @@ export type MintedAgentCard = {
|
|||||||
fileName: string;
|
fileName: string;
|
||||||
/** Designer commentary emitted alongside the image (may be empty). */
|
/** Designer commentary emitted alongside the image (may be empty). */
|
||||||
designerNotes: string;
|
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. */
|
/** 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).
|
* Mint a trading card for an agent. One long API call (~2–3 minutes).
|
||||||
* Reroll = call again; the backend holds no session state.
|
* 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(
|
export async function mintAgentCard(
|
||||||
id: string,
|
id: string,
|
||||||
styleNotes?: string,
|
styleNotes?: string,
|
||||||
|
lock?: boolean,
|
||||||
): Promise<MintedAgentCard> {
|
): Promise<MintedAgentCard> {
|
||||||
return invokeTauri<MintedAgentCard>("mint_agent_card", {
|
return invokeTauri<MintedAgentCard>("mint_agent_card", {
|
||||||
id,
|
id,
|
||||||
styleNotes: styleNotes || null,
|
styleNotes: styleNotes || null,
|
||||||
|
lock: lock ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,6 +230,12 @@ export type AgentSnapshotImportPreview = {
|
|||||||
sourceAllowlist: string[];
|
sourceAllowlist: string[];
|
||||||
/** Validated, pretty-printed manifest for full payload disclosure. */
|
/** Validated, pretty-printed manifest for full payload disclosure. */
|
||||||
manifestJson: string;
|
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`. */
|
/** Confirmation sent to `confirm_agent_snapshot_import`. */
|
||||||
|
|||||||
@@ -10356,6 +10356,9 @@ export function maybeInstallE2eTauriMocks() {
|
|||||||
memoryEntryCount: 0,
|
memoryEntryCount: 0,
|
||||||
hasSourceAllowlist: false,
|
hasSourceAllowlist: false,
|
||||||
sourceAllowlistCount: 0,
|
sourceAllowlistCount: 0,
|
||||||
|
sourceAllowlist: [],
|
||||||
|
manifestJson: "{}",
|
||||||
|
locked: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case "confirm_agent_snapshot_import": {
|
case "confirm_agent_snapshot_import": {
|
||||||
|
|||||||
Reference in New Issue
Block a user