Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 35 additions & 8 deletions desktop/src-tauri/src/commands/personas/card.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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,
Expand Down
75 changes: 75 additions & 0 deletions desktop/src-tauri/src/commands/personas/card/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
49 changes: 49 additions & 0 deletions desktop/src/features/agents/cardMintStore.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
9 changes: 9 additions & 0 deletions desktop/src/features/agents/cardMintStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`, {
Expand Down
Loading
Loading