From 834524d626ebe3df3d8c1e3b2aa8b7636d5e3e0f Mon Sep 17 00:00:00 2001
From: "coderabbitai[bot]"
<136622811+coderabbitai[bot]@users.noreply.github.com>
Date: Tue, 10 Nov 2026 23:50:28 +0000
Subject: [PATCH 01/11] fix(webview): prevent CSP injection through OpenRouter
URLs
---
src/core/webview/ClineProvider.ts | 13 +++---
.../webview/__tests__/ClineProvider.spec.ts | 44 +++++++++++++++++++
src/core/webview/webviewCsp.ts | 19 ++++++++
3 files changed, 68 insertions(+), 8 deletions(-)
create mode 100644 src/core/webview/webviewCsp.ts
diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts
index 6cec3bdbe7..1bd953f059 100644
--- a/src/core/webview/ClineProvider.ts
+++ b/src/core/webview/ClineProvider.ts
@@ -154,6 +154,7 @@ import {
import { readTaskMessages } from "../task-persistence/taskMessages"
import { readFileWithMissingRetry } from "../task-persistence/readFileWithMissingRetry"
import { getNonce } from "./getNonce"
+import { getOpenRouterCspOrigin, escapeCspAttribute } from "./webviewCsp"
import { getUri } from "./getUri"
import { omitOriginalContentFromExtensionMessage } from "./stripOriginalContent"
import { REQUESTY_BASE_URL } from "../../shared/utils/requesty"
@@ -1848,9 +1849,7 @@ export class ClineProvider
// Get the OpenRouter base URL from configuration
const { apiConfiguration } = await this.getState()
- const openRouterBaseUrl = apiConfiguration.openRouterBaseUrl || "https://openrouter.ai"
- // Extract the domain for CSP
- const openRouterDomain = openRouterBaseUrl.match(/^(https?:\/\/[^/]+)/)?.[1] || "https://openrouter.ai"
+ const openRouterDomain = getOpenRouterCspOrigin(apiConfiguration.openRouterBaseUrl)
const stylesUri = getUri(webview, this.contextProxy.extensionUri, [
"webview-ui",
@@ -1897,7 +1896,7 @@ export class ClineProvider
-
+
', "https://example.com"],
+ ["https://example.com;script-src", "https://openrouter.ai"],
+ [`https://example.com; script-src-elem 'unsafe-inline'">`, "https://openrouter.ai"],
+ ["https://example.com'", "https://openrouter.ai"],
+ ['https://example.com">', "https://openrouter.ai"],
+ ["javascript:attack()", "https://openrouter.ai"],
+ ["file:///tmp/test", "https://openrouter.ai"],
+ ["invalid", "https://openrouter.ai"],
+ ])("builds safe production and HMR CSPs for endpoint %s", async (baseUrl, origin) => {
+ const state = await provider.getState()
+ vi.spyOn(provider, "getState").mockResolvedValue({
+ ...state,
+ apiConfiguration: { ...state.apiConfiguration, openRouterBaseUrl: baseUrl },
+ })
+ vi.mocked(axios.get).mockResolvedValue({ data: "" })
+
+ for (const build of ["getHtmlContent", "getHMRHtmlContent"] as const) {
+ const html = await provider[build](mockWebviewView.webview)
+ const csp = html.match(/http-equiv="Content-Security-Policy" content="([^"]*)"/)?.[1]
+ expect(csp).toBeDefined()
+ expect(csp).toContain(`connect-src vscode-webview://test-csp-source ${origin} `)
+ expect(csp?.match(/script-src /g)).toHaveLength(1)
+ expect(csp).toContain("'nonce-")
+ expect(html).not.toContain("attack()")
+ expect(html).not.toContain("password")
+ }
+ })
+
test("resolveWebviewView sets up webview correctly in development mode even if local server is not running", async () => {
const developmentContext = { ...mockContext, extensionMode: vscode.ExtensionMode.Development }
provider = new ClineProvider(
diff --git a/src/core/webview/webviewCsp.ts b/src/core/webview/webviewCsp.ts
new file mode 100644
index 0000000000..0969ca1a40
--- /dev/null
+++ b/src/core/webview/webviewCsp.ts
@@ -0,0 +1,19 @@
+/** Return one CSP source, never configuration-supplied policy or markup. */
+export function getOpenRouterCspOrigin(baseUrl?: string): string {
+ try {
+ const url = new URL(baseUrl || "https://openrouter.ai")
+ // URL hostnames can contain CSP delimiters such as semicolons and quotes.
+ // Allow DNS names, IPv4 and bracketed IPv6, including local proxy hosts.
+ if ((url.protocol === "https:" || url.protocol === "http:") && /^[a-z0-9._:[\]-]+$/i.test(url.hostname)) {
+ return url.origin
+ }
+ } catch {
+ // Invalid endpoints must not prevent the webview from rendering safely.
+ }
+ return "https://openrouter.ai"
+}
+
+/** Escape a complete CSP for a double-quoted HTML attribute. */
+export function escapeCspAttribute(policy: string): string {
+ return policy.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">")
+}
From 1638b42f214dc623196c9a983afbd2756fce64bf Mon Sep 17 00:00:00 2001
From: Elliott de Launay
Date: Sat, 14 Nov 2026 19:55:38 +0000
Subject: [PATCH 02/11] test(webview): add unit coverage for webviewCsp helpers
---
.../webview/__tests__/ClineProvider.spec.ts | 36 ++++++------
src/core/webview/__tests__/webviewCsp.spec.ts | 57 +++++++++++++++++++
2 files changed, 74 insertions(+), 19 deletions(-)
create mode 100644 src/core/webview/__tests__/webviewCsp.spec.ts
diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts
index 1e6b48740f..bf489e9dc8 100644
--- a/src/core/webview/__tests__/ClineProvider.spec.ts
+++ b/src/core/webview/__tests__/ClineProvider.spec.ts
@@ -3057,38 +3057,36 @@ describe("ClineProvider", () => {
const webview = { ...mockWebviewView.webview, cspSource: 'https://test/?x="">' }
for (const build of ["getHtmlContent", "getHMRHtmlContent"] as const) {
const html = await provider[build](webview)
- const csp = html.match(/http-equiv="Content-Security-Policy" content="([^"]*)"/)?.[1]
+ // Capture through the stylesheet link so a raw quote in any cspSource cannot truncate the check.
+ const csp = html.match(
+ /http-equiv="Content-Security-Policy" content="([\s\S]*?)">\s*]/)
+ expect(csp).not.toMatch(/["<>]/)
}
})
- test.each([
- [undefined, "https://openrouter.ai"],
- ["https://user:password@EXAMPLE.com:8443/api/v1?key=secret#fragment", "https://example.com:8443"],
- ["http://localhost:3000/api/v1", "http://localhost:3000"],
- ["http://[::1]:3000/api", "http://[::1]:3000"],
- ['https://example.com/path;script-src?x=">', "https://example.com"],
- ["https://example.com;script-src", "https://openrouter.ai"],
- [`https://example.com; script-src-elem 'unsafe-inline'">`, "https://openrouter.ai"],
- ["https://example.com'", "https://openrouter.ai"],
- ['https://example.com">', "https://openrouter.ai"],
- ["javascript:attack()", "https://openrouter.ai"],
- ["file:///tmp/test", "https://openrouter.ai"],
- ["invalid", "https://openrouter.ai"],
- ])("builds safe production and HMR CSPs for endpoint %s", async (baseUrl, origin) => {
+ test("builds the production and HMR CSPs from the webviewCsp helpers", async () => {
const state = await provider.getState()
vi.spyOn(provider, "getState").mockResolvedValue({
...state,
- apiConfiguration: { ...state.apiConfiguration, openRouterBaseUrl: baseUrl },
+ apiConfiguration: {
+ ...state.apiConfiguration,
+ // The origin helper must strip credentials and paths; the escape helper must escape quotes.
+ openRouterBaseUrl:
+ 'https://user:password@example.com:8443/api;script-src?x=">',
+ },
})
vi.mocked(axios.get).mockResolvedValue({ data: "" })
for (const build of ["getHtmlContent", "getHMRHtmlContent"] as const) {
const html = await provider[build](mockWebviewView.webview)
- const csp = html.match(/http-equiv="Content-Security-Policy" content="([^"]*)"/)?.[1]
+ const csp = html.match(
+ /http-equiv="Content-Security-Policy" content="([\s\S]*?)">\s* {
+ it.each([
+ [undefined, "https://openrouter.ai"],
+ ["https://openrouter.ai/api/v1", "https://openrouter.ai"],
+ // Credentials, paths, queries and fragments are stripped, not echoed.
+ ["https://user:password@EXAMPLE.com:8443/api/v1?key=secret#fragment", "https://example.com:8443"],
+ // Local proxy hosts, IPv4 and bracketed IPv6 stay allowed.
+ ["http://localhost:3000/api/v1", "http://localhost:3000"],
+ ["http://127.0.0.1:3000/api", "http://127.0.0.1:3000"],
+ ["http://[::1]:3000/api", "http://[::1]:3000"],
+ ["http://my_proxy:8080", "http://my_proxy:8080"],
+ // CSP delimiters and markup in the path or host fall back to the default.
+ ['https://example.com/path;script-src?x=">', "https://example.com"],
+ ["https://example.com;script-src", "https://openrouter.ai"],
+ [`https://example.com; script-src-elem 'unsafe-inline'">`, "https://openrouter.ai"],
+ ["https://example.com'", "https://openrouter.ai"],
+ ['https://example.com">', "https://openrouter.ai"],
+ // The hostname regex rejects wildcard and list syntax that CSP would misread.
+ ["https://*.example.com", "https://openrouter.ai"],
+ ["https://a,b.com", "https://openrouter.ai"],
+ // The protocol allowlist rejects non-http(s) schemes before the host is read.
+ ["javascript:attack()", "https://openrouter.ai"],
+ ["file:///tmp/test", "https://openrouter.ai"],
+ ["ftp://example.com", "https://openrouter.ai"],
+ ["ws://example.com", "https://openrouter.ai"],
+ // Malformed input falls back to the default.
+ ["invalid", "https://openrouter.ai"],
+ ["", "https://openrouter.ai"],
+ ])("maps endpoint %s to origin %s", (baseUrl, origin) => {
+ expect(getOpenRouterCspOrigin(baseUrl)).toBe(origin)
+ })
+})
+
+describe("escapeCspAttribute", () => {
+ it.each([
+ ["&", "&"],
+ ['"', """],
+ ["<", "<"],
+ [">", ">"],
+ ])("escapes %s as %s", (raw, escaped) => {
+ expect(escapeCspAttribute(`a ${raw} b`)).toBe(`a ${escaped} b`)
+ })
+
+ it("leaves safe characters unchanged", () => {
+ expect(escapeCspAttribute("default-src 'none'; script-src 'nonce-abc'")).toBe(
+ "default-src 'none'; script-src 'nonce-abc'",
+ )
+ })
+
+ it("escapes every character so no raw markup survives", () => {
+ expect(escapeCspAttribute('&">',
},
diff --git a/src/core/webview/__tests__/webviewCsp.spec.ts b/src/core/webview/__tests__/webviewCsp.spec.ts
index 937704bcf9..4ebe92d7b7 100644
--- a/src/core/webview/__tests__/webviewCsp.spec.ts
+++ b/src/core/webview/__tests__/webviewCsp.spec.ts
@@ -13,12 +13,15 @@ describe("getOpenRouterCspOrigin", () => {
["http://127.0.0.1:3000/api", "http://127.0.0.1:3000"],
["http://[::1]:3000/api", "http://[::1]:3000"],
["http://my_proxy:8080", "http://my_proxy:8080"],
+ ["https://my-proxy.example.com:8443/api", "https://my-proxy.example.com:8443"],
// CSP delimiters and markup in the path or host fall back to the default.
['https://example.com/path;script-src?x=">', "https://example.com"],
["https://example.com;script-src", "https://openrouter.ai"],
[`https://example.com; script-src-elem 'unsafe-inline'">`, "https://openrouter.ai"],
["https://example.com'", "https://openrouter.ai"],
['https://example.com">', "https://openrouter.ai"],
+ // Only the hostname regex rejects this quote; the URL parses without it.
+ ['https://example.com"', "https://openrouter.ai"],
// The hostname regex rejects wildcard and list syntax that CSP would misread.
["https://*.example.com", "https://openrouter.ai"],
["https://a,b.com", "https://openrouter.ai"],
From dd09f9151933512f606921e0603149f39bc61e25 Mon Sep 17 00:00:00 2001
From: Elliott de Launay
Date: Sun, 15 Nov 2026 00:25:38 +0000
Subject: [PATCH 05/11] fix(webview): move provider lookups to the extension
host
---
packages/types/src/vscode-extension-host.ts | 11 +
src/core/webview/ClineProvider.ts | 15 +-
.../webview/__tests__/ClineProvider.spec.ts | 98 +++------
.../__tests__/providerInfoHandler.spec.ts | 168 +++++++++++++++
src/core/webview/__tests__/webviewCsp.spec.ts | 60 ------
src/core/webview/providerInfoHandler.ts | 195 ++++++++++++++++++
src/core/webview/webviewCsp.ts | 19 --
src/core/webview/webviewMessageHandler.ts | 4 +
.../__tests__/providerInfoHooks.spec.tsx | 173 ++++++++++++++++
.../__tests__/useOpenRouterKeyInfo.spec.tsx | 123 -----------
.../useOpenRouterModelProviders.spec.tsx | 190 -----------------
.../__tests__/useRequestyKeyInfo.spec.tsx | 122 -----------
.../ui/hooks/requestProviderInfo.ts | 38 ++++
.../ui/hooks/useOpenRouterKeyInfo.ts | 49 +----
.../ui/hooks/useOpenRouterModelProviders.ts | 86 +-------
.../components/ui/hooks/useRequestyKeyInfo.ts | 43 +---
webview-ui/src/utils/test-utils.tsx | 33 ---
17 files changed, 648 insertions(+), 779 deletions(-)
create mode 100644 src/core/webview/__tests__/providerInfoHandler.spec.ts
delete mode 100644 src/core/webview/__tests__/webviewCsp.spec.ts
create mode 100644 src/core/webview/providerInfoHandler.ts
delete mode 100644 src/core/webview/webviewCsp.ts
create mode 100644 webview-ui/src/components/ui/hooks/__tests__/providerInfoHooks.spec.tsx
delete mode 100644 webview-ui/src/components/ui/hooks/__tests__/useOpenRouterKeyInfo.spec.tsx
delete mode 100644 webview-ui/src/components/ui/hooks/__tests__/useOpenRouterModelProviders.spec.tsx
delete mode 100644 webview-ui/src/components/ui/hooks/__tests__/useRequestyKeyInfo.spec.tsx
create mode 100644 webview-ui/src/components/ui/hooks/requestProviderInfo.ts
diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts
index 8a006c0639..c58ab6145c 100644
--- a/packages/types/src/vscode-extension-host.ts
+++ b/packages/types/src/vscode-extension-host.ts
@@ -27,6 +27,15 @@ import type { WorktreeIncludeStatus } from "./worktree.js"
* ExtensionMessage
* Extension -> Webview | CLI
*/
+/** Provider lookups the extension host runs for the webview, keeping provider origins out of the CSP. */
+export type ProviderInfoKind = "openRouterKeyInfo" | "openRouterModelProviders" | "requestyKeyInfo"
+
+export interface ProviderInfoParams {
+ apiKey?: string
+ baseUrl?: string
+ modelId?: string
+}
+
export interface ExtensionMessage {
type:
| "action"
@@ -66,6 +75,7 @@ export interface ExtensionMessage {
| "ttsStart"
| "ttsStop"
| "fileSearchResults"
+ | "providerInfo"
| "toggleApiConfigPin"
| "acceptInput"
| "setHistoryPreviewCollapsed"
@@ -549,6 +559,7 @@ export interface WebviewMessage {
| "resetState"
| typeof RouterModelsMessageType.flushRouterModels
| typeof RouterModelsMessageType.requestRouterModels
+ | "requestProviderInfo"
| typeof OpenAiModelsMessageType.requestOpenAiModels
| typeof OllamaModelsMessageType.requestOllamaModels
| typeof LmStudioModelsMessageType.requestLmStudioModels
diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts
index 1bd953f059..333c646ff9 100644
--- a/src/core/webview/ClineProvider.ts
+++ b/src/core/webview/ClineProvider.ts
@@ -154,7 +154,6 @@ import {
import { readTaskMessages } from "../task-persistence/taskMessages"
import { readFileWithMissingRetry } from "../task-persistence/readFileWithMissingRetry"
import { getNonce } from "./getNonce"
-import { getOpenRouterCspOrigin, escapeCspAttribute } from "./webviewCsp"
import { getUri } from "./getUri"
import { omitOriginalContentFromExtensionMessage } from "./stripOriginalContent"
import { REQUESTY_BASE_URL } from "../../shared/utils/requesty"
@@ -1847,10 +1846,6 @@ export class ClineProvider
const nonce = getNonce()
- // Get the OpenRouter base URL from configuration
- const { apiConfiguration } = await this.getState()
- const openRouterDomain = getOpenRouterCspOrigin(apiConfiguration.openRouterBaseUrl)
-
const stylesUri = getUri(webview, this.contextProxy.extensionUri, [
"webview-ui",
"build",
@@ -1887,7 +1882,7 @@ export class ClineProvider
`img-src ${webview.cspSource} https://storage.googleapis.com https://img.clerk.com https://avatars.githubusercontent.com https://lh3.googleusercontent.com data:`,
`media-src ${webview.cspSource}`,
`script-src 'unsafe-eval' ${webview.cspSource} https://*.posthog.com http://${localServerUrl} http://0.0.0.0:${localPort} 'nonce-${nonce}'`,
- `connect-src ${webview.cspSource} ${openRouterDomain} https://* https://*.posthog.com ws://${localServerUrl} ws://0.0.0.0:${localPort} http://${localServerUrl} http://0.0.0.0:${localPort}`,
+ `connect-src ${webview.cspSource} https://* https://*.posthog.com ws://${localServerUrl} ws://0.0.0.0:${localPort} http://${localServerUrl} http://0.0.0.0:${localPort}`,
]
return /*html*/ `
@@ -1896,7 +1891,7 @@ export class ClineProvider
-
+
',
- },
- })
- vi.mocked(axios.get).mockResolvedValue({ data: "" })
+ // Provider lookups run in the extension host, so configured endpoints must never reach the CSP.
+ test.each(["getHtmlContent", "getHMRHtmlContent"] as const)(
+ "%s keeps configured provider endpoints out of the CSP",
+ async (build) => {
+ const state = await provider.getState()
+ vi.spyOn(provider, "getState").mockResolvedValue({
+ ...state,
+ apiConfiguration: {
+ ...state.apiConfiguration,
+ openRouterBaseUrl: "https://evil.example;script-src 'unsafe-inline'\">",
+ requestyBaseUrl: "https://requesty.example",
+ },
+ })
+ vi.mocked(axios.get).mockResolvedValue({ data: "" })
- for (const build of ["getHtmlContent", "getHMRHtmlContent"] as const) {
const html = await provider[build](mockWebviewView.webview)
const csp = html.match(
/http-equiv="Content-Security-Policy" content="([\s\S]*?)">\s* {
- const state = await provider.getState()
- vi.spyOn(provider, "getState").mockResolvedValue({
- ...state,
- apiConfiguration: { ...state.apiConfiguration, openRouterBaseUrl: baseUrl },
- })
- vi.mocked(axios.get).mockResolvedValue({ data: "" })
-
- for (const build of ["getHtmlContent", "getHMRHtmlContent"] as const) {
- const html = await provider[build](mockWebviewView.webview)
- const csp = html.match(
- /http-equiv="Content-Security-Policy" content="([\s\S]*?)">\s* {
const developmentContext = { ...mockContext, extensionMode: vscode.ExtensionMode.Development }
@@ -3143,7 +3107,7 @@ describe("ClineProvider", () => {
// Verify Content Security Policy contains the necessary PostHog domains
expect(mockWebviewView.webview.html).toContain(
- "connect-src vscode-webview://test-csp-source https://openrouter.ai https://api.requesty.ai https://us.i.posthog.com",
+ "connect-src vscode-webview://test-csp-source https://us.i.posthog.com",
)
// Extract the script-src directive section and verify required security elements
diff --git a/src/core/webview/__tests__/providerInfoHandler.spec.ts b/src/core/webview/__tests__/providerInfoHandler.spec.ts
new file mode 100644
index 0000000000..0101295f07
--- /dev/null
+++ b/src/core/webview/__tests__/providerInfoHandler.spec.ts
@@ -0,0 +1,168 @@
+import type { WebviewMessage } from "@roo-code/types"
+
+import type { ClineProvider } from "../ClineProvider"
+import { handleRequestProviderInfo } from "../providerInfoHandler"
+
+const KEY_INFO = {
+ label: "sk-or-v1-...abc",
+ usage: 12.5,
+ is_free_tier: false,
+ is_provisioning_key: false,
+ rate_limit: { requests: 60, interval: "1m" },
+ limit: 100,
+}
+
+const REQUESTY_KEY_INFO = {
+ name: "key",
+ monthly_limit: "100",
+ monthly_spend: "5",
+ org_balance: "95",
+ config: {},
+}
+
+const ENDPOINTS = {
+ data: {
+ id: "openai/gpt-4o",
+ name: "GPT-4o",
+ description: "desc",
+ architecture: { input_modalities: ["text", "image"], output_modalities: ["text"] },
+ endpoints: [
+ {
+ name: "OpenAI | gpt-4o",
+ tag: "openai",
+ context_length: 128000,
+ max_completion_tokens: 16384,
+ pricing: { prompt: "0.000005", completion: "0.000015", input_cache_read: "0.0000025" },
+ },
+ ],
+ },
+}
+
+const jsonResponse = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status })
+
+describe("handleRequestProviderInfo", () => {
+ const postMessageToWebview = vi.fn()
+ const provider = { postMessageToWebview, log: vi.fn() } as unknown as ClineProvider // Only these two members are used.
+ let fetchSpy: ReturnType
+
+ const send = (values: WebviewMessage["values"]) =>
+ handleRequestProviderInfo(provider, { type: "requestProviderInfo", requestId: "req-1", values })
+
+ beforeEach(() => {
+ postMessageToWebview.mockReset()
+ fetchSpy = vi.spyOn(globalThis, "fetch")
+ vi.spyOn(console, "error").mockImplementation(() => {})
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it("fetches OpenRouter key info from the default endpoint with a bearer token", async () => {
+ fetchSpy.mockResolvedValue(jsonResponse({ data: KEY_INFO }))
+
+ await send({ kind: "openRouterKeyInfo", apiKey: "sk-or-v1-test" })
+
+ expect(fetchSpy.mock.calls[0]![0]).toBe("https://openrouter.ai/api/v1/key")
+ expect(fetchSpy.mock.calls[0]![1]).toMatchObject({ headers: { Authorization: "Bearer sk-or-v1-test" } })
+ expect(postMessageToWebview).toHaveBeenCalledWith({
+ type: "providerInfo",
+ requestId: "req-1",
+ values: { data: KEY_INFO },
+ })
+ })
+
+ it("uses a caller-supplied OpenRouter base URL", async () => {
+ fetchSpy.mockResolvedValue(jsonResponse({ data: KEY_INFO }))
+
+ await send({ kind: "openRouterKeyInfo", apiKey: "k", baseUrl: "https://proxy.example.com/api/v1" })
+
+ expect(fetchSpy.mock.calls[0]![0]).toBe("https://proxy.example.com/api/v1/key")
+ })
+
+ it.each(["openRouterKeyInfo", "requestyKeyInfo"])(
+ "returns null without a request when %s has no apiKey",
+ async (kind) => {
+ await send({ kind })
+
+ expect(fetchSpy).not.toHaveBeenCalled()
+ expect(postMessageToWebview).toHaveBeenCalledWith({
+ type: "providerInfo",
+ requestId: "req-1",
+ values: { data: null },
+ })
+ },
+ )
+
+ it("fetches Requesty key info from the api service URL", async () => {
+ fetchSpy.mockResolvedValue(jsonResponse(REQUESTY_KEY_INFO))
+
+ await send({ kind: "requestyKeyInfo", apiKey: "rk" })
+
+ expect(fetchSpy.mock.calls[0]![0]).toBe("https://api.requesty.ai/x/apikey")
+ expect(postMessageToWebview).toHaveBeenCalledWith({
+ type: "providerInfo",
+ requestId: "req-1",
+ values: { data: REQUESTY_KEY_INFO },
+ })
+ })
+
+ it("maps OpenRouter endpoints to model info keyed by provider tag", async () => {
+ fetchSpy.mockResolvedValue(jsonResponse(ENDPOINTS))
+
+ await send({ kind: "openRouterModelProviders", modelId: "openai/gpt-4o" })
+
+ expect(fetchSpy.mock.calls[0]![0]).toBe("https://openrouter.ai/api/v1/models/openai/gpt-4o/endpoints")
+ const { values } = postMessageToWebview.mock.calls[0]![0]
+ expect(values.data.openai).toMatchObject({
+ label: "openai",
+ maxTokens: 16384,
+ contextWindow: 128000,
+ supportsImages: true,
+ supportsPromptCache: true,
+ inputPrice: 5,
+ outputPrice: 15,
+ cacheReadsPrice: 2.5,
+ })
+ })
+
+ it("returns no providers for an image-output model", async () => {
+ const body = { data: { ...ENDPOINTS.data, architecture: { output_modalities: ["image"] } } }
+ fetchSpy.mockResolvedValue(jsonResponse(body))
+
+ await send({ kind: "openRouterModelProviders", modelId: "m" })
+
+ expect(postMessageToWebview.mock.calls[0]![0].values).toEqual({ data: {} })
+ })
+
+ it("returns null for a schema-invalid key response", async () => {
+ fetchSpy.mockResolvedValue(jsonResponse({ data: { label: 1 } }))
+
+ await send({ kind: "openRouterKeyInfo", apiKey: "k" })
+
+ expect(postMessageToWebview.mock.calls[0]![0].values).toEqual({ data: null })
+ })
+
+ it("posts an error without the configured URL when the request fails", async () => {
+ fetchSpy.mockResolvedValue(jsonResponse({}, 401))
+
+ await send({ kind: "openRouterKeyInfo", apiKey: "k", baseUrl: "https://secret.example.com" })
+
+ expect(postMessageToWebview).toHaveBeenCalledWith({
+ type: "providerInfo",
+ requestId: "req-1",
+ error: "Provider info request failed",
+ })
+ })
+
+ it.each([undefined, "unknown", "__proto__", "constructor"])("rejects unknown kind %s", async (kind) => {
+ await send({ kind })
+
+ expect(fetchSpy).not.toHaveBeenCalled()
+ expect(postMessageToWebview).toHaveBeenCalledWith({
+ type: "providerInfo",
+ requestId: "req-1",
+ error: "Unknown provider info kind",
+ })
+ })
+})
diff --git a/src/core/webview/__tests__/webviewCsp.spec.ts b/src/core/webview/__tests__/webviewCsp.spec.ts
deleted file mode 100644
index 4ebe92d7b7..0000000000
--- a/src/core/webview/__tests__/webviewCsp.spec.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-import { describe, expect, it } from "vitest"
-
-import { escapeCspAttribute, getOpenRouterCspOrigin } from "../webviewCsp"
-
-describe("getOpenRouterCspOrigin", () => {
- it.each([
- [undefined, "https://openrouter.ai"],
- ["https://openrouter.ai/api/v1", "https://openrouter.ai"],
- // Credentials, paths, queries and fragments are stripped, not echoed.
- ["https://user:password@EXAMPLE.com:8443/api/v1?key=secret#fragment", "https://example.com:8443"],
- // Local proxy hosts, IPv4 and bracketed IPv6 stay allowed.
- ["http://localhost:3000/api/v1", "http://localhost:3000"],
- ["http://127.0.0.1:3000/api", "http://127.0.0.1:3000"],
- ["http://[::1]:3000/api", "http://[::1]:3000"],
- ["http://my_proxy:8080", "http://my_proxy:8080"],
- ["https://my-proxy.example.com:8443/api", "https://my-proxy.example.com:8443"],
- // CSP delimiters and markup in the path or host fall back to the default.
- ['https://example.com/path;script-src?x=">', "https://example.com"],
- ["https://example.com;script-src", "https://openrouter.ai"],
- [`https://example.com; script-src-elem 'unsafe-inline'">`, "https://openrouter.ai"],
- ["https://example.com'", "https://openrouter.ai"],
- ['https://example.com">', "https://openrouter.ai"],
- // Only the hostname regex rejects this quote; the URL parses without it.
- ['https://example.com"', "https://openrouter.ai"],
- // The hostname regex rejects wildcard and list syntax that CSP would misread.
- ["https://*.example.com", "https://openrouter.ai"],
- ["https://a,b.com", "https://openrouter.ai"],
- // The protocol allowlist rejects non-http(s) schemes before the host is read.
- ["javascript:attack()", "https://openrouter.ai"],
- ["file:///tmp/test", "https://openrouter.ai"],
- ["ftp://example.com", "https://openrouter.ai"],
- ["ws://example.com", "https://openrouter.ai"],
- // Malformed input falls back to the default.
- ["invalid", "https://openrouter.ai"],
- ["", "https://openrouter.ai"],
- ])("maps endpoint %s to origin %s", (baseUrl, origin) => {
- expect(getOpenRouterCspOrigin(baseUrl)).toBe(origin)
- })
-})
-
-describe("escapeCspAttribute", () => {
- it.each([
- ["&", "&"],
- ['"', """],
- ["<", "<"],
- [">", ">"],
- ])("escapes %s as %s", (raw, escaped) => {
- expect(escapeCspAttribute(`a ${raw} b`)).toBe(`a ${escaped} b`)
- })
-
- it("leaves safe characters unchanged", () => {
- expect(escapeCspAttribute("default-src 'none'; script-src 'nonce-abc'")).toBe(
- "default-src 'none'; script-src 'nonce-abc'",
- )
- })
-
- it("escapes every character so no raw markup survives", () => {
- expect(escapeCspAttribute('&">