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
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
15 changes: 13 additions & 2 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -951,6 +951,16 @@ export class ClineProvider
})
this.webviewDisposables.push(configDisposable)

// Re-broadcast state when a secret changes in another window or is updated by the OS keyring,
// so openRouterImageApiKeyConfigured stays accurate across all open windows.
const secretsDisposable = this.context.secrets.onDidChange(async ({ key }) => {
if (key === "openRouterImageApiKey") {
await this.contextProxy.refreshSecrets()
await this.postStateToWebview()
}
})
this.webviewDisposables.push(secretsDisposable)

// If the extension is starting a new session, clear previous task state.
// But don't clear if there's already an active task (e.g., resumed via IPC/bridge).
const currentTask = this.getCurrentTask()
Expand Down Expand Up @@ -2352,7 +2362,7 @@ export class ClineProvider
maxGitStatusFiles: maxGitStatusFiles ?? 0,
taskSyncEnabled,
imageGenerationProvider,
openRouterImageApiKey,
openRouterImageApiKeyConfigured: !!openRouterImageApiKey,
openRouterImageGenerationSelectedModel,
openAiCodexIsAuthenticated: await (async () => {
try {
Expand All @@ -2376,7 +2386,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 +2582,7 @@ export class ClineProvider
taskSyncEnabled,
imageGenerationProvider: stateValues.imageGenerationProvider,
openRouterImageApiKey: stateValues.openRouterImageApiKey,
openRouterImageApiKeyConfigured: !!stateValues.openRouterImageApiKey,
openRouterImageGenerationSelectedModel: stateValues.openRouterImageGenerationSelectedModel,
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ describe("ClineProvider - API Handler Rebuild Guard", () => {
get: vi.fn().mockImplementation((key: string) => secrets[key]),
store: vi.fn().mockImplementation((key: string, value: string | undefined) => (secrets[key] = value)),
delete: vi.fn().mockImplementation((key: string) => delete secrets[key]),
onDidChange: vi.fn().mockReturnValue({ dispose: vi.fn() }),
},
workspaceState: {
get: vi.fn().mockReturnValue(undefined),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ describe("ClineProvider flicker-free cancel", () => {
get: vi.fn().mockResolvedValue(undefined),
store: vi.fn().mockResolvedValue(undefined),
delete: vi.fn().mockResolvedValue(undefined),
onDidChange: vi.fn().mockReturnValue({ dispose: vi.fn() }),
},
workspaceState: {
get: vi.fn().mockReturnValue(undefined),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ describe("ClineProvider - Lock API Config Across Modes", () => {
delete secrets[key]
return Promise.resolve()
}),
onDidChange: vi.fn().mockReturnValue({ dispose: vi.fn() }),
},
workspaceState: {
get: vi.fn().mockImplementation((key: string, defaultValue?: unknown) => {
Expand Down
89 changes: 86 additions & 3 deletions src/core/webview/__tests__/ClineProvider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,7 @@ describe("ClineProvider", () => {
get: vi.fn().mockImplementation((key: string) => secrets[key]),
store: vi.fn().mockImplementation((key: string, value: string | undefined) => (secrets[key] = value)),
delete: vi.fn().mockImplementation((key: string) => delete secrets[key]),
onDidChange: vi.fn().mockReturnValue({ dispose: vi.fn() }),
},
workspaceState: {
get: vi.fn().mockReturnValue(undefined),
Expand Down Expand Up @@ -549,7 +550,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 +565,75 @@ 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)
})
})

describe("secrets.onDidChange integration", () => {
test("rebroadcasts state when openRouterImageApiKey changes in another window", async () => {
await provider.resolveWebviewView(mockWebviewView)

// Capture the onDidChange callback registered by resolveWebviewView
const onDidChangeMock = mockContext.secrets.onDidChange as ReturnType<typeof vi.fn>
expect(onDidChangeMock).toHaveBeenCalled()
const secretsChangeHandler = onDidChangeMock.mock.calls[0][0] as (event: { key: string }) => Promise<void>

// Simulate the OS keyring updating the key in another VS Code window
await provider.contextProxy.setValue("openRouterImageApiKey", "sk-or-v1-newkey")
mockPostMessage.mockClear()

await secretsChangeHandler({ key: "openRouterImageApiKey" })

// postStateToWebview must have fired so the webview gets the updated configured flag
expect(mockPostMessage).toHaveBeenCalled()
const stateMessage = mockPostMessage.mock.calls.find(([msg]: [any]) => msg?.type === "state")
expect(stateMessage).toBeDefined()
expect(stateMessage![0].state.openRouterImageApiKeyConfigured).toBe(true)
expect("openRouterImageApiKey" in stateMessage![0].state).toBe(false)
})

test("does not rebroadcast state for unrelated secret key changes", async () => {
await provider.resolveWebviewView(mockWebviewView)

const onDidChangeMock = mockContext.secrets.onDidChange as ReturnType<typeof vi.fn>
const secretsChangeHandler = onDidChangeMock.mock.calls[0][0] as (event: { key: string }) => Promise<void>

mockPostMessage.mockClear()

await secretsChangeHandler({ key: "someOtherSecret" })

expect(mockPostMessage).not.toHaveBeenCalled()
})

test("disposes the secrets listener when the webview is disposed", async () => {
await provider.resolveWebviewView(mockWebviewView)

const onDidChangeMock = mockContext.secrets.onDidChange as ReturnType<typeof vi.fn>
const disposable = onDidChangeMock.mock.results[0].value as { dispose: ReturnType<typeof vi.fn> }

await provider.dispose()

expect(disposable.dispose).toHaveBeenCalled()
})
})

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

Expand Down Expand Up @@ -2014,6 +2084,7 @@ describe("Project MCP Settings", () => {
get: vi.fn(),
store: vi.fn(),
delete: vi.fn(),
onDidChange: vi.fn().mockReturnValue({ dispose: vi.fn() }),
},
workspaceState: {
get: vi.fn().mockReturnValue(undefined),
Expand Down Expand Up @@ -2155,7 +2226,12 @@ describe.skip("ContextProxy integration", () => {
update: vi.fn().mockResolvedValue(undefined),
keys: vi.fn().mockReturnValue([]),
},
secrets: { get: vi.fn(), store: vi.fn(), delete: vi.fn() },
secrets: {
get: vi.fn(),
store: vi.fn(),
delete: vi.fn(),
onDidChange: vi.fn().mockReturnValue({ dispose: vi.fn() }),
},
extensionUri: {} as vscode.Uri,
globalStorageUri: { fsPath: "/test/path" },
extension: { packageJSON: { version: "1.0.0" } },
Expand Down Expand Up @@ -2225,7 +2301,12 @@ describe("getTelemetryProperties", () => {
update: vi.fn().mockResolvedValue(undefined),
keys: vi.fn().mockReturnValue([]),
},
secrets: { get: vi.fn(), store: vi.fn(), delete: vi.fn() },
secrets: {
get: vi.fn(),
store: vi.fn(),
delete: vi.fn(),
onDidChange: vi.fn().mockReturnValue({ dispose: vi.fn() }),
},
extensionUri: {} as vscode.Uri,
globalStorageUri: { fsPath: "/test/path" },
extension: { packageJSON: { version: "1.0.0" } },
Expand Down Expand Up @@ -2386,6 +2467,7 @@ describe("ClineProvider - Router Models", () => {
get: vi.fn().mockImplementation((key: string) => secrets[key]),
store: vi.fn().mockImplementation((key: string, value: string | undefined) => (secrets[key] = value)),
delete: vi.fn().mockImplementation((key: string) => delete secrets[key]),
onDidChange: vi.fn().mockReturnValue({ dispose: vi.fn() }),
},
workspaceState: {
get: vi.fn().mockReturnValue(undefined),
Expand Down Expand Up @@ -2703,6 +2785,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => {
get: vi.fn().mockImplementation((key: string) => secrets[key]),
store: vi.fn().mockImplementation((key: string, value: string | undefined) => (secrets[key] = value)),
delete: vi.fn().mockImplementation((key: string) => delete secrets[key]),
onDidChange: vi.fn().mockReturnValue({ dispose: vi.fn() }),
},
workspaceState: {
get: vi.fn().mockReturnValue(undefined),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ describe("ClineProvider - Sticky Mode", () => {
delete secrets[key]
return Promise.resolve()
}),
onDidChange: vi.fn().mockReturnValue({ dispose: vi.fn() }),
},
workspaceState: {
get: vi.fn().mockReturnValue(undefined),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ describe("ClineProvider - Sticky Provider Profile", () => {
delete secrets[key]
return Promise.resolve()
}),
onDidChange: vi.fn().mockReturnValue({ dispose: vi.fn() }),
},
workspaceState: {
get: vi.fn().mockReturnValue(undefined),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@ describe("ClineProvider Task History Synchronization", () => {
get: vi.fn().mockImplementation((key: string) => secrets[key]),
store: vi.fn().mockImplementation((key: string, value: string | undefined) => (secrets[key] = value)),
delete: vi.fn().mockImplementation((key: string) => delete secrets[key]),
onDidChange: vi.fn().mockReturnValue({ dispose: vi.fn() }),
},
workspaceState: {
get: vi.fn().mockReturnValue(undefined),
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
Loading
Loading