fix(desktop): make OpenAI key re-enterable after first save in card mint dialog (#4140)

Fixes a write-once dead-end in the card mint dialog where a user with an
expired OpenAI key had no way to replace it.

**Source-aware key status (Rust + TypeScript).** `card_mint_key_status`
returns a layer discriminant (`"none" | "global" | "persona" | "agent" |
"process"`) instead of a boolean. A pure `resolve_key_layer()` helper in
`card.rs` owns the classification logic; `card_mint_key_status`
delegates to it, so the production path is under direct test with no
duplicate logic.

**Mint form always reachable.** The key panel replaces the mint form
only for `none` (first-time setup) or when the user explicitly opens the
edit panel (`editingKey`). Keys from agent/persona/process layers show
an inline provenance row on the mint form with a "Why?" affordance;
clicking it shows the read-only redirect in a panel with a Cancel button
that returns to the mint form — never a terminal state.

**Precise auth-error matching.** The 401 handling in `cardMintStore.ts`
matches `startsWith("Card mint failed (HTTP 401 ")` plus the specific
`Incorrect API key` text, so avatar-fetch 401 errors pass through
unchanged.

**Tri-state key status row.** "Using your saved OpenAI key · Update"
renders only when `keyLayer === "global"` (confirmed writable key).
Query pending or errored hides the row without asserting key existence.

**Real tests.** Panel visibility derivations live in
`cardMintKeyUtils.ts`, which `AgentCardMintDialog.tsx` imports directly.
Tests cover all layers including the mint-reachability invariant (Mint
reachable for every resolved layer; only `none` gates setup).

- `card.rs` — new `resolve_key_layer()` pure helper;
`card_mint_key_status` delegates to it; 999 lines (under the 1000-line
ratchet)
- `card/tests.rs` — precedence test calls `resolve_key_layer()` directly
(no test-local closure); adds process-layer and blank-value cases
- `tauriPersonas.ts` — `CardMintKeyLayer` type; updated
`cardMintKeyStatus` signature
- `cardMintKeyUtils.ts` — `showKeyPanel`, `showReadOnlyRow`,
`showCancelButton`, `keyPanelTitle`, and helpers; component imports all
of them
- `AgentCardMintDialog.tsx` — inline provenance rows for all key
sources; key panel only for setup/edit; no unused variables
- `cardMintStore.ts` — precise 401 prefix matching
- `e2eBridge.ts` — `card_mint_key_status` stub returns `"global"` (not
boolean)
- Tests: 3959 JS passing, 2089 Rust passing, `tsc --noEmit` clean

Related: [block/buzz#4406](https://github.com/block/buzz/pull/4406)

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1ng3jzsaqxdhrfq22dg85j3lpr0zsh3jp7g2h9jyxl59wraayapnsu6kvfg <9a232143a0336e34814a6a0f4947e11bc50bc641f21572c886fd0ae1f7a4e867@buzz.block.builderlab.xyz>
This commit is contained in:
Will Pfleger
2026-08-03 11:12:09 -04:00
committed by GitHub
co-authored by npub1ng3jzsaqxdhrfq22dg85j3lpr0zsh3jp7g2h9jyxl59wraayapnsu6kvfg
parent be95a8a986
commit f810a2f49e
9 changed files with 674 additions and 61 deletions
@@ -283,6 +283,35 @@ pub(crate) fn resolve_env_from_layers(
process_value.filter(|k| !k.trim().is_empty())
}
/// Pure classification: same four env inputs as `resolve_env_from_layers`,
/// returns which layer supplies `OPENAI_API_KEY` (agent > persona > global >
/// process > none).
pub(crate) fn resolve_key_layer(
global_env: &std::collections::BTreeMap<String, String>,
persona_env: &std::collections::BTreeMap<String, String>,
record_env: &std::collections::BTreeMap<String, String>,
process_value: Option<String>,
) -> &'static str {
let key = "OPENAI_API_KEY";
let nonempty = |m: &std::collections::BTreeMap<String, String>| {
m.get(key).is_some_and(|v| !v.trim().is_empty())
};
if nonempty(record_env) {
return "agent";
}
if nonempty(persona_env) {
return "persona";
}
if nonempty(global_env) {
return "global";
}
let proc = process_value.as_deref().unwrap_or("");
if !proc.trim().is_empty() {
return "process";
}
"none"
}
/// The Responses endpoint to post mints to. `OPENAI_BASE_URL` (same env
/// layering as the key) overrides the default host, supporting endpoints and
/// proxies that speak the OpenAI Responses shape with Bearer auth. Azure
@@ -450,16 +479,15 @@ pub fn card_mint_save_openai_key(
save_global_agent_config(&app, &config)
}
/// Report whether an OpenAI key would resolve for a card mint of agent `id`,
/// using exactly the same env layering as `mint_agent_card`. Lets the mint
/// dialog offer inline key setup BEFORE the user commits to a mint, instead
/// of failing after the fact. Never returns the key itself.
/// Report which env layer resolves the OpenAI key for a card mint of agent
/// `id` — same layering as `mint_agent_card`. Delegates to `resolve_key_layer`
/// for the classification; see that helper for the return-value contract.
#[tauri::command]
pub fn card_mint_key_status(
id: String,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<bool, String> {
) -> Result<String, String> {
let _store_guard = state
.managed_agents_store_lock
.lock()
@@ -478,14 +506,13 @@ pub fn card_mint_key_status(
.map(|p| p.env_vars.clone())
.unwrap_or_default();
Ok(resolve_env_from_layers(
"OPENAI_API_KEY",
Ok(resolve_key_layer(
&global.env_vars,
&persona_env,
&record.env_vars,
std::env::var("OPENAI_API_KEY").ok(),
)
.is_some())
.to_string())
}
/// Mint a trading card for the agent identified by `id` (instance pubkey,
@@ -71,6 +71,81 @@ fn key_resolution_layering_record_wins() {
assert!(resolve_env_from_layers("OPENAI_API_KEY", &global, &persona, &record, None).is_none());
}
/// Prove that `resolve_key_layer` classifies layers in the same precedence
/// order that `mint_agent_card`/`resolve_env_from_layers` uses, so the dialog
/// update path is only offered when writing global will actually win.
#[test]
fn key_status_layer_matches_mint_resolution_priority() {
let key = "OPENAI_API_KEY";
let mut global = BTreeMap::new();
let mut persona = BTreeMap::new();
let mut record = BTreeMap::new();
// No key anywhere → "none"
assert_eq!(resolve_key_layer(&global, &persona, &record, None), "none");
// Only global → "global" (the only writable layer)
global.insert(key.to_string(), "sk-global".to_string());
assert_eq!(
resolve_key_layer(&global, &persona, &record, None),
"global"
);
// mint resolution also picks global when record and persona are empty
assert_eq!(
resolve_env_from_layers(key, &global, &persona, &record, None).as_deref(),
Some("sk-global")
);
// Persona overrides global → status must report "persona", NOT "global"
persona.insert(key.to_string(), "sk-persona".to_string());
assert_eq!(
resolve_key_layer(&global, &persona, &record, None),
"persona"
);
// mint would use the persona key
assert_eq!(
resolve_env_from_layers(key, &global, &persona, &record, None).as_deref(),
Some("sk-persona")
);
// Writing to global would NOT change what mint resolves — status correctly
// returns "persona" so the dialog shows a read-only redirect instead.
let mut global_updated = global.clone();
global_updated.insert(key.to_string(), "sk-new-global".to_string());
assert_eq!(
resolve_env_from_layers(key, &global_updated, &persona, &record, None).as_deref(),
Some("sk-persona"),
"writing global must not change resolution when persona key exists"
);
// Agent record overrides both → status must report "agent"
record.insert(key.to_string(), "sk-agent".to_string());
assert_eq!(resolve_key_layer(&global, &persona, &record, None), "agent");
assert_eq!(
resolve_env_from_layers(key, &global, &persona, &record, None).as_deref(),
Some("sk-agent")
);
// Process env is last resort (only when all map layers are empty)
let empty = BTreeMap::new();
assert_eq!(
resolve_key_layer(&empty, &empty, &empty, Some("sk-process".to_string())),
"process"
);
// Blank values are skipped — process wins over a whitespace global
let mut blank_global = BTreeMap::new();
blank_global.insert(key.to_string(), " ".to_string());
assert_eq!(
resolve_key_layer(
&blank_global,
&empty,
&empty,
Some("sk-process".to_string())
),
"process"
);
}
#[test]
fn key_resolution_skips_blank_values() {
let mut record = BTreeMap::new();
@@ -96,6 +96,55 @@ describe("cardMintStore", () => {
assert.equal(getCardMintJobs()[0].error, "No OPENAI_API_KEY found.");
});
it("replaces a 401 HTTP error with an actionable update-key message", async () => {
await runCardMintJob(INPUT, () =>
Promise.reject(
new Error(
"Card mint failed (HTTP 401 Unauthorized): Incorrect API key provided: sk-proj-***",
),
),
);
const { error } = getCardMintJobs()[0];
assert.ok(
error?.includes("invalid or expired"),
`expected 'invalid or expired' in: ${error}`,
);
assert.ok(
error?.includes("Update API key"),
`expected 'Update API key' in: ${error}`,
);
});
it("replaces an 'Incorrect API key' error without an HTTP status code", async () => {
await runCardMintJob(INPUT, () =>
Promise.reject(new Error("Incorrect API key provided: sk-proj-***")),
);
const { error } = getCardMintJobs()[0];
assert.ok(
error?.includes("invalid or expired"),
`expected 'invalid or expired' in: ${error}`,
);
});
it("does not apply the 401 branch to generic non-auth errors", async () => {
await runCardMintJob(INPUT, () =>
Promise.reject(new Error("Connection timeout")),
);
assert.equal(getCardMintJobs()[0].error, "Connection timeout");
});
it("does not rewrite avatar fetch 401 as an API key error", async () => {
// Avatar fetch failures have a different error prefix — rewriting them
// would send the user down a path that cannot fix the avatar failure.
const avatarError = "Avatar fetch failed: HTTP 401 Unauthorized";
await runCardMintJob(INPUT, () => Promise.reject(new Error(avatarError)));
assert.equal(
getCardMintJobs()[0].error,
avatarError,
"avatar 401 must pass through unchanged",
);
});
it("viewMintedCardJob moves a done job into the viewer and clears the chip", async () => {
await runCardMintJob(INPUT, () => Promise.resolve(CARD));
const jobId = getCardMintJobs()[0].jobId;
@@ -123,6 +123,15 @@ export async function runCardMintJob(
// removed between dialog-open and mint. The dialog's key-setup panel is
// long gone — surface a plain instruction instead of the wire prefix.
message = message.slice(NO_OPENAI_KEY_PREFIX.length).trim();
} else if (
message.startsWith("Card mint failed (HTTP 401 ") ||
message.includes("Incorrect API key")
) {
// The saved OpenAI key is invalid or expired. Only match the OpenAI-call
// envelope prefix and the specific Incorrect-API-key message to avoid
// rewriting unrelated 401s (e.g. "Avatar fetch failed: HTTP 401 …").
message =
'The OpenAI API key is invalid or expired. Open the mint dialog and use "Update API key" to replace it.';
}
updateJob(jobId, { phase: "error", error: message });
toast.error(`Minting ${input.agentName}'s card failed`, {
@@ -0,0 +1,257 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
// Tests for the key-panel visibility derivations that AgentCardMintDialog
// imports from cardMintKeyUtils. These tests exercise the exact production
// module — changes to any exported function will cause failures here.
import {
isReadOnlyLayer,
isWritableLayer,
keyPanelTitle,
showCancelButton,
showKeyPanel,
showKeyStatusRow,
showReadOnlyRow,
} from "./cardMintKeyUtils.ts";
describe("cardMintKeyUtils — key panel derivations", () => {
// ── isWritableLayer ────────────────────────────────────────────────────────
it("isWritableLayer_none_true", () => {
assert.equal(isWritableLayer("none"), true);
});
it("isWritableLayer_global_true", () => {
assert.equal(isWritableLayer("global"), true);
});
it("isWritableLayer_agent_false", () => {
assert.equal(isWritableLayer("agent"), false);
});
it("isWritableLayer_persona_false", () => {
assert.equal(isWritableLayer("persona"), false);
});
it("isWritableLayer_process_false", () => {
assert.equal(isWritableLayer("process"), false);
});
it("isWritableLayer_undefined_false", () => {
// Unknown (pending/error) — don't offer a write path
assert.equal(isWritableLayer(undefined), false);
});
// ── isReadOnlyLayer ────────────────────────────────────────────────────────
it("isReadOnlyLayer_agent_true", () => {
assert.equal(isReadOnlyLayer("agent"), true);
});
it("isReadOnlyLayer_persona_true", () => {
assert.equal(isReadOnlyLayer("persona"), true);
});
it("isReadOnlyLayer_process_true", () => {
assert.equal(isReadOnlyLayer("process"), true);
});
it("isReadOnlyLayer_global_false", () => {
assert.equal(isReadOnlyLayer("global"), false);
});
it("isReadOnlyLayer_none_false", () => {
assert.equal(isReadOnlyLayer("none"), false);
});
it("isReadOnlyLayer_undefined_false", () => {
assert.equal(isReadOnlyLayer(undefined), false);
});
// ── showKeyPanel ───────────────────────────────────────────────────────────
// Key panel replaces the mint form ONLY for first-time setup (none) or
// user-initiated editing (editingKey). Read-only layers do NOT replace the
// mint form — they show an inline status row instead.
it("showKeyPanel_none_notEditing_shows", () => {
// First-time user: key not set → show setup panel
assert.equal(showKeyPanel("none", false), true);
});
it("showKeyPanel_global_notEditing_hides", () => {
// Normal state: key in global defaults, not editing → show mint form
assert.equal(showKeyPanel("global", false), false);
});
it("showKeyPanel_global_editing_shows", () => {
// User clicked Update → show the update panel
assert.equal(showKeyPanel("global", true), true);
});
it("showKeyPanel_agent_notEditing_hides", () => {
// Read-only layer: mint form stays visible; inline row shown instead
assert.equal(showKeyPanel("agent", false), false);
});
it("showKeyPanel_persona_notEditing_hides", () => {
assert.equal(showKeyPanel("persona", false), false);
});
it("showKeyPanel_process_notEditing_hides", () => {
assert.equal(showKeyPanel("process", false), false);
});
it("showKeyPanel_agent_editing_shows", () => {
// User clicked Why? on a read-only row → show the redirect panel
assert.equal(showKeyPanel("agent", true), true);
});
it("showKeyPanel_persona_editing_shows", () => {
assert.equal(showKeyPanel("persona", true), true);
});
it("showKeyPanel_process_editing_shows", () => {
assert.equal(showKeyPanel("process", true), true);
});
it("showKeyPanel_undefined_notEditing_hides", () => {
// Query pending/error → show mint form (fail-open, no panel claim)
assert.equal(showKeyPanel(undefined, false), false);
});
// ── showCancelButton ───────────────────────────────────────────────────────
it("showCancelButton_global_editing_shows", () => {
// Update mode for a global key: Cancel returns to the mint form
assert.equal(showCancelButton("global", true), true);
});
it("showCancelButton_none_editing_hides", () => {
// First-time setup: no cancel (no mint form to return to)
assert.equal(showCancelButton("none", true), false);
});
it("showCancelButton_global_notEditing_hides", () => {
assert.equal(showCancelButton("global", false), false);
});
it("showCancelButton_agent_editing_shows", () => {
// Read-only layer + user clicked Why?: Cancel returns to the mint form
assert.equal(showCancelButton("agent", true), true);
});
it("showCancelButton_persona_editing_shows", () => {
assert.equal(showCancelButton("persona", true), true);
});
it("showCancelButton_process_editing_shows", () => {
assert.equal(showCancelButton("process", true), true);
});
// ── showKeyStatusRow ───────────────────────────────────────────────────────
it("showKeyStatusRow_global_notEditing_shows", () => {
// Confirmed writable key: show "Using your saved OpenAI key · Update"
assert.equal(showKeyStatusRow("global", false), true);
});
it("showKeyStatusRow_global_editing_hides", () => {
// In update panel: status row is redundant while editing
assert.equal(showKeyStatusRow("global", true), false);
});
it("showKeyStatusRow_none_notEditing_hides", () => {
// No key: show setup panel, not status row
assert.equal(showKeyStatusRow("none", false), false);
});
it("showKeyStatusRow_agent_notEditing_hides", () => {
// Read-only layer: use showReadOnlyRow instead
assert.equal(showKeyStatusRow("agent", false), false);
});
it("showKeyStatusRow_undefined_notEditing_hides", () => {
// Query pending/error: do not assert key existence
assert.equal(showKeyStatusRow(undefined, false), false);
});
// ── showReadOnlyRow ────────────────────────────────────────────────────────
// Inline provenance row on the mint form for keys the dialog cannot update.
it("showReadOnlyRow_agent_notEditing_shows", () => {
assert.equal(showReadOnlyRow("agent", false), true);
});
it("showReadOnlyRow_persona_notEditing_shows", () => {
assert.equal(showReadOnlyRow("persona", false), true);
});
it("showReadOnlyRow_process_notEditing_shows", () => {
assert.equal(showReadOnlyRow("process", false), true);
});
it("showReadOnlyRow_agent_editing_hides", () => {
// User clicked Why? → redirect panel shown; row hidden
assert.equal(showReadOnlyRow("agent", true), false);
});
it("showReadOnlyRow_global_notEditing_hides", () => {
// Global key uses showKeyStatusRow instead
assert.equal(showReadOnlyRow("global", false), false);
});
it("showReadOnlyRow_none_hides", () => {
assert.equal(showReadOnlyRow("none", false), false);
});
it("showReadOnlyRow_undefined_hides", () => {
assert.equal(showReadOnlyRow(undefined, false), false);
});
// ── Mint-reachability invariant ────────────────────────────────────────────
// The mint form (and Mint button) must be reachable whenever a key resolves.
// showKeyPanel returns true only for setup (none) or user-initiated edit.
it("mintReachable_global_noEdit", () => {
assert.equal(showKeyPanel("global", false), false);
});
it("mintReachable_agent_noEdit", () => {
assert.equal(showKeyPanel("agent", false), false);
});
it("mintReachable_persona_noEdit", () => {
assert.equal(showKeyPanel("persona", false), false);
});
it("mintReachable_process_noEdit", () => {
assert.equal(showKeyPanel("process", false), false);
});
it("mintBlocked_none_noEdit", () => {
// Only when no key is set at all does the panel gate minting
assert.equal(showKeyPanel("none", false), true);
});
// ── keyPanelTitle ──────────────────────────────────────────────────────────
it("keyPanelTitle_none_firstTimeSetup", () => {
assert.equal(
keyPanelTitle("none", false),
"One-time setup: OpenAI API key",
);
});
it("keyPanelTitle_global_editing_update", () => {
assert.equal(keyPanelTitle("global", true), "Update OpenAI API key");
});
it("keyPanelTitle_agent_readOnly", () => {
assert.equal(keyPanelTitle("agent", false), "OpenAI API key");
});
it("keyPanelTitle_persona_readOnly", () => {
assert.equal(keyPanelTitle("persona", true), "OpenAI API key");
});
});
@@ -20,6 +20,7 @@ import { globalAgentConfigQueryKey } from "@/features/agents/useGlobalAgentConfi
import {
cardMintKeyStatus,
cardMintSaveOpenaiKey,
type CardMintKeyLayer,
type SnapshotMemoryLevel,
} from "@/shared/api/tauriPersonas";
import { Button } from "@/shared/ui/button";
@@ -34,6 +35,14 @@ import { Input } from "@/shared/ui/input";
import { Switch } from "@/shared/ui/switch";
import { Textarea } from "@/shared/ui/textarea";
import { SnapshotOptionMenu } from "./SnapshotOptionMenu";
import {
isReadOnlyLayer,
keyPanelTitle,
showCancelButton,
showKeyPanel,
showKeyStatusRow,
showReadOnlyRow,
} from "./cardMintKeyUtils";
const OPENAI_KEYS_URL = "https://platform.openai.com/api-keys";
@@ -121,6 +130,7 @@ export function AgentCardMintDialog({
const [memoryLevel, setMemoryLevel] =
React.useState<SnapshotMemoryLevel>("none");
const [keyDraft, setKeyDraft] = React.useState("");
const [editingKey, setEditingKey] = React.useState(false);
const queryClient = useQueryClient();
@@ -130,14 +140,16 @@ export function AgentCardMintDialog({
// (owner, agent) pair, so the plaintext warning would be false there.
const showMemoryWarning = memoryLevel !== "none" && !effectiveLock;
// Whether a key already resolves through the agent's env layering. While
// unknown (loading/error) we show the normal mint form — the mint itself
// still fails cleanly if no key exists.
// Whether a key already resolves through the agent's env layering, and from
// which layer. While unknown (loading/error) we treat as if no verified key
// exists — mint still works fail-open, but we don't assert a key is present.
const keyStatusQuery = useQuery({
queryKey: ["cardMintKeyStatus", agentId],
queryFn: () => cardMintKeyStatus(agentId),
});
const needsKey = keyStatusQuery.data === false;
const keyLayer: CardMintKeyLayer | undefined = keyStatusQuery.data;
// True when the key resolves from a layer this dialog cannot update.
const keyIsReadOnly = isReadOnlyLayer(keyLayer);
// Save the pasted key into the global Agent Defaults env — the same single
// source of truth every agent inherits. Narrow Rust seam: validated
@@ -146,13 +158,19 @@ export function AgentCardMintDialog({
const saveKeyMutation = useMutation({
mutationFn: (key: string) => cardMintSaveOpenaiKey(key),
onSuccess: () => {
queryClient.setQueryData(["cardMintKeyStatus", agentId], true);
// The key now lives in global defaults — update the cached layer so the
// status row shows correctly without waiting for a refetch.
queryClient.setQueryData<CardMintKeyLayer>(
["cardMintKeyStatus", agentId],
"global",
);
// The Agent Defaults editor caches the whole config — refetch it so a
// later-opened settings view shows the key we just wrote.
void queryClient.invalidateQueries({
queryKey: globalAgentConfigQueryKey,
});
setKeyDraft("");
setEditingKey(false);
toast.success(
"API key saved to your agent defaults. Running agents pick it up on their next restart.",
);
@@ -188,7 +206,7 @@ export function AgentCardMintDialog({
</DialogDescription>
</DialogHeader>
{needsKey ? (
{showKeyPanel(keyLayer, editingKey) ? (
<div
className="flex flex-col gap-4"
data-testid="agent-card-key-setup"
@@ -196,53 +214,90 @@ export function AgentCardMintDialog({
<div className="flex flex-col gap-2 rounded-md border border-border p-3">
<span className="flex items-center gap-1.5 text-sm font-medium">
<KeyRound className="h-3.5 w-3.5" />
One-time setup: OpenAI API key
{keyPanelTitle(keyLayer, editingKey)}
</span>
<p className="text-xs text-muted-foreground">
Minting a card costs money it generates the art and card text
through the OpenAI API with your key (typically well under a
dollar per mint, billed by OpenAI). The key is saved to your
agent defaults, so you only do this once.
</p>
<Button
className="w-fit px-0 text-xs"
data-testid="agent-card-key-link"
onClick={() =>
void openUrl(OPENAI_KEYS_URL).catch(() => {
toast.error("Failed to open link");
})
}
size="sm"
variant="link"
>
<ExternalLink className="mr-1 h-3 w-3" />
Get a key at platform.openai.com
</Button>
<Input
autoFocus
data-testid="agent-card-key-input"
disabled={saveKeyMutation.isPending}
onChange={(e) => setKeyDraft(e.target.value)}
placeholder="sk-…"
type="password"
value={keyDraft}
/>
{keyIsReadOnly ? (
// Key resolves from a layer the dialog cannot write to — show
// a read-only redirect instead of an input that would be
// shadowed by the higher-priority layer.
<p
className="text-xs text-muted-foreground"
data-testid="agent-card-key-readonly"
>
{keyLayer === "agent"
? "This agent's OpenAI key is set in its own agent settings — update it there."
: keyLayer === "persona"
? "This agent's OpenAI key comes from its linked persona settings — update it there."
: "This agent's OpenAI key is set in the process environment — update it in your shell or launch config."}
</p>
) : (
<>
<p className="text-xs text-muted-foreground">
Minting a card costs money it generates the art and card
text through the OpenAI API with your key (typically well
under a dollar per mint, billed by OpenAI). The key is saved
as <code className="font-mono">OPENAI_API_KEY</code> in your
agent defaults env that's the row to update in Settings if
you ever need to change it there.
</p>
<Button
className="w-fit px-0 text-xs"
data-testid="agent-card-key-link"
onClick={() =>
void openUrl(OPENAI_KEYS_URL).catch(() => {
toast.error("Failed to open link");
})
}
size="sm"
variant="link"
>
<ExternalLink className="mr-1 h-3 w-3" />
Get a key at platform.openai.com
</Button>
<Input
autoFocus
data-testid="agent-card-key-input"
disabled={saveKeyMutation.isPending}
onChange={(e) => setKeyDraft(e.target.value)}
placeholder="sk-…"
type="password"
value={keyDraft}
/>
</>
)}
</div>
<FreeSharePathRow
disabled={saveKeyMutation.isPending}
onExportInstead={onExportInstead}
/>
<div className="flex justify-end">
<Button
data-testid="agent-card-key-save"
disabled={
saveKeyMutation.isPending || keyDraft.trim().length === 0
}
onClick={() => saveKeyMutation.mutate(keyDraft.trim())}
>
<KeyRound className="mr-2 h-4 w-4" />
{saveKeyMutation.isPending ? "Saving…" : "Save key & continue"}
</Button>
<div className="flex justify-end gap-2">
{showCancelButton(keyLayer, editingKey) ? (
<Button
data-testid="agent-card-key-cancel"
disabled={saveKeyMutation.isPending}
onClick={() => {
setKeyDraft("");
setEditingKey(false);
}}
variant="outline"
>
Cancel
</Button>
) : null}
{!keyIsReadOnly ? (
<Button
data-testid="agent-card-key-save"
disabled={
saveKeyMutation.isPending || keyDraft.trim().length === 0
}
onClick={() => saveKeyMutation.mutate(keyDraft.trim())}
>
<KeyRound className="mr-2 h-4 w-4" />
{saveKeyMutation.isPending
? "Saving…"
: "Save key & continue"}
</Button>
) : null}
</div>
</div>
) : (
@@ -324,6 +379,50 @@ export function AgentCardMintDialog({
onCheckedChange={setLockCard}
/>
</div>
{showKeyStatusRow(keyLayer, editingKey) ? (
<div
className="flex items-center gap-1 text-xs text-muted-foreground"
data-testid="agent-card-key-status"
>
<KeyRound className="h-3 w-3 shrink-0" />
<span>Using your saved OpenAI key</span>
<span aria-hidden>·</span>
<Button
className="h-auto p-0 text-xs"
data-testid="agent-card-update-key"
onClick={() => setEditingKey(true)}
size="sm"
variant="link"
>
Update
</Button>
</div>
) : null}
{showReadOnlyRow(keyLayer, editingKey) ? (
<div
className="flex items-center gap-1 text-xs text-muted-foreground"
data-testid="agent-card-key-readonly-row"
>
<KeyRound className="h-3 w-3 shrink-0" />
<span>
{keyLayer === "agent"
? "OpenAI key from agent settings"
: keyLayer === "persona"
? "OpenAI key from persona settings"
: "OpenAI key from environment"}
</span>
<span aria-hidden>·</span>
<Button
className="h-auto p-0 text-xs"
data-testid="agent-card-key-why"
onClick={() => setEditingKey(true)}
size="sm"
variant="link"
>
Why?
</Button>
</div>
) : null}
<p
className="text-xs text-muted-foreground"
data-testid="agent-card-cost-note"
@@ -0,0 +1,75 @@
import type { CardMintKeyLayer } from "@/shared/api/tauriPersonas";
/**
* Pure derivations for the key-setup panel visibility in `AgentCardMintDialog`.
*
* Extracted so that: (a) the component imports and uses the exact same logic
* as the tests verify, and (b) changes to panel conditions are caught by
* test failures rather than silently diverging.
*/
/** Whether the key panel should be shown (setup or user-initiated update only). */
export function showKeyPanel(
keyLayer: CardMintKeyLayer | undefined,
editingKey: boolean,
): boolean {
return keyLayer === "none" || editingKey;
}
/**
* Whether the resolved layer is writable from the dialog.
* Only `"global"` (and unset/`"none"`) can be updated via the dialog seam.
*/
export function isWritableLayer(
keyLayer: CardMintKeyLayer | undefined,
): boolean {
return keyLayer === "global" || keyLayer === "none";
}
/**
* Whether the resolved layer cannot be updated from the dialog (key would be
* shadowed by the higher-priority layer even if global were updated).
*/
export function isReadOnlyLayer(
keyLayer: CardMintKeyLayer | undefined,
): boolean {
return (
keyLayer === "agent" || keyLayer === "persona" || keyLayer === "process"
);
}
/** Whether the "Cancel" button should be shown (update mode only, with a mint form to return to). */
export function showCancelButton(
keyLayer: CardMintKeyLayer | undefined,
editingKey: boolean,
): boolean {
return editingKey && keyLayer !== "none";
}
/** Whether the "Using your saved OpenAI key · Update" status row should be shown. */
export function showKeyStatusRow(
keyLayer: CardMintKeyLayer | undefined,
editingKey: boolean,
): boolean {
return keyLayer === "global" && !editingKey;
}
/** Whether the read-only provenance row should be shown on the mint form. */
export function showReadOnlyRow(
keyLayer: CardMintKeyLayer | undefined,
editingKey: boolean,
): boolean {
return isReadOnlyLayer(keyLayer) && !editingKey;
}
/** The header title for the key-setup panel. */
export function keyPanelTitle(
keyLayer: CardMintKeyLayer | undefined,
editingKey: boolean,
): string {
if (isReadOnlyLayer(keyLayer)) return "OpenAI API key";
if (keyLayer === "none") return "One-time setup: OpenAI API key";
return editingKey
? "Update OpenAI API key"
: "One-time setup: OpenAI API key";
}
+25 -4
View File
@@ -260,11 +260,32 @@ export type MintedAgentCard = {
export const NO_OPENAI_KEY_PREFIX = "NO_OPENAI_KEY:";
/**
* Check whether an OpenAI key would resolve for a card mint of this agent
* (same env layering as the mint itself). Never returns the key.
* Which env layer resolves the OpenAI key for a card mint of this agent.
*
* - `"none"` — no key configured anywhere; show the first-time setup panel.
* - `"global"` — key comes from global Agent Defaults env; writable from the
* dialog via `cardMintSaveOpenaiKey`.
* - `"persona"` — key is set on the linked persona; cannot be updated from
* the mint dialog (would be shadowed by the higher-priority layer).
* - `"agent"` — key is set directly on the agent record; same restriction.
* - `"process"` — key comes from the process environment (dev fallback);
* same restriction.
*/
export async function cardMintKeyStatus(id: string): Promise<boolean> {
return invokeTauri<boolean>("card_mint_key_status", { id });
export type CardMintKeyLayer =
| "none"
| "global"
| "persona"
| "agent"
| "process";
/**
* Report which env layer resolves the OpenAI key for a card mint of this agent
* (same layering as the mint itself). Returns a layer discriminant — never the
* key value itself. Use to decide whether to show a writable key input (none /
* global) or a read-only redirect (persona / agent / process).
*/
export async function cardMintKeyStatus(id: string): Promise<CardMintKeyLayer> {
return invokeTauri<CardMintKeyLayer>("card_mint_key_status", { id });
}
/**
+3 -2
View File
@@ -11852,8 +11852,9 @@ export function maybeInstallE2eTauriMocks() {
// command was invoked via `__BUZZ_E2E_COMMANDS__`, not the dialog.
return true;
case "card_mint_key_status":
// Cards: pretend a key is configured so the mint form renders.
return true;
// Cards: pretend a key is configured in global defaults so the mint
// form renders and the key-status row is shown.
return "global";
case "list_agent_cards":
// Cards archive starts empty in E2E; specs exercising the gallery
// can extend this with a seeded config knob when needed.