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
31 changes: 31 additions & 0 deletions packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,35 @@ import type { RuleMetadata } from "./rules.js"
import type { TelemetrySetting } from "./telemetry.js"
import type { WorktreeIncludeStatus } from "./worktree.js"

/** Provider lookups the extension host runs for the webview, keeping provider origins out of the CSP. */
export type ProviderInfoKind = keyof ProviderInfoResultMap

export interface ProviderInfoParams {
apiKey?: string
baseUrl?: string
modelId?: string
}

/** Result of each lookup. The host and the webview share it, so the kind fixes the result type. */
export interface ProviderInfoResultMap {
openRouterKeyInfo: {
label: string
usage: number
is_free_tier: boolean
is_provisioning_key: boolean
rate_limit: { requests: number; interval: string }
limit: number | null
} | null
openRouterModelProviders: Record<string, ModelInfo & { label: string }>
requestyKeyInfo: {
name: string
monthly_limit: string
monthly_spend: string
org_balance: string
config: { aliases?: Record<string, unknown> }
} | null
}

/**
* ExtensionMessage
* Extension -> Webview | CLI
Expand Down Expand Up @@ -66,6 +95,7 @@ export interface ExtensionMessage {
| "ttsStart"
| "ttsStop"
| "fileSearchResults"
| "providerInfo"
| "toggleApiConfigPin"
| "acceptInput"
| "setHistoryPreviewCollapsed"
Expand Down Expand Up @@ -549,6 +579,7 @@ export interface WebviewMessage {
| "resetState"
| typeof RouterModelsMessageType.flushRouterModels
| typeof RouterModelsMessageType.requestRouterModels
| "requestProviderInfo"
| typeof OpenAiModelsMessageType.requestOpenAiModels
| typeof OllamaModelsMessageType.requestOllamaModels
| typeof LmStudioModelsMessageType.requestLmStudioModels
Expand Down
117 changes: 117 additions & 0 deletions src/api/providers/fetchers/__tests__/openrouter.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
// npx vitest run api/providers/fetchers/__tests__/openrouter.spec.ts

import http from "http"
import nock from "nock"
import type { AddressInfo } from "net"
import * as path from "path"

import { back as nockBack } from "nock"
Expand Down Expand Up @@ -332,11 +335,125 @@ describe("OpenRouter API", () => {
"https://openrouter.ai/api/v1/models/anthropic/claude-sonnet-4/endpoints",
{
timeout: MODEL_CATALOG_FETCH_TIMEOUT_MS,
signal: expect.any(AbortSignal),
maxContentLength: 1024 * 1024,
},
)

getSpy.mockRestore()
})

it("returns no endpoints for an image-output model", async () => {
const axios = await import("axios")
const getSpy = vi.spyOn(axios.default, "get").mockResolvedValue({
data: {
data: {
id: "vendor/image-model",
name: "Image Model",
architecture: { output_modalities: ["image"] },
endpoints: [{ provider_name: "P", context_length: 1000 }],
},
},
})

await expect(getOpenRouterModelEndpoints("vendor/image-model")).resolves.toEqual({})

getSpy.mockRestore()
})

it.each([
["a non-numeric", "bad", false],
["a numeric", 4096, true],
])("with untrustedBaseUrl handles %s context_length", async (_name, contextLength, accepted) => {
const axios = await import("axios")
const endpoint = { name: "P", provider_name: "P", context_length: contextLength }
const body = { data: { id: "m", name: "M", endpoints: [endpoint] } }
const getSpy = vi.spyOn(axios.default, "get").mockResolvedValue({ data: body })
vi.spyOn(console, "error").mockImplementation(() => {})

const result = await getOpenRouterModelEndpoints("m", undefined, { untrustedBaseUrl: true })

if (accepted) {
expect(Object.keys(result)).toEqual(["P"])
expect(result["P"]).toMatchObject({ contextWindow: 4096 })
} else {
expect(result).toEqual({})
// Trusted callers keep the lenient fallback.
await expect(getOpenRouterModelEndpoints("m")).resolves.toHaveProperty("P")
}

getSpy.mockRestore()
})

it("bounds the total time of a body that streams slowly after the headers arrive", async () => {
const server = http.createServer((_req, res) => {
res.writeHead(200, { "Content-Type": "application/json" })
// One byte at a time keeps the socket active, so only a total deadline can stop it.
const timer = setInterval(() => res.write(" "), 50)
res.on("close", () => clearInterval(timer))
})
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve))
const { port } = server.address() as AddressInfo
vi.spyOn(console, "error").mockImplementation(() => {})
nock.enableNetConnect("127.0.0.1")

try {
const start = Date.now()
const result = await getOpenRouterModelEndpoints(
"m",
{ openRouterBaseUrl: `http://127.0.0.1:${port}` },
{ untrustedBaseUrl: true, timeoutMs: 300 },
)

expect(result).toEqual({})
expect(Date.now() - start).toBeLessThan(2000)
} finally {
nock.disableNetConnect()
server.closeAllConnections()
await new Promise((resolve) => server.close(resolve))
}
})

it("uses the caller timeout when given", async () => {
const axios = await import("axios")
const getSpy = vi.spyOn(axios.default, "get").mockResolvedValue({
data: { data: { id: "m", name: "M", endpoints: [] } },
})

await getOpenRouterModelEndpoints("m", undefined, { timeoutMs: 10_000 })

expect(getSpy).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ timeout: 10_000 }))

getSpy.mockRestore()
})

it("with untrustedBaseUrl logs no host name when the request fails", async () => {
const axios = await import("axios")
const getSpy = vi
.spyOn(axios.default, "get")
.mockRejectedValue(new Error("getaddrinfo ENOTFOUND secret.example.com"))
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {})

await getOpenRouterModelEndpoints(
"m",
{ openRouterBaseUrl: "https://secret.example.com" },
{ untrustedBaseUrl: true },
)

expect(JSON.stringify(errorSpy.mock.calls)).not.toContain("secret.example.com")

getSpy.mockRestore()
})

it("returns no endpoints for a body without an endpoints list", async () => {
const axios = await import("axios")
const getSpy = vi.spyOn(axios.default, "get").mockResolvedValue({ data: { data: { id: "m" } } })
vi.spyOn(console, "error").mockImplementation(() => {})

await expect(getOpenRouterModelEndpoints("m")).resolves.toEqual({})

getSpy.mockRestore()
})
})

describe("parseOpenRouterModel", () => {
Expand Down
21 changes: 18 additions & 3 deletions src/api/providers/fetchers/openrouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@
import type { ApiHandlerOptions } from "../../../shared/api"
import { parseApiPrice } from "../../../shared/cost"

import { throwIfAborted } from "../utils/abort-signal"
import { hasUsableTimeout, mergeAbortSignalAndTimeout, throwIfAborted, toSdkTimeoutMs } from "../utils/abort-signal"

import { MODEL_CATALOG_FETCH_TIMEOUT_MS } from "./modelCache"

const MAX_ENDPOINTS_BODY_BYTES = 1024 * 1024

/**
* OpenRouterBaseModel
*/
Expand Down Expand Up @@ -149,22 +151,32 @@
export async function getOpenRouterModelEndpoints(
modelId: string,
options?: ApiHandlerOptions,
// untrustedBaseUrl: the base URL comes from unsaved webview settings. Reject schema-invalid
// bodies and keep the URL out of error logs.
opts?: { untrustedBaseUrl?: boolean; timeoutMs?: number },
): Promise<Record<string, ModelInfo>> {
const models: Record<string, ModelInfo> = {}
const baseURL = options?.openRouterBaseUrl || "https://openrouter.ai/api/v1"
const untrusted = opts?.untrustedBaseUrl === true
const timeoutMs = hasUsableTimeout(opts?.timeoutMs) ? opts.timeoutMs : MODEL_CATALOG_FETCH_TIMEOUT_MS

try {
// This request runs outside getModels()' single-flight, so it needs its own bound:
// a stalled endpoint response would otherwise keep the caller's catalog load pending
// forever, and later callers would join that pending promise instead of retrying.
const response = await axios.get<OpenRouterModelEndpointsResponse>(`${baseURL}/models/${modelId}/endpoints`, {
timeout: MODEL_CATALOG_FETCH_TIMEOUT_MS,
timeout: toSdkTimeoutMs(timeoutMs),
// Also bound the whole body read, in case the transport stops its timer after the headers.
signal: mergeAbortSignalAndTimeout(undefined, timeoutMs),
// The base URL can come from user settings. The cap stops a huge body from filling host memory.
maxContentLength: MAX_ENDPOINTS_BODY_BYTES,
})
const result = openRouterModelEndpointsResponseSchema.safeParse(response.data)
const data = result.success ? result.data.data : response.data.data
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

if (!result.success) {
console.error("OpenRouter model endpoints response is invalid", result.error.format())
if (untrusted) return models
}

const { id, architecture, endpoints } = data
Expand All @@ -184,8 +196,11 @@
})
}
} catch (error) {
// The error text can carry a configured host name.
console.error(
`Error fetching OpenRouter model endpoints: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
untrusted
? `Error fetching OpenRouter model endpoints: ${error instanceof Error ? error.name : "unknown"}`

Check warning on line 202 in src/api/providers/fetchers/openrouter.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/api/providers/fetchers/openrouter.ts:202: 2 mutation test gaps; example: NoCoverage StringLiteral mutant (replacement: ""). See the job summary for the complete list and resolution guidance.
: `Error fetching OpenRouter model endpoints: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,

Check warning on line 203 in src/api/providers/fetchers/openrouter.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/api/providers/fetchers/openrouter.ts:203: Survived StringLiteral mutant (replacement: ``). See the job summary for the complete list and resolution guidance.
)
}

Expand Down
16 changes: 2 additions & 14 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1846,12 +1846,6 @@ export class ClineProvider

const nonce = getNonce()

// 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 stylesUri = getUri(webview, this.contextProxy.extensionUri, [
"webview-ui",
"build",
Expand Down Expand Up @@ -1888,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*/ `
Expand Down Expand Up @@ -1962,12 +1956,6 @@ export class ClineProvider
*/
const nonce = getNonce()

// 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"

// Tip: Install the es6-string-html VS Code extension to enable code highlighting below
return /*html*/ `
<!DOCTYPE html>
Expand All @@ -1976,7 +1964,7 @@ export class ClineProvider
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
<meta name="theme-color" content="#000000">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; font-src ${webview.cspSource} data:; style-src ${webview.cspSource} 'unsafe-inline'; 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 ${webview.cspSource} 'wasm-unsafe-eval' 'nonce-${nonce}' 'strict-dynamic'; connect-src ${webview.cspSource} ${openRouterDomain} https://api.requesty.ai https://us.i.posthog.com;">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; font-src ${webview.cspSource} data:; style-src ${webview.cspSource} 'unsafe-inline'; 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 ${webview.cspSource} 'wasm-unsafe-eval' 'nonce-${nonce}' 'strict-dynamic'; connect-src ${webview.cspSource} https://us.i.posthog.com;">
<link rel="stylesheet" type="text/css" href="${stylesUri}">
<link href="${codiconsUri}" rel="stylesheet" />
<script nonce="${nonce}">
Expand Down
51 changes: 43 additions & 8 deletions src/core/webview/__tests__/ClineProvider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2110,7 +2110,8 @@ describe("ClineProvider", () => {

test("bounds failed HTML generation and reports the exhausted budget once", async () => {
await provider.resolveWebviewView(mockWebviewView)
const generate = vi.spyOn(provider, "getState").mockRejectedValue(new Error("cannot generate"))
const generate = vi.fn().mockRejectedValue(new Error("cannot generate"))
provider["getWebviewHtml"] = generate
const assignHtml = trackHtmlAssignments()

await vi.advanceTimersByTimeAsync(600_000)
Expand All @@ -2137,7 +2138,8 @@ describe("ClineProvider", () => {
test("gives a user-opened replacement a fresh budget after unavailable host recovery", async () => {
await provider.resolveWebviewView(mockWebviewView)
vi.mocked(vscode.commands.getCommands).mockResolvedValue([])
const generateHtml = vi.spyOn(provider, "getState")
const generateHtml = vi.fn(provider["getWebviewHtml"].bind(provider))
provider["getWebviewHtml"] = generateHtml

await vi.advanceTimersByTimeAsync(130_000)
expect(generateHtml).toHaveBeenCalledTimes(1)
Expand Down Expand Up @@ -2262,18 +2264,18 @@ describe("ClineProvider", () => {
await provider.resolveWebviewView(mockWebviewView)
expect(vi.getTimerCount()).toBe(timersAfterFirstResolve)

// getHtmlContent regenerates the HTML via one getState() call per reload,
// so getState invocations after this point count watchdog reloads.
const getStateSpy = vi.spyOn(provider, "getState")
// Each watchdog reload regenerates the HTML once, so calls after this point count reloads.
const regenerate = vi.fn(provider["getWebviewHtml"].bind(provider))
provider["getWebviewHtml"] = regenerate

await vi.advanceTimersByTimeAsync(120_000)

expect(getStateSpy).toHaveBeenCalledTimes(1)
expect(regenerate).toHaveBeenCalledTimes(1)
})

test("logs when regenerating the reload HTML fails", async () => {
await provider.resolveWebviewView(mockWebviewView)
vi.spyOn(provider, "getState").mockRejectedValue(new Error("regen boom"))
provider["getWebviewHtml"] = vi.fn().mockRejectedValue(new Error("regen boom"))
;(mockOutputChannel.appendLine as ReturnType<typeof vi.fn>).mockClear()

await vi.advanceTimersByTimeAsync(120_000)
Expand Down Expand Up @@ -3052,6 +3054,39 @@ describe("ClineProvider", () => {
})
})

// 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'\"><script>attack()</script>",
requestyBaseUrl: "https://requesty.example",
},
})
vi.mocked(axios.get).mockResolvedValue({ data: "" })

const html = await provider[build](mockWebviewView.webview)
const csp = html.match(
/http-equiv="Content-Security-Policy" content="([\s\S]*?)">\s*<link rel="stylesheet"/,
)?.[1]

expect(csp).toBeDefined()
expect(csp).toContain("connect-src vscode-webview://test-csp-source ")
if (build === "getHtmlContent") {
expect(csp).toContain("connect-src vscode-webview://test-csp-source https://us.i.posthog.com;")
}
expect(csp).not.toContain("evil.example")
expect(csp).not.toContain("requesty")
expect(csp).not.toContain("openrouter")
expect(csp?.match(/script-src /g)).toHaveLength(1)
expect(html).not.toContain("attack()")
},
)

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(
Expand All @@ -3075,7 +3110,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
Expand Down
Loading
Loading