From de2987a6304c020b5a9f84816d8d0b123ed493b7 Mon Sep 17 00:00:00 2001 From: gubin-dev Date: Thu, 3 Sep 2026 00:41:46 +0300 Subject: [PATCH 1/3] refactor(providers): finish canonical identifier audit --- packages/config-eslint/provider-identifiers.js | 14 +++++++++++--- .../config-eslint/provider-identifiers.test.js | 10 ++++++++++ .../src/__tests__/provider-identifiers.test.ts | 3 +++ packages/types/src/global-settings.ts | 3 ++- src/api/providers/vscode-lm.ts | 10 ++++++++-- src/core/config/importExport.ts | 3 ++- src/integrations/kimi-code/oauth.ts | 7 ++++--- src/integrations/openai-codex/oauth.ts | 7 ++++--- .../settings/ImageGenerationSettings.tsx | 2 +- webview-ui/src/oauth/urls.ts | 5 +++-- 10 files changed, 48 insertions(+), 16 deletions(-) diff --git a/packages/config-eslint/provider-identifiers.js b/packages/config-eslint/provider-identifiers.js index b1c2ebabb4..83968ad96e 100644 --- a/packages/config-eslint/provider-identifiers.js +++ b/packages/config-eslint/provider-identifiers.js @@ -80,7 +80,7 @@ export function createProviderIdentifierConfig({ providerIdentifiers, retiredPro return undefined } - function getProviderExpressionBranches(node) { + function getProviderExpressionChildren(node) { node = unwrapExpression(node) if (node?.type === "LogicalExpression") { @@ -95,6 +95,14 @@ export function createProviderIdentifierConfig({ providerIdentifiers, retiredPro return [node.right] } + if (node?.type === "CallExpression") { + return node.arguments + } + + if (node?.type === "ArrayExpression") { + return node.elements.filter((element) => element !== null) + } + return [] } @@ -119,8 +127,8 @@ export function createProviderIdentifierConfig({ providerIdentifiers, retiredPro }, create(context) { function reportIfRawProvider(node) { - for (const branch of getProviderExpressionBranches(node)) { - reportIfRawProvider(branch) + for (const child of getProviderExpressionChildren(node)) { + reportIfRawProvider(child) } const provider = getRawProvider(node) diff --git a/packages/config-eslint/provider-identifiers.test.js b/packages/config-eslint/provider-identifiers.test.js index 123b9b6a32..de03860b2a 100644 --- a/packages/config-eslint/provider-identifiers.test.js +++ b/packages/config-eslint/provider-identifiers.test.js @@ -200,6 +200,16 @@ ruleTester.run("no-raw-provider-identifiers provider-like values", rule, { }, ], }, + { + code: 'const schema = { imageGenerationProvider: z.enum(["openrouter"]) }', + errors: [ + { + messageId: "useCanonical", + data: { replacement: "providerIdentifiers.openrouter", value: "openrouter" }, + type: "Literal", + }, + ], + }, { code: 'const imageProvider = useGemini ? "gemini" : "openrouter"', errors: [ diff --git a/packages/types/src/__tests__/provider-identifiers.test.ts b/packages/types/src/__tests__/provider-identifiers.test.ts index 29e55b31cf..d9ba4fb799 100644 --- a/packages/types/src/__tests__/provider-identifiers.test.ts +++ b/packages/types/src/__tests__/provider-identifiers.test.ts @@ -21,6 +21,8 @@ import { retiredProviderNamesSchema, } from "../index.js" +// Raw values are intentional here: these fixtures protect the persisted provider identifier contract. +/* eslint-disable zoo/no-raw-provider-identifiers */ const expectedProviderIdentifiers = [ "openrouter", "vercel-ai-gateway", @@ -70,6 +72,7 @@ const expectedRetiredProviderIdentifiers = [ "io-intelligence", "roo", ] +/* eslint-enable zoo/no-raw-provider-identifiers */ describe("provider identifiers", () => { it("preserves active provider serialized values", () => { diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 95f246dbe7..692798d00d 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -14,6 +14,7 @@ import { telemetrySettingsSchema } from "./telemetry.js" import { toolNamesSchema } from "./tool.js" import { type Keys } from "./type-fu.js" import { languagesSchema } from "./vscode.js" +import { providerIdentifiers } from "./provider-identifiers.js" /** * Default delay in milliseconds after writes to allow diagnostics to detect potential problems. @@ -114,7 +115,7 @@ export const globalSettingsSchema = z.object({ dismissedUpsells: z.array(z.string()).optional(), // Image generation settings (experimental) - flattened for simplicity - imageGenerationProvider: z.enum(["openrouter"]).optional(), + imageGenerationProvider: z.enum([providerIdentifiers.openrouter]).optional(), openRouterImageApiKey: z.string().optional(), openRouterImageGenerationSelectedModel: z.string().optional(), diff --git a/src/api/providers/vscode-lm.ts b/src/api/providers/vscode-lm.ts index c657e6c0d6..62bcbf0c27 100644 --- a/src/api/providers/vscode-lm.ts +++ b/src/api/providers/vscode-lm.ts @@ -2,7 +2,13 @@ import { Anthropic } from "@anthropic-ai/sdk" import * as vscode from "vscode" import OpenAI from "openai" -import { type ModelInfo, openAiModelInfoSaneDefaults, vscodeLlmDefaultModelId, vscodeLlmModels } from "@roo-code/types" +import { + type ModelInfo, + openAiModelInfoSaneDefaults, + providerIdentifiers, + vscodeLlmDefaultModelId, + vscodeLlmModels, +} from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" import { SELECTOR_SEPARATOR, stringifyVsCodeLmModelSelector } from "../../shared/vsCodeSelectorUtils" @@ -555,7 +561,7 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan // Fallback when no client is available const fallbackId = this.options.vsCodeLmModelSelector ? stringifyVsCodeLmModelSelector(this.options.vsCodeLmModelSelector) - : "vscode-lm" + : providerIdentifiers.vscodeLm console.debug("Zoo Code : No client available, using fallback model info") diff --git a/src/core/config/importExport.ts b/src/core/config/importExport.ts index 7b3b5aa231..3c213fedf4 100644 --- a/src/core/config/importExport.ts +++ b/src/core/config/importExport.ts @@ -10,6 +10,7 @@ import { globalSettingsSchema, providerSettingsWithIdSchema, isProviderName, + retiredProviderIdentifiers, type GlobalSettings, type ProviderSettingsWithId, } from "@roo-code/types" @@ -106,7 +107,7 @@ function sanitizeGlobalSettings(rawGlobalSettings: unknown): { let valueToValidate = rawValue - if (key === "imageGenerationProvider" && rawValue === "roo") { + if (key === "imageGenerationProvider" && rawValue === retiredProviderIdentifiers.roo) { warnings.push(`Setting "${path}" used unsupported value "roo" and was cleared during import.`) valueToValidate = undefined } diff --git a/src/integrations/kimi-code/oauth.ts b/src/integrations/kimi-code/oauth.ts index cd28df8b04..0eb9581512 100644 --- a/src/integrations/kimi-code/oauth.ts +++ b/src/integrations/kimi-code/oauth.ts @@ -1,5 +1,6 @@ import type { ExtensionContext } from "vscode" import { z } from "zod" +import { providerIdentifiers } from "@roo-code/types" export const KIMI_CODE_OAUTH_CONFIG = { authHost: "https://auth.kimi.com", @@ -16,7 +17,7 @@ const TOKEN_EXPIRY_BUFFER_MS = 60_000 const OAUTH_REQUEST_TIMEOUT_MS = 30_000 const credentialsSchema = z.object({ - type: z.literal("kimi-code"), + type: z.literal(providerIdentifiers.kimiCode), accessToken: z.string().min(1), refreshToken: z.string().min(1), expiresAt: z.number(), @@ -130,7 +131,7 @@ async function requestDeviceToken(deviceCode: string, signal?: AbortSignal): Pro const tokens = tokenResponseSchema.parse(await response.json()) if (!tokens.refresh_token) throw new Error("Kimi Code OAuth did not return a refresh token") return { - type: "kimi-code", + type: providerIdentifiers.kimiCode, accessToken: tokens.access_token, refreshToken: tokens.refresh_token, expiresAt: Date.now() + tokens.expires_in * 1000, @@ -147,7 +148,7 @@ export async function refreshKimiCodeAccessToken(credentials: KimiCodeCredential if (!response.ok) throw await readOAuthError(response) const tokens = tokenResponseSchema.parse(await response.json()) return { - type: "kimi-code", + type: providerIdentifiers.kimiCode, accessToken: tokens.access_token, refreshToken: tokens.refresh_token ?? credentials.refreshToken, expiresAt: Date.now() + tokens.expires_in * 1000, diff --git a/src/integrations/openai-codex/oauth.ts b/src/integrations/openai-codex/oauth.ts index d87199126c..e11ac4cb58 100644 --- a/src/integrations/openai-codex/oauth.ts +++ b/src/integrations/openai-codex/oauth.ts @@ -3,6 +3,7 @@ import * as http from "http" import { URL } from "url" import type { ExtensionContext } from "vscode" import { z } from "zod" +import { providerIdentifiers } from "@roo-code/types" /** * OpenAI Codex OAuth Configuration @@ -28,7 +29,7 @@ const OPENAI_CODEX_CREDENTIALS_KEY = "openai-codex-oauth-credentials" // Credentials schema const openAiCodexCredentialsSchema = z.object({ - type: z.literal("openai-codex"), + type: z.literal(providerIdentifiers.openaiCodex), access_token: z.string().min(1), refresh_token: z.string().min(1), // expires is in milliseconds since epoch @@ -264,7 +265,7 @@ export async function exchangeCodeForTokens(code: string, codeVerifier: string): }) return { - type: "openai-codex", + type: providerIdentifiers.openaiCodex, access_token: tokenResponse.access_token, refresh_token: tokenResponse.refresh_token, expires: expiresAt, @@ -316,7 +317,7 @@ export async function refreshAccessToken(credentials: OpenAiCodexCredentials): P }) return { - type: "openai-codex", + type: providerIdentifiers.openaiCodex, access_token: tokenResponse.access_token, refresh_token: tokenResponse.refresh_token ?? credentials.refresh_token, expires: expiresAt, diff --git a/webview-ui/src/components/settings/ImageGenerationSettings.tsx b/webview-ui/src/components/settings/ImageGenerationSettings.tsx index f322436a17..fd54054b70 100644 --- a/webview-ui/src/components/settings/ImageGenerationSettings.tsx +++ b/webview-ui/src/components/settings/ImageGenerationSettings.tsx @@ -115,7 +115,7 @@ export const ImageGenerationSettings = ({ value={currentProvider} onChange={(e: any) => handleProviderChange(e.target.value)} className="w-full"> - + OpenRouter diff --git a/webview-ui/src/oauth/urls.ts b/webview-ui/src/oauth/urls.ts index 8d5f7208f3..dca51a87b2 100644 --- a/webview-ui/src/oauth/urls.ts +++ b/webview-ui/src/oauth/urls.ts @@ -1,3 +1,4 @@ +import { providerIdentifiers } from "@roo-code/types" import { Package } from "@roo/package" export function getCallbackUrl(provider: string, uriScheme?: string) { @@ -5,11 +6,11 @@ export function getCallbackUrl(provider: string, uriScheme?: string) { } export function getOpenRouterAuthUrl(uriScheme?: string) { - return `https://openrouter.ai/auth?callback_url=${getCallbackUrl("openrouter", uriScheme)}` + return `https://openrouter.ai/auth?callback_url=${getCallbackUrl(providerIdentifiers.openrouter, uriScheme)}` } export function getRequestyAuthUrl(uriScheme?: string) { - return `https://app.requesty.ai/oauth/authorize?callback_url=${getCallbackUrl("requesty", uriScheme)}` + return `https://app.requesty.ai/oauth/authorize?callback_url=${getCallbackUrl(providerIdentifiers.requesty, uriScheme)}` } const ZOO_CODE_DEFAULT_BASE_URL = "https://www.zoocode.dev" From 8ef6d957b486603691ec4a55954c5b27214701cd Mon Sep 17 00:00:00 2001 From: gubin-dev Date: Thu, 10 Sep 2026 18:22:18 +0300 Subject: [PATCH 2/3] fix(providers): address review and close coverage gaps --- .../config-eslint/provider-identifiers.js | 13 +++++ .../provider-identifiers.test.js | 28 ++++++++++ .../config/__tests__/importExport.spec.ts | 51 +++++++++++++++++++ webview-ui/src/oauth/__tests__/urls.spec.ts | 45 ++++++++++++++++ 4 files changed, 137 insertions(+) create mode 100644 webview-ui/src/oauth/__tests__/urls.spec.ts diff --git a/packages/config-eslint/provider-identifiers.js b/packages/config-eslint/provider-identifiers.js index 83968ad96e..fadfe5ea41 100644 --- a/packages/config-eslint/provider-identifiers.js +++ b/packages/config-eslint/provider-identifiers.js @@ -103,6 +103,10 @@ export function createProviderIdentifierConfig({ providerIdentifiers, retiredPro return node.elements.filter((element) => element !== null) } + if (node?.type === "SpreadElement") { + return [node.argument] + } + return [] } @@ -126,7 +130,16 @@ export function createProviderIdentifierConfig({ providerIdentifiers, retiredPro }, }, create(context) { + // Parent provider contexts and call visitors can reach the same expression. + const visitedExpressions = new WeakSet() + function reportIfRawProvider(node) { + node = unwrapExpression(node) + if (!node || visitedExpressions.has(node)) { + return + } + visitedExpressions.add(node) + for (const child of getProviderExpressionChildren(node)) { reportIfRawProvider(child) } diff --git a/packages/config-eslint/provider-identifiers.test.js b/packages/config-eslint/provider-identifiers.test.js index de03860b2a..030b5dbdd6 100644 --- a/packages/config-eslint/provider-identifiers.test.js +++ b/packages/config-eslint/provider-identifiers.test.js @@ -76,10 +76,38 @@ ruleTester.run("no-raw-provider-identifiers provider-like values", rule, { "const provider = configuredProvider || providerIdentifiers.openrouter", "const apiProvider = useGemini ? providerIdentifiers.gemini : providerIdentifiers.openrouter", "getProviderServiceConfig(providerIdentifiers.gemini)", + "const provider = getProvider(providerIdentifiers.openrouter)", + "const schema = { imageGenerationProvider: z.enum([...knownValues, ...[providerIdentifiers.openrouter]]) }", 'if (config?.protocol === "gemini") {}', 'const response = { protocol: "anthropic", format: "openrouter" }', ], invalid: [ + ...[ + 'const provider = getProvider("openrouter")', + 'const provider = getProvider(getProvider("openrouter"))', + 'const provider = getProvider("openrouter" as const)', + 'getProvider(...["openrouter"])', + 'const config = { apiProvider: getProvider("openrouter") }', + 'const schema = { imageGenerationProvider: z.enum([...["openrouter"]]) }', + 'const schema = { imageGenerationProvider: z.enum([, ...[...["openrouter"]]]) }', + ].map((code) => ({ + code, + errors: [ + { + messageId: "useCanonical", + data: { replacement: "providerIdentifiers.openrouter", value: "openrouter" }, + type: "Literal", + }, + ], + })), + { + code: 'const provider = getProvider("openrouter", "openrouter")', + errors: Array.from({ length: 2 }, () => ({ + messageId: "useCanonical", + data: { replacement: "providerIdentifiers.openrouter", value: "openrouter" }, + type: "Literal", + })), + }, { code: 'const apiProvider = "roo"', errors: [ diff --git a/src/core/config/__tests__/importExport.spec.ts b/src/core/config/__tests__/importExport.spec.ts index 6a99adaa7c..1f866bb523 100644 --- a/src/core/config/__tests__/importExport.spec.ts +++ b/src/core/config/__tests__/importExport.spec.ts @@ -1240,6 +1240,57 @@ describe("importExport", () => { consoleWarnSpy.mockRestore() }) + it.each([ + { imageGenerationProvider: providerIdentifiers.openrouter, expectedWarnings: undefined }, + { + imageGenerationProvider: retiredProviderIdentifiers.roo, + expectedWarnings: [ + 'Setting "globalSettings.imageGenerationProvider" used unsupported value "roo" and was cleared during import.', + ], + }, + ])( + "only clears the retired image provider: $imageGenerationProvider", + async ({ imageGenerationProvider, expectedWarnings }) => { + ;(vscode.window.showOpenDialog as Mock).mockResolvedValue([{ fsPath: "/mock/path/settings.json" }]) + ;(fs.readFile as Mock).mockResolvedValue( + JSON.stringify({ + providerProfiles: { + currentApiConfigName: "valid-profile", + apiConfigs: { + "valid-profile": { apiProvider: providerIdentifiers.openai, id: "valid-id" }, + }, + }, + globalSettings: { + imageGenerationProvider, + customInstructions: "roo", + }, + }), + ) + mockProviderSettingsManager.export.mockResolvedValue({ + currentApiConfigName: "default", + apiConfigs: { default: { apiProvider: providerIdentifiers.anthropic, id: "default-id" } }, + }) + mockProviderSettingsManager.listConfig.mockResolvedValue([ + { name: "valid-profile", id: "valid-id", apiProvider: providerIdentifiers.openai }, + ]) + + const result = await importSettings({ + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + customModesManager: mockCustomModesManager, + }) + + expect(result.success).toBe(true) + expect(result.warnings).toEqual(expectedWarnings) + expect(mockContextProxy.setValues).toHaveBeenCalledWith( + expect.objectContaining({ + imageGenerationProvider: expectedWarnings ? undefined : providerIdentifiers.openrouter, + customInstructions: "roo", + }), + ) + }, + ) + it("should normalize imageGenerationProvider roo while preserving other global settings", async () => { ;(vscode.window.showOpenDialog as Mock).mockResolvedValue([{ fsPath: "/mock/path/settings.json" }]) diff --git a/webview-ui/src/oauth/__tests__/urls.spec.ts b/webview-ui/src/oauth/__tests__/urls.spec.ts new file mode 100644 index 0000000000..9d5523fe75 --- /dev/null +++ b/webview-ui/src/oauth/__tests__/urls.spec.ts @@ -0,0 +1,45 @@ +import { Package } from "@roo/package" + +import { getCallbackUrl, getOpenRouterAuthUrl, getRequestyAuthUrl, getZooCodeAuthUrl } from "../urls" + +describe("OAuth URLs", () => { + it.each([undefined, "", "vscode-insiders", "cursor"])("encodes callback URLs for scheme %s", (scheme) => { + expect(getCallbackUrl("auth-callback", scheme)).toBe( + encodeURIComponent(`${scheme || "vscode"}://${Package.publisher}.${Package.name}/auth-callback`), + ) + }) + + it.each([ + [getOpenRouterAuthUrl, "https://openrouter.ai/auth", "openrouter"], + [getRequestyAuthUrl, "https://app.requesty.ai/oauth/authorize", "requesty"], + ] as const)("preserves the endpoint and provider callback for %s", (getAuthUrl, endpoint, identifier) => { + for (const scheme of [undefined, "", "vscode-insiders", "cursor"]) { + const callback = `${scheme || "vscode"}://${Package.publisher}.${Package.name}/${identifier}` + expect(getAuthUrl(scheme)).toBe(`${endpoint}?callback_url=${encodeURIComponent(callback)}`) + expect(new URL(getAuthUrl(scheme)).searchParams.get("callback_url")).toBe(callback) + } + }) + + it("uses default Zoo connection settings", () => { + const url = new URL(getZooCodeAuthUrl()) + expect(url.origin + url.pathname).toBe("https://www.zoocode.dev/dashboard/connect") + expect(Object.fromEntries(url.searchParams)).toEqual({ + device: "VS Code", + editor: "VS Code", + version: Package.version, + callback_uri: `vscode://${Package.publisher}.${Package.name}/auth-callback`, + }) + expect(getZooCodeAuthUrl("", "", "")).toBe(getZooCodeAuthUrl()) + }) + + it("encodes custom device names and uses the supplied Zoo host and editor scheme", () => { + const url = new URL(getZooCodeAuthUrl("cursor", "https://example.com", "Work & Home / ноутбук")) + expect(url.origin + url.pathname).toBe("https://example.com/dashboard/connect") + expect(Object.fromEntries(url.searchParams)).toEqual({ + device: "Work & Home / ноутбук", + editor: "VS Code", + version: Package.version, + callback_uri: `cursor://${Package.publisher}.${Package.name}/auth-callback`, + }) + }) +}) From ffac4ad6abfbf2698db1e0f97a8d72ad8bff55e4 Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Fri, 11 Sep 2026 00:00:30 +0000 Subject: [PATCH 3/3] test(oauth, importExport): strengthen mutation resistance --- src/core/config/__tests__/importExport.spec.ts | 1 + webview-ui/src/oauth/__tests__/urls.spec.ts | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/src/core/config/__tests__/importExport.spec.ts b/src/core/config/__tests__/importExport.spec.ts index 1f866bb523..0287b26511 100644 --- a/src/core/config/__tests__/importExport.spec.ts +++ b/src/core/config/__tests__/importExport.spec.ts @@ -1282,6 +1282,7 @@ describe("importExport", () => { expect(result.success).toBe(true) expect(result.warnings).toEqual(expectedWarnings) + expect(mockContextProxy.setValues).toHaveBeenCalledTimes(1) expect(mockContextProxy.setValues).toHaveBeenCalledWith( expect.objectContaining({ imageGenerationProvider: expectedWarnings ? undefined : providerIdentifiers.openrouter, diff --git a/webview-ui/src/oauth/__tests__/urls.spec.ts b/webview-ui/src/oauth/__tests__/urls.spec.ts index 9d5523fe75..9289d52358 100644 --- a/webview-ui/src/oauth/__tests__/urls.spec.ts +++ b/webview-ui/src/oauth/__tests__/urls.spec.ts @@ -29,6 +29,9 @@ describe("OAuth URLs", () => { version: Package.version, callback_uri: `vscode://${Package.publisher}.${Package.name}/auth-callback`, }) + expect(getZooCodeAuthUrl()).toContain( + `callback_uri=${encodeURIComponent(`vscode://${Package.publisher}.${Package.name}/auth-callback`)}`, + ) expect(getZooCodeAuthUrl("", "", "")).toBe(getZooCodeAuthUrl()) }) @@ -41,5 +44,8 @@ describe("OAuth URLs", () => { version: Package.version, callback_uri: `cursor://${Package.publisher}.${Package.name}/auth-callback`, }) + expect(getZooCodeAuthUrl("cursor")).toContain( + `callback_uri=${encodeURIComponent(`cursor://${Package.publisher}.${Package.name}/auth-callback`)}`, + ) }) })