diff --git a/desktop/src-tauri/src/commands/personas/card.rs b/desktop/src-tauri/src/commands/personas/card.rs index c516db1736..29a5c35e6a 100644 --- a/desktop/src-tauri/src/commands/personas/card.rs +++ b/desktop/src-tauri/src/commands/personas/card.rs @@ -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, + persona_env: &std::collections::BTreeMap, + record_env: &std::collections::BTreeMap, + process_value: Option, +) -> &'static str { + let key = "OPENAI_API_KEY"; + let nonempty = |m: &std::collections::BTreeMap| { + 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 { +) -> Result { 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, diff --git a/desktop/src-tauri/src/commands/personas/card/tests.rs b/desktop/src-tauri/src/commands/personas/card/tests.rs index ca69c43866..407ab44974 100644 --- a/desktop/src-tauri/src/commands/personas/card/tests.rs +++ b/desktop/src-tauri/src/commands/personas/card/tests.rs @@ -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(); diff --git a/desktop/src/features/agents/cardMintStore.test.mjs b/desktop/src/features/agents/cardMintStore.test.mjs index eb9c0f07b0..6c2f4522ab 100644 --- a/desktop/src/features/agents/cardMintStore.test.mjs +++ b/desktop/src/features/agents/cardMintStore.test.mjs @@ -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; diff --git a/desktop/src/features/agents/cardMintStore.ts b/desktop/src/features/agents/cardMintStore.ts index 0e4746d445..c38bf393cc 100644 --- a/desktop/src/features/agents/cardMintStore.ts +++ b/desktop/src/features/agents/cardMintStore.ts @@ -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`, { diff --git a/desktop/src/features/agents/ui/AgentCardMintDialog.test.mjs b/desktop/src/features/agents/ui/AgentCardMintDialog.test.mjs new file mode 100644 index 0000000000..ff3efaec92 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentCardMintDialog.test.mjs @@ -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"); + }); +}); diff --git a/desktop/src/features/agents/ui/AgentCardMintDialog.tsx b/desktop/src/features/agents/ui/AgentCardMintDialog.tsx index 219de4aefa..b2bffc00df 100644 --- a/desktop/src/features/agents/ui/AgentCardMintDialog.tsx +++ b/desktop/src/features/agents/ui/AgentCardMintDialog.tsx @@ -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("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( + ["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({ - {needsKey ? ( + {showKeyPanel(keyLayer, editingKey) ? (
- One-time setup: OpenAI API key + {keyPanelTitle(keyLayer, editingKey)} -

- 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. -

- - 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. +

+ {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."} +

+ ) : ( + <> +

+ 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 OPENAI_API_KEY in your + agent defaults env — that's the row to update in Settings if + you ever need to change it there. +

+ + setKeyDraft(e.target.value)} + placeholder="sk-…" + type="password" + value={keyDraft} + /> + + )}
-
- +
+ {showCancelButton(keyLayer, editingKey) ? ( + + ) : null} + {!keyIsReadOnly ? ( + + ) : null}
) : ( @@ -324,6 +379,50 @@ export function AgentCardMintDialog({ onCheckedChange={setLockCard} /> + {showKeyStatusRow(keyLayer, editingKey) ? ( +
+ + Using your saved OpenAI key + · + +
+ ) : null} + {showReadOnlyRow(keyLayer, editingKey) ? ( +
+ + + {keyLayer === "agent" + ? "OpenAI key from agent settings" + : keyLayer === "persona" + ? "OpenAI key from persona settings" + : "OpenAI key from environment"} + + · + +
+ ) : null}

{ - return invokeTauri("card_mint_key_status", { id }); +export async function cardMintKeyStatus(id: string): Promise { + return invokeTauri("card_mint_key_status", { id }); } /** diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 355ccea9fc..a5dc007a3a 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -11834,8 +11834,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.