Skip to content
Open
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
2 changes: 1 addition & 1 deletion packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,7 @@ export type ExtensionState = Pick<
marketplaceInstalledMetadata?: { project: Record<string, any>; global: Record<string, any> }
profileThresholds: Record<string, number>
hasOpenedModeSelector: boolean
openRouterImageApiKey?: string
openRouterImageApiKeyConfigured: boolean
messageQueue?: QueuedMessage[]
lastShownAnnouncementId?: string
apiModelId?: string
Expand Down
5 changes: 3 additions & 2 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2352,7 +2352,7 @@ export class ClineProvider
maxGitStatusFiles: maxGitStatusFiles ?? 0,
taskSyncEnabled,
imageGenerationProvider,
openRouterImageApiKey,
openRouterImageApiKeyConfigured: !!openRouterImageApiKey,
openRouterImageGenerationSelectedModel,
openAiCodexIsAuthenticated: await (async () => {
try {
Expand All @@ -2376,7 +2376,7 @@ export class ClineProvider
Omit<
ExtensionState,
"clineMessages" | "renderContext" | "hasOpenedModeSelector" | "version" | "shouldShowAnnouncement"
>
> & { openRouterImageApiKey?: string }
> {
const stateValues = this.contextProxy.getValues()
const customModes = await this.customModesManager.getCustomModes()
Expand Down Expand Up @@ -2572,6 +2572,7 @@ export class ClineProvider
taskSyncEnabled,
imageGenerationProvider: stateValues.imageGenerationProvider,
openRouterImageApiKey: stateValues.openRouterImageApiKey,
openRouterImageApiKeyConfigured: !!stateValues.openRouterImageApiKey,
openRouterImageGenerationSelectedModel: stateValues.openRouterImageGenerationSelectedModel,
}
}
Expand Down
23 changes: 22 additions & 1 deletion src/core/webview/__tests__/ClineProvider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -549,7 +549,7 @@ describe("ClineProvider", () => {
profileThresholds: {},
hasOpenedModeSelector: false,
diagnosticsEnabled: true,
openRouterImageApiKey: undefined,
openRouterImageApiKeyConfigured: false,
openRouterImageGenerationSelectedModel: undefined,
taskSyncEnabled: false,
checkpointTimeout: DEFAULT_CHECKPOINT_TIMEOUT_SECONDS,
Expand All @@ -564,6 +564,27 @@ describe("ClineProvider", () => {
expect(mockPostMessage).toHaveBeenCalledWith(message)
})

describe("getStateToPostToWebview secrets redaction", () => {
test("omits raw openRouterImageApiKey from broadcast payload when key is set", async () => {
// Use contextProxy.setValue rather than mocking secrets.get because ContextProxy only
// reads secrets.get during initialize() (which ran during provider construction). Any
// mock changes after construction are too late — they never reach secretCache.
await provider.contextProxy.setValue("openRouterImageApiKey", "sk-or-v1-supersecret")

const state = await provider.getStateToPostToWebview()

expect("openRouterImageApiKey" in state).toBe(false)
expect(state.openRouterImageApiKeyConfigured).toBe(true)
})

test("sets openRouterImageApiKeyConfigured to false when no key is stored", async () => {
const state = await provider.getStateToPostToWebview()

expect("openRouterImageApiKey" in state).toBe(false)
expect(state.openRouterImageApiKeyConfigured).toBe(false)
})
})

test("postMessageToWebview does not throw when webview is disposed", async () => {
await provider.resolveWebviewView(mockWebviewView)

Expand Down
6 changes: 3 additions & 3 deletions webview-ui/src/components/settings/ExperimentalSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ type ExperimentalSettingsProps = HTMLAttributes<HTMLDivElement> & {
apiConfiguration?: any
setApiConfigurationField?: any
imageGenerationProvider?: ImageGenerationProvider
openRouterImageApiKey?: string
openRouterImageApiKeyConfigured: boolean
openRouterImageGenerationSelectedModel?: string
setImageGenerationProvider?: (provider: ImageGenerationProvider) => void
setOpenRouterImageApiKey?: (apiKey: string) => void
Expand All @@ -34,7 +34,7 @@ export const ExperimentalSettings = ({
apiConfiguration,
setApiConfigurationField,
imageGenerationProvider,
openRouterImageApiKey,
openRouterImageApiKeyConfigured,
openRouterImageGenerationSelectedModel,
setImageGenerationProvider,
setOpenRouterImageApiKey,
Expand Down Expand Up @@ -74,7 +74,7 @@ export const ExperimentalSettings = ({
setExperimentEnabled(EXPERIMENT_IDS.IMAGE_GENERATION, enabled)
}
imageGenerationProvider={imageGenerationProvider}
openRouterImageApiKey={openRouterImageApiKey}
openRouterImageApiKeyConfigured={openRouterImageApiKeyConfigured}
openRouterImageGenerationSelectedModel={openRouterImageGenerationSelectedModel}
setImageGenerationProvider={setImageGenerationProvider}
setOpenRouterImageApiKey={setOpenRouterImageApiKey}
Expand Down
15 changes: 10 additions & 5 deletions webview-ui/src/components/settings/ImageGenerationSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ interface ImageGenerationSettingsProps {
enabled: boolean
onChange: (enabled: boolean) => void
imageGenerationProvider?: ImageGenerationProvider
openRouterImageApiKey?: string
openRouterImageApiKeyConfigured: boolean
openRouterImageGenerationSelectedModel?: string
setImageGenerationProvider: (provider: ImageGenerationProvider) => void
setOpenRouterImageApiKey: (apiKey: string) => void
Expand All @@ -18,7 +18,7 @@ export const ImageGenerationSettings = ({
enabled,
onChange,
imageGenerationProvider,
openRouterImageApiKey,
openRouterImageApiKeyConfigured,
openRouterImageGenerationSelectedModel,
setImageGenerationProvider,
setOpenRouterImageApiKey,
Expand Down Expand Up @@ -88,7 +88,7 @@ export const ImageGenerationSettings = ({
}

const requiresApiKey = currentProvider === "openrouter"
const isConfigured = !requiresApiKey || (requiresApiKey && openRouterImageApiKey)
const isConfigured = !requiresApiKey || openRouterImageApiKeyConfigured

return (
<div className="space-y-4">
Expand Down Expand Up @@ -133,9 +133,14 @@ export const ImageGenerationSettings = ({
{t("settings:experimental.IMAGE_GENERATION.openRouterApiKeyLabel")}
</label>
<VSCodeTextField
value={openRouterImageApiKey || ""}
onInput={(e: any) => handleApiKeyChange(e.target.value)}
placeholder={t("settings:experimental.IMAGE_GENERATION.openRouterApiKeyPlaceholder")}
placeholder={
openRouterImageApiKeyConfigured
? t(
"settings:experimental.IMAGE_GENERATION.openRouterApiKeyConfiguredPlaceholder",
)
: t("settings:experimental.IMAGE_GENERATION.openRouterApiKeyPlaceholder")
}
className="w-full"
type="password"
/>
Expand Down
18 changes: 8 additions & 10 deletions webview-ui/src/components/settings/SettingsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
const [isDiscardDialogShow, setDiscardDialogShow] = useState(false)
const [isChangeDetected, setChangeDetected] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined)
const [pendingImageApiKey, setPendingImageApiKey] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<SectionName>(
targetSection && sectionNames.includes(targetSection as SectionName)
? (targetSection as SectionName)
Expand Down Expand Up @@ -196,7 +197,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
maxDiagnosticMessages,
includeTaskHistoryInEnhance,
imageGenerationProvider,
openRouterImageApiKey,
openRouterImageApiKeyConfigured,
openRouterImageGenerationSelectedModel,
reasoningBlockCollapsed,
enterBehavior,
Expand Down Expand Up @@ -323,13 +324,8 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
}, [])

const setOpenRouterImageApiKey = useCallback((apiKey: string) => {
setCachedState((prevState) => {
if (prevState.openRouterImageApiKey !== apiKey) {
setChangeDetected(true)
}

return { ...prevState, openRouterImageApiKey: apiKey }
})
setPendingImageApiKey(apiKey)
setChangeDetected(true)
}, [])

const setImageGenerationSelectedModel = useCallback((model: string) => {
Expand Down Expand Up @@ -418,7 +414,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
maxGitStatusFiles: maxGitStatusFiles ?? 0,
profileThresholds,
imageGenerationProvider,
openRouterImageApiKey,
...(pendingImageApiKey !== null ? { openRouterImageApiKey: pendingImageApiKey || undefined } : {}),
openRouterImageGenerationSelectedModel,
experiments,
customSupportPrompts,
Expand All @@ -431,6 +427,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
vscode.postMessage({ type: "telemetrySetting", text: telemetrySetting })
vscode.postMessage({ type: "debugSetting", bool: cachedState.debug })

setPendingImageApiKey(null)
setChangeDetected(false)
}
}
Expand All @@ -454,6 +451,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
if (confirm) {
// Discard changes: Reset state and flag
setCachedState(extensionState) // Revert to original state
setPendingImageApiKey(null)
setChangeDetected(false) // Reset change flag
confirmDialogHandler.current?.() // Execute the pending action (e.g., tab switch)
}
Expand Down Expand Up @@ -904,7 +902,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
apiConfiguration={apiConfiguration}
setApiConfigurationField={setApiConfigurationField}
imageGenerationProvider={imageGenerationProvider}
openRouterImageApiKey={openRouterImageApiKey as string | undefined}
openRouterImageApiKeyConfigured={openRouterImageApiKeyConfigured ?? false}
openRouterImageGenerationSelectedModel={
openRouterImageGenerationSelectedModel as string | undefined
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ describe("ImageGenerationSettings", () => {
enabled: false,
onChange: mockOnChange,
imageGenerationProvider: undefined,
openRouterImageApiKey: undefined,
openRouterImageApiKeyConfigured: false,
openRouterImageGenerationSelectedModel: undefined,
setImageGenerationProvider: mockSetImageGenerationProvider,
setOpenRouterImageApiKey: mockSetOpenRouterImageApiKey,
Expand All @@ -44,7 +44,7 @@ describe("ImageGenerationSettings", () => {
render(
<ImageGenerationSettings
{...defaultProps}
openRouterImageApiKey="existing-key"
openRouterImageApiKeyConfigured={true}
openRouterImageGenerationSelectedModel="google/gemini-2.5-flash-image"
/>,
)
Expand Down Expand Up @@ -90,6 +90,21 @@ describe("ImageGenerationSettings", () => {
).toBeInTheDocument()
})

it("should show configured placeholder when openRouterImageApiKeyConfigured is true", () => {
const { getByPlaceholderText } = render(
<ImageGenerationSettings
{...defaultProps}
enabled={true}
imageGenerationProvider="openrouter"
openRouterImageApiKeyConfigured={true}
/>,
)

expect(
getByPlaceholderText("settings:experimental.IMAGE_GENERATION.openRouterApiKeyConfiguredPlaceholder"),
).toBeInTheDocument()
})

it("should not render API key field when provider is roo", () => {
const { queryByPlaceholderText } = render(
<ImageGenerationSettings {...defaultProps} enabled={true} imageGenerationProvider="roo" />,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ describe("SettingsView - Change Detection Fix", () => {
includeDiagnosticMessages: false,
maxDiagnosticMessages: 50,
includeTaskHistoryInEnhance: true,
openRouterImageApiKey: undefined,
openRouterImageApiKeyConfigured: false,
openRouterImageGenerationSelectedModel: undefined,
reasoningBlockCollapsed: true,
...overrides,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ import React from "react"

import SettingsView from "../SettingsView"

// Mock vscode API
// Mock the vscode utility module (acquireVsCodeApi is only available inside the VS Code webview)
vi.mock("@src/utils/vscode", () => ({
vscode: { postMessage: vi.fn(), getState: vi.fn(), setState: vi.fn() },
}))

// Legacy global mock retained for other test setups that may rely on it
const mockPostMessage = vi.fn()
const mockVscode = {
postMessage: mockPostMessage,
Expand Down Expand Up @@ -242,6 +247,8 @@ vi.mock("../SettingsSearch", () => ({

import { useExtensionState } from "@src/context/ExtensionStateContext"
import ApiOptions from "../ApiOptions"
import { ExperimentalSettings } from "../ExperimentalSettings"
import { vscode } from "@src/utils/vscode"

describe("SettingsView - Unsaved Changes Detection", () => {
let queryClient: QueryClient
Expand Down Expand Up @@ -304,7 +311,7 @@ describe("SettingsView - Unsaved Changes Detection", () => {
includeDiagnosticMessages: false,
maxDiagnosticMessages: 50,
includeTaskHistoryInEnhance: true,
openRouterImageApiKey: undefined,
openRouterImageApiKeyConfigured: false,
openRouterImageGenerationSelectedModel: undefined,
reasoningBlockCollapsed: true,
}
Expand All @@ -316,6 +323,8 @@ describe("SettingsView - Unsaved Changes Detection", () => {
// Don't do anything with props, just render a div
return <div data-testid="api-options">ApiOptions</div>
})
// Reset ExperimentalSettings to silent default
vi.mocked(ExperimentalSettings).mockImplementation(() => <div>ExperimentalSettings</div>)
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
Expand Down Expand Up @@ -603,4 +612,96 @@ describe("SettingsView - Unsaved Changes Detection", () => {
// No dialog should appear
expect(screen.queryByText("settings:unsavedChangesDialog.title")).not.toBeInTheDocument()
})

describe("pending image API key", () => {
const renderWithApiKeyTrigger = () => {
vi.mocked(ExperimentalSettings).mockImplementation(({ setOpenRouterImageApiKey }: any) => (
<div>
<button data-testid="set-api-key" onClick={() => setOpenRouterImageApiKey?.("sk-or-new-key")}>
Set Key
</button>
<button data-testid="clear-api-key" onClick={() => setOpenRouterImageApiKey?.("")}>
Clear Key
</button>
</div>
))
// Render with the experimental tab active so ExperimentalSettings is mounted
return render(
<QueryClientProvider client={queryClient}>
<SettingsView onDone={vi.fn()} targetSection="experimental" />
</QueryClientProvider>,
)
}

it("typing a new key marks settings as changed and includes key in save payload", async () => {
renderWithApiKeyTrigger()

await waitFor(() => expect(screen.getByTestId("save-button")).toBeInTheDocument())

fireEvent.click(screen.getByTestId("set-api-key"))

// Save button should now be enabled
await waitFor(() => {
expect((screen.getByTestId("save-button") as HTMLButtonElement).disabled).toBe(false)
})

fireEvent.click(screen.getByTestId("save-button"))

await waitFor(() => {
const calls = vi.mocked(vscode.postMessage).mock.calls
const updateCall = (calls.find(([msg]: any) => msg?.type === "updateSettings") as any)?.[0]
expect(updateCall).toBeDefined()
expect(updateCall.updatedSettings.openRouterImageApiKey).toBe("sk-or-new-key")
})
})

it("clearing the key sends undefined (triggers deletion) rather than empty string", async () => {
renderWithApiKeyTrigger()

await waitFor(() => expect(screen.getByTestId("save-button")).toBeInTheDocument())

fireEvent.click(screen.getByTestId("clear-api-key"))

await waitFor(() => {
expect((screen.getByTestId("save-button") as HTMLButtonElement).disabled).toBe(false)
})

fireEvent.click(screen.getByTestId("save-button"))

await waitFor(() => {
const calls = vi.mocked(vscode.postMessage).mock.calls
const updateCall = (calls.find(([msg]: any) => msg?.type === "updateSettings") as any)?.[0]
expect(updateCall).toBeDefined()
// Empty string should become undefined so the host deletes the secret
expect(updateCall.updatedSettings.openRouterImageApiKey).toBeUndefined()
})
})

it("discarding changes resets pending key so save button returns to disabled", async () => {
renderWithApiKeyTrigger()

await waitFor(() => expect(screen.getByTestId("save-button")).toBeInTheDocument())

fireEvent.click(screen.getByTestId("set-api-key"))

await waitFor(() => {
expect((screen.getByTestId("save-button") as HTMLButtonElement).disabled).toBe(false)
})

// Click Done to trigger the unsaved-changes dialog
fireEvent.click(screen.getByText("settings:common.done"))

await waitFor(() => {
expect(screen.getByText("settings:unsavedChangesDialog.title")).toBeInTheDocument()
})

// Confirm discard
fireEvent.click(screen.getByText("settings:unsavedChangesDialog.discardButton"))

// Save button should be disabled again (pending key cleared)
await waitFor(() => {
expect((screen.getByTestId("save-button") as HTMLButtonElement).disabled).toBe(true)
})
})
})
})
Loading
Loading