fix(ui): write playground key settings only through the encoded storage path

Key source, virtual key and custom headers now go through their own write loop, separate from the plain settings, so a key value can never take the plain sessionStorage branch
This commit is contained in:
Yuneng Jiang 2026-10-16 10:55:55 -07:00
parent e547be51a5
commit ca4e742115
No known key found for this signature in database
4 changed files with 54 additions and 24 deletions

View file

@ -6,7 +6,7 @@ import { makeOpenAIChatCompletionRequest } from "@/components/llm_calls/chat_com
import { fetchAvailableModels } from "@/components/llm_calls/fetch_models"; import { fetchAvailableModels } from "@/components/llm_calls/fetch_models";
import { makeOpenAIResponsesRequest } from "@/components/llm_calls/responses_api"; import { makeOpenAIResponsesRequest } from "@/components/llm_calls/responses_api";
import { callMCPTool, fetchMCPServers, fetchMCPToolsets, listMCPTools } from "@/components/networking"; import { callMCPTool, fetchMCPServers, fetchMCPToolsets, listMCPTools } from "@/components/networking";
import { setSecureItem } from "@/utils/secureStorage"; import { getSecureItem, setSecureItem } from "@/utils/secureStorage";
import { PlaygroundChat } from "./PlaygroundChat"; import { PlaygroundChat } from "./PlaygroundChat";
vi.mock("@/app/(dashboard)/hooks/useCan", () => ({ default: () => true })); vi.mock("@/app/(dashboard)/hooks/useCan", () => ({ default: () => true }));
@ -487,4 +487,17 @@ describe("PlaygroundChat", () => {
expect(screen.queryByText("Python Code Executed")).not.toBeInTheDocument(); expect(screen.queryByText("Python Code Executed")).not.toBeInTheDocument();
}); });
it("keeps the virtual key encoded in session storage", async () => {
setSecureItem("apiKeySource", JSON.stringify("custom"));
setSecureItem("apiKey", "sk-virtual-123");
preload({ selectedModel: "gpt-fast" });
renderChat();
await waitFor(() => expect(fetchAvailableModels).toHaveBeenCalledWith("sk-virtual-123"));
expect(getSecureItem("apiKey")).toBe("sk-virtual-123");
const stored = Object.keys(sessionStorage).map((key) => sessionStorage.getItem(key) ?? "");
expect(stored.length).toBeGreaterThan(0);
expect(stored.filter((value) => value.includes("sk-virtual-123"))).toEqual([]);
});
}); });

View file

@ -6,7 +6,8 @@ import {
allowedTools, allowedTools,
defaultSettings, defaultSettings,
loadSettings, loadSettings,
storedValues, plainValues,
secureValues,
toggleMcpItem, toggleMcpItem,
toggleToolLimit, toggleToolLimit,
withEndpoint, withEndpoint,
@ -29,7 +30,7 @@ const TOOLS = ["search", "fetch", "summarize"] as const;
const SCOPE = { server: "s", tools: TOOLS }; const SCOPE = { server: "s", tools: TOOLS };
describe("loadSettings", () => { describe("loadSettings", () => {
it("restores every persisted field from what storedValues wrote", () => { it("restores every persisted field from what was written", () => {
const persisted: Partial<PlaygroundSettings> = { const persisted: Partial<PlaygroundSettings> = {
keySource: "custom", keySource: "custom",
virtualKey: "sk-test", virtualKey: "sk-test",
@ -48,8 +49,8 @@ describe("loadSettings", () => {
}; };
const original = settingsWith(persisted); const original = settingsWith(persisted);
const written = Object.fromEntries( const written = Object.fromEntries(
storedValues(original) [...secureValues(original), ...plainValues(original)]
.filter((entry): entry is { key: string; value: string; secure: boolean } => entry.value !== null) .filter((entry): entry is { key: string; value: string } => entry.value !== null)
.map(({ key, value }) => [key, value]), .map(({ key, value }) => [key, value]),
); );

View file

@ -29,7 +29,7 @@ export type PlaygroundSettings = Readonly<{
maxTokens: number | null; maxTokens: number | null;
}>; }>;
export type StoredValue = Readonly<{ key: string; value: string | null; secure: boolean }>; export type StoredValue = Readonly<{ key: string; value: string | null }>;
export type SettingsStorage = Readonly<{ export type SettingsStorage = Readonly<{
read: (key: string) => string | null; read: (key: string) => string | null;
@ -122,21 +122,24 @@ export const loadSettings = (storage: SettingsStorage, keysDisabled: boolean): P
stream: storage.read("streamingEnabled") !== "false", stream: storage.read("streamingEnabled") !== "false",
}); });
export const storedValues = (settings: PlaygroundSettings): readonly StoredValue[] => [ export const secureValues = (settings: PlaygroundSettings): readonly StoredValue[] => [
{ key: "apiKeySource", value: JSON.stringify(settings.keySource), secure: true }, { key: "apiKeySource", value: JSON.stringify(settings.keySource) },
{ key: "apiKey", value: settings.virtualKey, secure: true }, { key: "apiKey", value: settings.virtualKey },
{ key: "customHeaders", value: JSON.stringify(settings.headerPairs), secure: true }, { key: "customHeaders", value: JSON.stringify(settings.headerPairs) },
{ key: "customProxyBaseUrl", value: settings.proxyBaseUrl || null, secure: false }, ];
{ key: "endpointType", value: settings.endpoint, secure: false },
{ key: "selectedModel", value: settings.model, secure: false }, export const plainValues = (settings: PlaygroundSettings): readonly StoredValue[] => [
{ key: "selectedVoice", value: settings.voice, secure: false }, { key: "customProxyBaseUrl", value: settings.proxyBaseUrl || null },
{ key: "selectedTags", value: JSON.stringify(settings.tags), secure: false }, { key: "endpointType", value: settings.endpoint },
{ key: "selectedVectorStores", value: JSON.stringify(settings.vectorStores), secure: false }, { key: "selectedModel", value: settings.model },
{ key: "selectedGuardrails", value: JSON.stringify(settings.guardrails), secure: false }, { key: "selectedVoice", value: settings.voice },
{ key: "selectedPolicies", value: JSON.stringify(settings.policies), secure: false }, { key: "selectedTags", value: JSON.stringify(settings.tags) },
{ key: "selectedMCPServers", value: JSON.stringify(settings.mcpSelection), secure: false }, { key: "selectedVectorStores", value: JSON.stringify(settings.vectorStores) },
{ key: "mcpServerToolRestrictions", value: JSON.stringify(settings.toolLimits), secure: false }, { key: "selectedGuardrails", value: JSON.stringify(settings.guardrails) },
{ key: "streamingEnabled", value: String(settings.stream), secure: false }, { key: "selectedPolicies", value: JSON.stringify(settings.policies) },
{ key: "selectedMCPServers", value: JSON.stringify(settings.mcpSelection) },
{ key: "mcpServerToolRestrictions", value: JSON.stringify(settings.toolLimits) },
{ key: "streamingEnabled", value: String(settings.stream) },
]; ];
const singleServer = (selection: readonly string[]): readonly string[] => const singleServer = (selection: readonly string[]): readonly string[] =>

View file

@ -1,6 +1,12 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { getSecureItem, setSecureItem } from "@/utils/secureStorage"; import { getSecureItem, setSecureItem } from "@/utils/secureStorage";
import { defaultSettings, loadSettings, storedValues, type PlaygroundSettings } from "./playgroundSettings"; import {
defaultSettings,
loadSettings,
plainValues,
secureValues,
type PlaygroundSettings,
} from "./playgroundSettings";
export type SettingsUpdate = Partial<PlaygroundSettings> | ((current: PlaygroundSettings) => PlaygroundSettings); export type SettingsUpdate = Partial<PlaygroundSettings> | ((current: PlaygroundSettings) => PlaygroundSettings);
@ -10,10 +16,17 @@ const browserStorage = {
}; };
const persist = (settings: PlaygroundSettings): void => { const persist = (settings: PlaygroundSettings): void => {
storedValues(settings).forEach(({ key, value, secure }) => { secureValues(settings).forEach(({ key, value }) => {
try {
if (value === null) window.sessionStorage.removeItem(key);
else setSecureItem(key, value);
} catch {
return;
}
});
plainValues(settings).forEach(({ key, value }) => {
try { try {
if (value === null) window.sessionStorage.removeItem(key); if (value === null) window.sessionStorage.removeItem(key);
else if (secure) setSecureItem(key, value);
else window.sessionStorage.setItem(key, value); else window.sessionStorage.setItem(key, value);
} catch { } catch {
return; return;