mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub1ng3jzsaqxdhrfq22dg85j3lpr0zsh3jp7g2h9jyxl59wraayapnsu6kvfg
parent
be95a8a986
commit
f810a2f49e
@@ -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";
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user