From 8be27ac11489c2bbaebd5838c8e16a97821b2303 Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Tue, 22 Sep 2026 07:17:01 +0900 Subject: [PATCH 01/46] feat(mimo): fetch MiMo model catalog dynamically and add V2.6 series Xiaomi released the MiMo-V2.6 series (mimo-v2.6-pro, mimo-v2.6-flash, mimo-v2.6-pro-ultraspeed) on 2026-09-22, and mimo-v2.5-pro / mimo-v2.5 stop working on 2026-10-21 10:00 (GMT+8). The dedicated MiMo provider only offered a hardcoded catalog, so newer models were invisible unless users reconfigured MiMo as an OpenAI-compatible provider. Wire MiMo into the router-model pipeline so the picker populates from the account's GET {baseUrl}/models response, mirroring DeepSeek/Moonshot: - add mimo to dynamicProviders and dynamicProviderExtras - getMimoModels fetcher merges the API response with static mimoModels specs; unknown models get MiMo-family defaults with preserveReasoning - requestRouterModels fetches MiMo when an API key exists, honoring unsaved key/baseUrl form values (cluster URL selects the cache scope) - settings ModelPicker and useSelectedModel merge the fetched catalog with the static fallback, so post-V2.6 releases appear without an extension update Also update the static catalog: V2.6 entries with official pay-as-you-go pricing (https://mimo.mi.com/docs/en-US/price/pay-as-you-go), the v2.5 entries synced to the same page (flat pricing, 256K long-context multiplier removed), and the default model moved off the EOL'd mimo-v2.5-pro. Context windows (1M) and max output (128K) follow the published V2.5-line specs; the pricing page publishes no V2.6 spec table. --- .../__tests__/provider-identifiers.test.ts | 1 + packages/types/src/provider-settings.ts | 1 + packages/types/src/providers/mimo.ts | 81 ++++++--- src/api/providers/__tests__/mimo.spec.ts | 45 ++++- .../providers/fetchers/__tests__/mimo.spec.ts | 169 ++++++++++++++++++ src/api/providers/fetchers/mimo.ts | 90 ++++++++++ src/api/providers/fetchers/modelCache.ts | 5 + .../webview/__tests__/ClineProvider.spec.ts | 3 + ...webviewMessageHandler.routerModels.spec.ts | 123 +++++++++++++ .../__tests__/webviewMessageHandler.spec.ts | 3 + src/core/webview/webviewMessageHandler.ts | 25 +++ src/shared/api.ts | 1 + .../src/components/settings/ApiOptions.tsx | 25 ++- .../providers/__tests__/NanoGPT.spec.tsx | 1 + .../hooks/__tests__/useSelectedModel.spec.ts | 28 ++- .../components/ui/hooks/useSelectedModel.ts | 10 +- .../src/utils/__tests__/validate.spec.ts | 1 + 17 files changed, 568 insertions(+), 44 deletions(-) create mode 100644 src/api/providers/fetchers/__tests__/mimo.spec.ts create mode 100644 src/api/providers/fetchers/mimo.ts diff --git a/packages/types/src/__tests__/provider-identifiers.test.ts b/packages/types/src/__tests__/provider-identifiers.test.ts index d9ba4fb799..4d82659e4b 100644 --- a/packages/types/src/__tests__/provider-identifiers.test.ts +++ b/packages/types/src/__tests__/provider-identifiers.test.ts @@ -108,6 +108,7 @@ describe("provider identifiers", () => { providerIdentifiers.poe, providerIdentifiers.deepseek, providerIdentifiers.moonshot, + providerIdentifiers.mimo, providerIdentifiers.opencodeGo, providerIdentifiers.kenari, providerIdentifiers.nanogpt, diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index 0b898f1b66..2bc5536fcd 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -70,6 +70,7 @@ export const dynamicProviders = [ providerIdentifiers.poe, providerIdentifiers.deepseek, providerIdentifiers.moonshot, + providerIdentifiers.mimo, providerIdentifiers.opencodeGo, providerIdentifiers.kenari, providerIdentifiers.nanogpt, diff --git a/packages/types/src/providers/mimo.ts b/packages/types/src/providers/mimo.ts index debd0cbefc..664a23d8ff 100644 --- a/packages/types/src/providers/mimo.ts +++ b/packages/types/src/providers/mimo.ts @@ -1,8 +1,12 @@ import type { ModelInfo } from "../model.js" -// https://developer.puter.com/ai/xiaomi/mimo-v2.5-pro/ -// https://developer.puter.com/ai/xiaomi/mimo-v2.5/ -// https://platform.xiaomimimo.com/docs/en-US/quick-start/model-hyperparameters +// https://mimo.mi.com/docs/en-US/quick-start/model-hyperparameters +// https://mimo.mi.com/docs/en-US/quick-start/usage-guide/text-generation/deep-thinking +// https://mimo.mi.com/docs/en-US/price/pay-as-you-go +// +// mimo-v2.5-pro and mimo-v2.5 are deprecated on 2026-10-21 10:00 (GMT+8) and +// their model names will stop working. They stay in the catalog as a fallback +// until the EOL date passes; mimo-v2.6-* entries carry the V2.6 series. // // NOTE: mimo-v2-flash is not included here. Its thinking mode defaults to // disabled and it doesn't reliably handle reasoning_content passthrough @@ -12,28 +16,60 @@ import type { ModelInfo } from "../model.js" // an agentic provider, and flash just can't do that yet. export type MimoModelId = keyof typeof mimoModels -export const mimoDefaultModelId: MimoModelId = "mimo-v2.5-pro" +export const mimoDefaultModelId: MimoModelId = "mimo-v2.6-pro" export const mimoModels = { + "mimo-v2.6-pro": { + maxTokens: 131_072, + contextWindow: 1_048_576, + supportsImages: true, // V2.6 series is full-modality (text, image, audio, video) + supportsPromptCache: false, + preserveReasoning: true, + inputPrice: 0.435, // $0.435/1M tokens (cache miss) + outputPrice: 0.87, // $0.87/1M tokens + cacheReadsPrice: 0.0036, // $0.0036/1M tokens (cache hit) + cacheWritesPrice: 0, // Free for limited time + description: + "MiMo V2.6 Pro - Xiaomi's flagship omni-modal reasoning model with 1M context, deep thinking, tool calling, and structured output.", + }, + "mimo-v2.6-flash": { + maxTokens: 131_072, + contextWindow: 1_048_576, + supportsImages: true, // Full-modality: text, image, audio, video input + supportsPromptCache: false, + preserveReasoning: true, + inputPrice: 0.14, // $0.14/1M tokens (cache miss) + outputPrice: 0.28, // $0.28/1M tokens + cacheReadsPrice: 0.0028, // $0.0028/1M tokens (cache hit) + cacheWritesPrice: 0, // Free for limited time + description: + "MiMo V2.6 Flash - Full-modality, low-cost reasoning model for high-frequency calls and large-scale tasks.", + }, + "mimo-v2.6-pro-ultraspeed": { + maxTokens: 131_072, + contextWindow: 1_048_576, + supportsImages: true, // Full-modality like the rest of the V2.6 series + supportsPromptCache: false, + preserveReasoning: true, + inputPrice: 4.35, // $4.35/1M tokens (cache miss) + outputPrice: 8.7, // $8.70/1M tokens + cacheReadsPrice: 0.036, // $0.036/1M tokens (cache hit) + cacheWritesPrice: 0, // Free for limited time + description: + "MiMo V2.6 Pro Ultraspeed - V2.6-Pro performance at up to 20x speed for real-time, latency-sensitive workloads.", + }, "mimo-v2.5-pro": { maxTokens: 131_072, contextWindow: 1_048_576, supportsImages: false, // Pro series is text-only supportsPromptCache: false, preserveReasoning: true, - inputPrice: 1.0, // $1.00/1M tokens (cache miss, ≤256K) - outputPrice: 3.0, // $3.00/1M tokens (≤256K) - cacheReadsPrice: 0.2, // $0.20/1M tokens (cache hit, ≤256K) + inputPrice: 0.435, // $0.435/1M tokens (cache miss) + outputPrice: 0.87, // $0.87/1M tokens + cacheReadsPrice: 0.0036, // $0.0036/1M tokens (cache hit) cacheWritesPrice: 0, // Free for limited time - // MiMo charges 2x above 256K context - longContextPricing: { - thresholdTokens: 256_000, - inputPriceMultiplier: 2, - outputPriceMultiplier: 2, - cacheReadsPriceMultiplier: 2, - }, description: - "MiMo V2.5 Pro - Xiaomi's flagship reasoning model with 1M context, deep thinking, tool calling, and structured output.", + "MiMo V2.5 Pro - Deprecated on 2026-10-21. Xiaomi's flagship reasoning model with 1M context, deep thinking, tool calling, and structured output.", }, "mimo-v2.5": { maxTokens: 131_072, @@ -41,19 +77,12 @@ export const mimoModels = { supportsImages: true, // Full-modal: text, image, audio, video input supportsPromptCache: false, preserveReasoning: true, - inputPrice: 0.4, // $0.40/1M tokens (cache miss, ≤256K) - outputPrice: 2.0, // $2.00/1M tokens (≤256K) - cacheReadsPrice: 0.08, // $0.08/1M tokens (cache hit, ≤256K) + inputPrice: 0.14, // $0.14/1M tokens (cache miss) + outputPrice: 0.28, // $0.28/1M tokens + cacheReadsPrice: 0.0028, // $0.0028/1M tokens (cache hit) cacheWritesPrice: 0, // Free for limited time - // MiMo charges 2x above 256K context - longContextPricing: { - thresholdTokens: 256_000, - inputPriceMultiplier: 2, - outputPriceMultiplier: 2, - cacheReadsPriceMultiplier: 2, - }, description: - "MiMo V2.5 - Full-modal understanding model (text, image, audio, video) with 1M context, deep thinking, tool calling, and structured output.", + "MiMo V2.5 - Deprecated on 2026-10-21. Full-modal understanding model (text, image, audio, video) with 1M context, deep thinking, tool calling, and structured output.", }, } as const satisfies Record diff --git a/src/api/providers/__tests__/mimo.spec.ts b/src/api/providers/__tests__/mimo.spec.ts index 65e78f4673..a4c1882146 100644 --- a/src/api/providers/__tests__/mimo.spec.ts +++ b/src/api/providers/__tests__/mimo.spec.ts @@ -85,23 +85,58 @@ describe("MimoHandler", () => { expect(model.id).toBe("mimo-v2.5-pro") expect(model.info.contextWindow).toBe(1_048_576) expect(model.info.maxTokens).toBe(131_072) - expect(model.info.inputPrice).toBe(1.0) - expect(model.info.outputPrice).toBe(3.0) + expect(model.info.inputPrice).toBe(0.435) + expect(model.info.outputPrice).toBe(0.87) }) it("should return correct model info for mimo-v2.5", () => { const h = new MimoHandler({ ...mockOptions, apiModelId: "mimo-v2.5" }) const model = h.getModel() expect(model.id).toBe("mimo-v2.5") - expect(model.info.inputPrice).toBe(0.4) - expect(model.info.outputPrice).toBe(2.0) + expect(model.info.inputPrice).toBe(0.14) + expect(model.info.outputPrice).toBe(0.28) + }) + + it("should return correct model info for mimo-v2.6-pro", () => { + const h = new MimoHandler({ ...mockOptions, apiModelId: "mimo-v2.6-pro" }) + const model = h.getModel() + expect(model.id).toBe("mimo-v2.6-pro") + expect(model.info.contextWindow).toBe(1_048_576) + expect(model.info.maxTokens).toBe(131_072) + expect(model.info.inputPrice).toBe(0.435) + expect(model.info.outputPrice).toBe(0.87) + expect(model.info.cacheReadsPrice).toBe(0.0036) + }) + + it("should return correct model info for mimo-v2.6-flash", () => { + const h = new MimoHandler({ ...mockOptions, apiModelId: "mimo-v2.6-flash" }) + const model = h.getModel() + expect(model.id).toBe("mimo-v2.6-flash") + expect(model.info.supportsImages).toBe(true) + expect(model.info.inputPrice).toBe(0.14) + expect(model.info.outputPrice).toBe(0.28) + }) + + it("should return correct model info for mimo-v2.6-pro-ultraspeed", () => { + const h = new MimoHandler({ ...mockOptions, apiModelId: "mimo-v2.6-pro-ultraspeed" }) + const model = h.getModel() + expect(model.id).toBe("mimo-v2.6-pro-ultraspeed") + expect(model.info.inputPrice).toBe(4.35) + expect(model.info.outputPrice).toBe(8.7) + }) + + it("should default to mimo-v2.6-pro", () => { + const h = new MimoHandler({ ...mockOptions, apiModelId: undefined }) + const model = h.getModel() + expect(model.id).toBe(mimoDefaultModelId) + expect(model.info).toBe(mimoModels["mimo-v2.6-pro"]) }) it("should fallback to default model for unknown model ID", () => { const h = new MimoHandler({ ...mockOptions, apiModelId: "unknown-model" }) const model = h.getModel() expect(model.id).toBe("unknown-model") - expect(model.info).toBe(mimoModels["mimo-v2.5-pro"]) + expect(model.info).toBe(mimoModels[mimoDefaultModelId]) }) }) diff --git a/src/api/providers/fetchers/__tests__/mimo.spec.ts b/src/api/providers/fetchers/__tests__/mimo.spec.ts new file mode 100644 index 0000000000..ef67922c55 --- /dev/null +++ b/src/api/providers/fetchers/__tests__/mimo.spec.ts @@ -0,0 +1,169 @@ +import { mimoModels } from "@roo-code/types" + +import { getMimoModels } from "../mimo" + +describe("getMimoModels", () => { + const originalFetch = globalThis.fetch + + afterEach(() => { + globalThis.fetch = originalFetch + vi.restoreAllMocks() + }) + + it("merges API response with static model specs for known models", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + data: [{ id: "mimo-v2.6-pro" }, { id: "mimo-v2.6-flash" }], + }), + }) as unknown as typeof fetch + + const models = await getMimoModels("https://token-plan-sgp.xiaomimimo.com/v1", "mock-key") + + expect(globalThis.fetch).toHaveBeenCalledWith( + "https://token-plan-sgp.xiaomimimo.com/v1/models", + expect.any(Object), + ) + expect(models["mimo-v2.6-pro"]).toEqual(mimoModels["mimo-v2.6-pro"]) + expect(models["mimo-v2.6-flash"]).toEqual(mimoModels["mimo-v2.6-flash"]) + }) + + it("provides MiMo-family defaults for unknown model IDs without pricing", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + data: [{ id: "mimo-v3-future" }], + }), + }) as unknown as typeof fetch + + const models = await getMimoModels("https://token-plan-sgp.xiaomimimo.com/v1", "mock-key") + + expect(models["mimo-v3-future"]).toEqual({ + maxTokens: 16_000, + contextWindow: 262_144, + supportsImages: false, + supportsPromptCache: false, + preserveReasoning: true, + description: "MiMo model: mimo-v3-future", + }) + }) + + it("throws for HTTP errors", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 401, + statusText: "Unauthorized", + text: vi.fn().mockResolvedValue('{"error":{"message":"Invalid API key"}}'), + }) as unknown as typeof fetch + + await expect(getMimoModels("https://token-plan-sgp.xiaomimimo.com/v1", "invalid-key")).rejects.toThrow( + "HTTP 401: Unauthorized", + ) + }) + + it("uses default Singapore base URL when none provided", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ data: [] }), + }) as unknown as typeof fetch + + await getMimoModels(undefined, "mock-key") + + expect(globalThis.fetch).toHaveBeenCalledWith( + "https://token-plan-sgp.xiaomimimo.com/v1/models", + expect.any(Object), + ) + }) + + it("keeps /v1 in base URL and strips trailing slash", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ data: [] }), + }) as unknown as typeof fetch + + await getMimoModels("https://token-plan-cn.xiaomimimo.com/v1/", "mock-key") + + expect(globalThis.fetch).toHaveBeenCalledWith( + "https://token-plan-cn.xiaomimimo.com/v1/models", + expect.any(Object), + ) + }) + + it("throws when response data is not an array", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ data: "not-an-array" }), + }) as unknown as typeof fetch + + await expect(getMimoModels("https://token-plan-sgp.xiaomimimo.com/v1", "mock-key")).rejects.toThrow( + "Unexpected response format", + ) + }) + + it("skips models with empty or non-string ID", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + data: [{ id: "" }, { id: 123 }, { id: null }, { id: "mimo-v2.6-pro" }], + }), + }) as unknown as typeof fetch + + const models = await getMimoModels("https://token-plan-sgp.xiaomimimo.com/v1", "mock-key") + + expect(Object.keys(models)).toHaveLength(1) + expect(models["mimo-v2.6-pro"]).toBeDefined() + }) + + it("includes Authorization header when apiKey provided", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ data: [] }), + }) as unknown as typeof fetch + + await getMimoModels("https://token-plan-sgp.xiaomimimo.com/v1", "my-secret-key") + + expect(globalThis.fetch).toHaveBeenCalledWith( + "https://token-plan-sgp.xiaomimimo.com/v1/models", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer my-secret-key", + }), + }), + ) + }) + + it("mixes known and unknown models in same response", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + data: [{ id: "mimo-v2.6-pro" }, { id: "some-new-model" }], + }), + }) as unknown as typeof fetch + + const models = await getMimoModels("https://token-plan-sgp.xiaomimimo.com/v1", "mock-key") + + expect(models["mimo-v2.6-pro"]).toEqual(mimoModels["mimo-v2.6-pro"]) + expect(models["some-new-model"]).toEqual({ + maxTokens: 16_000, + contextWindow: 262_144, + supportsImages: false, + supportsPromptCache: false, + preserveReasoning: true, + description: "MiMo model: some-new-model", + }) + }) + + it("passes the caller's abort signal to the request", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response(JSON.stringify({ data: [] }), { status: 200 })) + const controller = new AbortController() + + await getMimoModels("https://token-plan-sgp.xiaomimimo.com/v1", "mock-key", { signal: controller.signal }) + + expect(fetchSpy).toHaveBeenCalledWith( + "https://token-plan-sgp.xiaomimimo.com/v1/models", + expect.objectContaining({ signal: controller.signal }), + ) + }) +}) diff --git a/src/api/providers/fetchers/mimo.ts b/src/api/providers/fetchers/mimo.ts new file mode 100644 index 0000000000..c3833cfced --- /dev/null +++ b/src/api/providers/fetchers/mimo.ts @@ -0,0 +1,90 @@ +import type { ModelRecord } from "@roo-code/types" +import { mimoModels } from "@roo-code/types" + +import { DEFAULT_HEADERS } from "../constants" + +/** + * Fetches available models from the Xiaomi MiMo API and merges them with known specs. + * + * MiMo's OpenAI-compatible /models endpoint only returns basic model IDs without + * pricing or context window info, so we merge the API response with the static + * `mimoModels` map for known models. Unknown models get MiMo-family defaults with + * `preserveReasoning` enabled — MiMo requires reasoning_content to be passed back + * in multi-turn tool-calling conversations, so reasoning preservation is a + * correctness requirement, not an optimization. + */ +export async function getMimoModels( + baseUrl?: string, + apiKey?: string, + opts?: { signal?: AbortSignal }, +): Promise { + // MiMo API uses OpenAI-compatible /v1/models endpoint. + // The base URL from settings already includes /v1 (e.g. https://token-plan-sgp.xiaomimimo.com/v1), + // so we keep it as-is and append /models directly. + const base = (baseUrl || "https://token-plan-sgp.xiaomimimo.com/v1").replace(/\/+$/, "") + const url = `${base}/models` + + const headers: Record = { + "Content-Type": "application/json", + ...DEFAULT_HEADERS, + } + + if (apiKey) { + headers["Authorization"] = `Bearer ${apiKey}` + } + + const response = await fetch(url, { + headers, + signal: opts?.signal, + }) + + if (!response.ok) { + let errorBody = "" + try { + errorBody = await response.text() + } catch { + errorBody = "(unable to read response body)" + } + + console.error(`[getMimoModels] HTTP error:`, { + status: response.status, + statusText: response.statusText, + url, + body: errorBody, + }) + + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + const data = await response.json() + + if (!data?.data || !Array.isArray(data.data)) { + console.error("[getMimoModels] Unexpected response format:", data) + throw new Error("Failed to fetch MiMo models: Unexpected response format.") + } + + // Use null-prototype object to prevent prototype pollution + const models: ModelRecord = Object.create(null) + + for (const model of data.data) { + const modelId = typeof model.id === "string" && model.id ? model.id : null + if (!modelId) continue + + const knownSpecs = mimoModels[modelId as keyof typeof mimoModels] + + if (knownSpecs) { + models[modelId] = { ...knownSpecs } + } else { + models[modelId] = { + maxTokens: 16_000, + contextWindow: 262_144, + supportsImages: false, + supportsPromptCache: false, + preserveReasoning: true, + description: `MiMo model: ${modelId}`, + } + } + } + + return models +} diff --git a/src/api/providers/fetchers/modelCache.ts b/src/api/providers/fetchers/modelCache.ts index 6f0898c71b..fcce580e7f 100644 --- a/src/api/providers/fetchers/modelCache.ts +++ b/src/api/providers/fetchers/modelCache.ts @@ -33,6 +33,7 @@ import { getLMStudioModels } from "./lmstudio" import { getPoeModels } from "./poe" import { getDeepSeekModels } from "./deepseek" import { getMoonshotModels } from "./moonshot" +import { getMimoModels } from "./mimo" import { getZooGatewayModels } from "./zoo-gateway" import { getKimiCodeModels } from "./kimi-code" @@ -105,6 +106,7 @@ const URL_SCOPED_PROVIDERS: ReadonlySet = new Set([ providerIdentifiers.poe, providerIdentifiers.deepseek, providerIdentifiers.moonshot, + providerIdentifiers.mimo, providerIdentifiers.ollama, providerIdentifiers.lmstudio, providerIdentifiers.requesty, @@ -304,6 +306,9 @@ async function fetchModelsFromProvider(options: GetModelsOptions, signal?: Abort case providerIdentifiers.moonshot: models = await getMoonshotModels(options.baseUrl, options.apiKey, ...fetchOpts) break + case providerIdentifiers.mimo: + models = await getMimoModels(options.baseUrl, options.apiKey, ...fetchOpts) + break case providerIdentifiers.zooGateway: models = await getZooGatewayModels({ zooSessionToken: options.apiKey, zooGatewayBaseUrl: options.baseUrl }) break diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 97c4dd877e..5fe417b395 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -3799,6 +3799,7 @@ describe("ClineProvider - Router Models", () => { poe: {}, deepseek: {}, moonshot: {}, + mimo: {}, "opencode-go": mockModels, kenari: mockModels, nanogpt: mockModels, @@ -3855,6 +3856,7 @@ describe("ClineProvider - Router Models", () => { poe: {}, deepseek: {}, moonshot: {}, + mimo: {}, "opencode-go": mockModels, kenari: mockModels, nanogpt: mockModels, @@ -3956,6 +3958,7 @@ describe("ClineProvider - Router Models", () => { poe: {}, deepseek: {}, moonshot: {}, + mimo: {}, "opencode-go": mockModels, kenari: mockModels, nanogpt: mockModels, diff --git a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts index 5a4b3e7be3..337aba0615 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts @@ -132,14 +132,17 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { expect(routerModels).toHaveProperty(providerIdentifiers.requesty) expect(routerModels).toHaveProperty(providerIdentifiers.deepseek) expect(routerModels).toHaveProperty(providerIdentifiers.moonshot) + expect(routerModels).toHaveProperty(providerIdentifiers.mimo) expect(routerModels.deepseek).toEqual({}) expect(routerModels.moonshot).toEqual({}) + expect(routerModels.mimo).toEqual({}) expect(getModelsMock).not.toHaveBeenCalledWith( expect.objectContaining({ provider: providerIdentifiers.deepseek }), ) expect(getModelsMock).not.toHaveBeenCalledWith( expect.objectContaining({ provider: providerIdentifiers.moonshot }), ) + expect(getModelsMock).not.toHaveBeenCalledWith(expect.objectContaining({ provider: providerIdentifiers.mimo })) }) it("fetches DeepSeek models when stored DeepSeek credentials exist", async () => { @@ -586,6 +589,126 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { }) }) + it("fetches MiMo models when stored MiMo credentials exist", async () => { + mockProvider.getState.mockResolvedValue({ + apiConfiguration: { + mimoApiKey: "stored-mimo-key", + mimoBaseUrl: "https://token-plan-sgp.xiaomimimo.com/v1", + }, + }) + + getModelsMock.mockImplementation(async (options) => { + if (options?.provider === providerIdentifiers.mimo) { + return { "mimo-v2.6-pro": { contextWindow: 1_048_576, supportsPromptCache: false } } + } + + switch (options?.provider) { + case providerIdentifiers.openrouter: + return { "openrouter/qwen2.5": { contextWindow: 32768, supportsPromptCache: false } } + case providerIdentifiers.requesty: + return { "requesty/model": { contextWindow: 8192, supportsPromptCache: false } } + case providerIdentifiers.vercelAiGateway: + return { "vercel/model": { contextWindow: 8192, supportsPromptCache: false } } + case providerIdentifiers.litellm: + return { "litellm/model": { contextWindow: 8192, supportsPromptCache: false } } + default: + return {} + } + }) + + await webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.requestRouterModels, + }) + + expect(getModelsMock).toHaveBeenCalledWith({ + provider: providerIdentifiers.mimo, + apiKey: "stored-mimo-key", + baseUrl: "https://token-plan-sgp.xiaomimimo.com/v1", + }) + + const response = mockProvider.postMessageToWebview.mock.calls.find( + (call) => call[0]?.type === RouterModelsMessageType.routerModels, + ) + expect(response).toBeDefined() + if (!response) throw new Error("Expected routerModels response") + expect(response[0].routerModels.mimo).toEqual({ + "mimo-v2.6-pro": { contextWindow: 1_048_576, supportsPromptCache: false }, + }) + }) + + it("flushes MiMo cache when explicit credentials are provided via message values", async () => { + getModelsMock.mockResolvedValue({ + "mimo-v2.6-pro": { contextWindow: 1_048_576, supportsPromptCache: false }, + }) + + await webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.requestRouterModels, + values: { + mimoApiKey: "new-mimo-key", + mimoBaseUrl: "https://token-plan-ams.xiaomimimo.com/v1", + }, + }) + + const mimoOptions = { + provider: providerIdentifiers.mimo, + apiKey: "new-mimo-key", + baseUrl: "https://token-plan-ams.xiaomimimo.com/v1", + } + expect(flushModelsMock).toHaveBeenCalledWith(mimoOptions, true) + expect(getModelsMock).toHaveBeenCalledWith(mimoOptions) + + const response = mockProvider.postMessageToWebview.mock.calls.find( + (call) => call[0]?.type === RouterModelsMessageType.routerModels, + ) + expect(response).toBeDefined() + if (!response) throw new Error("Expected routerModels response") + expect(response[0].routerModels.mimo).toEqual({ + "mimo-v2.6-pro": { contextWindow: 1_048_576, supportsPromptCache: false }, + }) + }) + + it("does not flush MiMo cache when using stored credentials", async () => { + mockProvider.getState.mockResolvedValue({ + apiConfiguration: { + mimoApiKey: "stored-mimo-key", + }, + }) + + getModelsMock.mockImplementation(async (options) => { + if (options?.provider === providerIdentifiers.mimo) { + return { "mimo-v2.6-pro": { contextWindow: 1_048_576, supportsPromptCache: false } } + } + + switch (options?.provider) { + case providerIdentifiers.openrouter: + return { "openrouter/qwen2.5": { contextWindow: 32768, supportsPromptCache: false } } + case providerIdentifiers.requesty: + return { "requesty/model": { contextWindow: 8192, supportsPromptCache: false } } + case providerIdentifiers.vercelAiGateway: + return { "vercel/model": { contextWindow: 8192, supportsPromptCache: false } } + case providerIdentifiers.litellm: + return { "litellm/model": { contextWindow: 8192, supportsPromptCache: false } } + default: + return {} + } + }) + + await webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.requestRouterModels, + }) + + const mimoFlushCalls = flushModelsMock.mock.calls.filter((c) => c[0]?.provider === providerIdentifiers.mimo) + expect(mimoFlushCalls.length).toBe(0) + + const mimoCalls = getModelsMock.mock.calls.filter((c) => c[0]?.provider === providerIdentifiers.mimo) + expect(mimoCalls.length).toBe(1) + expect(mimoCalls[0][0]).toEqual({ + provider: providerIdentifiers.mimo, + apiKey: "stored-mimo-key", + baseUrl: undefined, + }) + }) + it("posts a Moonshot provider error and keeps an empty aggregate entry when fetch fails", async () => { mockProvider.getState.mockResolvedValue({ apiConfiguration: { diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 4c2a301965..dde4eb3dbe 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -574,6 +574,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { poe: {}, deepseek: {}, moonshot: {}, + mimo: {}, "opencode-go": mockModels, kenari: mockModels, nanogpt: mockModels, @@ -822,6 +823,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { poe: {}, deepseek: {}, moonshot: {}, + mimo: {}, "opencode-go": mockModels, kenari: mockModels, nanogpt: mockModels, @@ -887,6 +889,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { poe: {}, deepseek: {}, moonshot: {}, + mimo: {}, "opencode-go": mockModels, kenari: mockModels, nanogpt: mockModels, diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 34a35ea3ca..76bd2ab6ae 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1130,6 +1130,7 @@ export const webviewMessageHandler = async ( [providerIdentifiers.poe]: {}, [providerIdentifiers.deepseek]: {}, [providerIdentifiers.moonshot]: {}, + [providerIdentifiers.mimo]: {}, [providerIdentifiers.opencodeGo]: {}, [providerIdentifiers.kenari]: {}, [providerIdentifiers.nanogpt]: {}, @@ -1268,6 +1269,30 @@ export const webviewMessageHandler = async ( }) } + // MiMo is conditional on apiKey. The baseUrl selects the cluster + // (cn/sgp/ams token-plan or pay-as-you-go), so unsaved form values are + // honored the same way as DeepSeek/Moonshot above. + const mimoApiKey = message?.values?.mimoApiKey ?? apiConfiguration.mimoApiKey + const mimoBaseUrl = message?.values?.mimoBaseUrl ?? apiConfiguration.mimoBaseUrl + + if (mimoApiKey) { + if (message?.values?.mimoApiKey || message?.values?.mimoBaseUrl) { + await flushModels( + { provider: providerIdentifiers.mimo, apiKey: mimoApiKey, baseUrl: mimoBaseUrl }, + true, + ) + } + + candidates.push({ + key: providerIdentifiers.mimo, + options: { + provider: providerIdentifiers.mimo, + apiKey: mimoApiKey, + baseUrl: mimoBaseUrl, + }, + }) + } + // Opencode Go's /models endpoint is public — it returns the full model list with no // Authorization header — so it's fetched unconditionally like openrouter/vercel-ai-gateway // above. Gating it behind a key meant the picker stayed empty (and fell back to the default diff --git a/src/shared/api.ts b/src/shared/api.ts index d301ee68b9..e2fbaa2fdf 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -192,6 +192,7 @@ const dynamicProviderExtras = { [providerIdentifiers.poe]: {} as { apiKey?: string; baseUrl?: string }, [providerIdentifiers.deepseek]: {} as { apiKey?: string; baseUrl?: string }, [providerIdentifiers.moonshot]: {} as { apiKey?: string; baseUrl?: string }, + [providerIdentifiers.mimo]: {} as { apiKey?: string; baseUrl?: string }, [providerIdentifiers.opencodeGo]: {} as { apiKey?: string }, [providerIdentifiers.kenari]: {} as { apiKey?: string }, [providerIdentifiers.nanogpt]: {} as { apiKey?: string }, diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 714b507774..195d0d79a2 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -745,11 +745,26 @@ const ApiOptions = ({ apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} defaultModelId={getDefaultModelIdForProvider(activeSelectedProvider, apiConfiguration)} - models={getStaticModelsForProvider( - activeSelectedProvider, - t("settings:labels.useCustomArn"), - apiConfiguration, - )} + models={ + // MiMo is a dynamic provider: merge the host-fetched model + // catalog into the static fallback so models newer than the + // shipped catalog (e.g. post-V2.6 releases) are selectable + // without an extension update. + activeSelectedProvider === providerIdentifiers.mimo + ? { + ...getStaticModelsForProvider( + activeSelectedProvider, + t("settings:labels.useCustomArn"), + apiConfiguration, + ), + ...routerModels?.[providerIdentifiers.mimo], + } + : getStaticModelsForProvider( + activeSelectedProvider, + t("settings:labels.useCustomArn"), + apiConfiguration, + ) + } modelIdKey="apiModelId" serviceName={getProviderServiceConfig(activeSelectedProvider).serviceName} serviceUrl={getProviderServiceConfig(activeSelectedProvider).serviceUrl} diff --git a/webview-ui/src/components/settings/providers/__tests__/NanoGPT.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/NanoGPT.spec.tsx index bb810caa75..605e6f4997 100644 --- a/webview-ui/src/components/settings/providers/__tests__/NanoGPT.spec.tsx +++ b/webview-ui/src/components/settings/providers/__tests__/NanoGPT.spec.tsx @@ -100,6 +100,7 @@ describe("NanoGPT", () => { poe: {}, deepseek: {}, moonshot: {}, + mimo: {}, "opencode-go": {}, kenari: {}, nanogpt: { "openai/test": { contextWindow: 1, maxTokens: 1, supportsPromptCache: false } }, diff --git a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts index 64d3067092..2b7360ff06 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts @@ -31,6 +31,8 @@ import { vscodeLlmDefaultModelId, moonshotDefaultModelId, moonshotModels, + mimoDefaultModelId, + mimoModels, kimiCodeDefaultModelInfo, lMStudioDefaultModelInfo, opencodeGoDefaultModelInfo, @@ -234,11 +236,18 @@ describe("useSelectedModel", () => { expect(result.current.info).toEqual(opencodeGoDefaultModelInfo) }) - it.each([providerIdentifiers.deepseek, providerIdentifiers.moonshot])( + it.each([providerIdentifiers.deepseek, providerIdentifiers.moonshot, providerIdentifiers.mimo])( "prefers router data over static data for %s", (provider) => { const modelInfo: ModelInfo = { contextWindow: 42_000, supportsPromptCache: false } - const modelId = provider === providerIdentifiers.deepseek ? "deepseek-v4-pro" : moonshotDefaultModelId + let modelId: string + if (provider === providerIdentifiers.deepseek) { + modelId = "deepseek-v4-pro" + } else if (provider === providerIdentifiers.moonshot) { + modelId = moonshotDefaultModelId + } else { + modelId = mimoDefaultModelId + } mockUseRouterModels.mockReturnValue(createRouterModelsResult({ [provider]: { [modelId]: modelInfo } })) mockUseOpenRouterModelProviders.mockReturnValue(createOpenRouterModelProvidersResult({})) @@ -266,10 +275,17 @@ describe("useSelectedModel", () => { expect(result.current.info?.supportsImages).toBe(true) }) - it.each([providerIdentifiers.deepseek, providerIdentifiers.moonshot])( + it.each([providerIdentifiers.deepseek, providerIdentifiers.moonshot, providerIdentifiers.mimo])( "falls back to static data when the %s router catalog is null", (provider) => { - const modelId = provider === providerIdentifiers.deepseek ? deepSeekDefaultModelId : moonshotDefaultModelId + let modelId: string + if (provider === providerIdentifiers.deepseek) { + modelId = deepSeekDefaultModelId + } else if (provider === providerIdentifiers.moonshot) { + modelId = moonshotDefaultModelId + } else { + modelId = mimoDefaultModelId + } mockUseRouterModels.mockReturnValue(createRouterModelsResult({ [provider]: null })) mockUseOpenRouterModelProviders.mockReturnValue(createOpenRouterModelProvidersResult({})) @@ -280,8 +296,10 @@ describe("useSelectedModel", () => { expect(result.current.id).toBe(modelId) if (provider === providerIdentifiers.deepseek) { expect(result.current.info).toEqual(deepSeekModels[deepSeekDefaultModelId]) - } else { + } else if (provider === providerIdentifiers.moonshot) { expect(result.current.info).toEqual(moonshotModels[modelId as keyof typeof moonshotModels]) + } else { + expect(result.current.info).toEqual(mimoModels[modelId as keyof typeof mimoModels]) } }, ) diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index 8d4b70ad4a..822a8d8a2f 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -317,9 +317,13 @@ function getSelectedModel({ return { id, info } } case providerIdentifiers.mimo: { - const id = apiConfiguration.apiModelId ?? defaultModelId - const info = mimoModels[id as keyof typeof mimoModels] ?? mimoModels["mimo-v2.5-pro"] - return { id, info } + const availableModels = routerModels[providerIdentifiers.mimo] + ? { ...mimoModels, ...routerModels[providerIdentifiers.mimo] } + : mimoModels + const id = getValidatedModelId(apiConfiguration.apiModelId, availableModels, defaultModelId) + const routerInfo = routerModels[providerIdentifiers.mimo]?.[id] + const staticInfo = mimoModels[id as keyof typeof mimoModels] + return { id, info: routerInfo ?? staticInfo } } case providerIdentifiers.zai: { const apiLine = apiConfiguration.zaiApiLine ?? "international_coding" diff --git a/webview-ui/src/utils/__tests__/validate.spec.ts b/webview-ui/src/utils/__tests__/validate.spec.ts index a1936b89d7..b954d641de 100644 --- a/webview-ui/src/utils/__tests__/validate.spec.ts +++ b/webview-ui/src/utils/__tests__/validate.spec.ts @@ -63,6 +63,7 @@ describe("Model Validation Functions", () => { "zoo-gateway": {}, "kimi-code": {}, moonshot: {}, + mimo: {}, } const allowAllOrganization: OrganizationAllowList = { From a340b6f61d9a6d6fb0af35d1af89d672001b5728 Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Tue, 22 Sep 2026 08:22:01 +0900 Subject: [PATCH 02/46] fix(mimo): harden model discovery per review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - require https: at the fetch boundary so the bearer key is never sent over plaintext HTTP (settings schema allows only https endpoints, but unsaved webview values reach this fetcher too) - skip null/non-object entries in the /models payload so one malformed element cannot abort the whole catalog - exclude ASR/TTS model families from discovery — they are not text chat models and require modality payloads this provider never builds - scope the MiMo model cache by API key so catalogs never leak across credentials on the same cluster URL - catch MiMo refresh failures in requestRouterModels and post the normal failure response instead of exiting before any response is sent --- .../providers/fetchers/__tests__/mimo.spec.ts | 56 +++++++++++++++++++ src/api/providers/fetchers/mimo.ts | 24 ++++++-- src/api/providers/fetchers/modelCache.ts | 1 + ...webviewMessageHandler.routerModels.spec.ts | 28 ++++++++++ src/core/webview/webviewMessageHandler.ts | 32 ++++++++--- 5 files changed, 128 insertions(+), 13 deletions(-) diff --git a/src/api/providers/fetchers/__tests__/mimo.spec.ts b/src/api/providers/fetchers/__tests__/mimo.spec.ts index ef67922c55..3ae0bafb76 100644 --- a/src/api/providers/fetchers/__tests__/mimo.spec.ts +++ b/src/api/providers/fetchers/__tests__/mimo.spec.ts @@ -153,6 +153,62 @@ describe("getMimoModels", () => { }) }) + it("throws when the base URL is not https and never sends the request", async () => { + const fetchSpy = vi.fn() + globalThis.fetch = fetchSpy as unknown as typeof fetch + + await expect(getMimoModels("http://token-plan-sgp.xiaomimimo.com/v1", "my-secret-key")).rejects.toThrow( + "requires an https:// base URL", + ) + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it("skips null and non-object entries in the model list", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + data: [null, "mimo-v2.6-pro", 42, { id: "mimo-v2.6-pro" }], + }), + }) as unknown as typeof fetch + + const models = await getMimoModels("https://token-plan-sgp.xiaomimimo.com/v1", "mock-key") + + expect(Object.keys(models)).toEqual(["mimo-v2.6-pro"]) + }) + + it("excludes ASR and TTS model families from the catalog", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + data: [ + { id: "mimo-v2.6-pro" }, + { id: "mimo-v2.5-asr" }, + { id: "mimo-v2.5-tts" }, + { id: "mimo-v2.5-tts-voiceclone" }, + { id: "mimo-v2.5-tts-voicedesign" }, + ], + }), + }) as unknown as typeof fetch + + const models = await getMimoModels("https://token-plan-sgp.xiaomimimo.com/v1", "mock-key") + + expect(Object.keys(models)).toEqual(["mimo-v2.6-pro"]) + }) + + it("strips multiple trailing slashes from the base URL", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ data: [] }), + }) as unknown as typeof fetch + + await getMimoModels("https://token-plan-cn.xiaomimimo.com/v1///", "mock-key") + + expect(globalThis.fetch).toHaveBeenCalledWith( + "https://token-plan-cn.xiaomimimo.com/v1/models", + expect.any(Object), + ) + }) + it("passes the caller's abort signal to the request", async () => { const fetchSpy = vi .spyOn(globalThis, "fetch") diff --git a/src/api/providers/fetchers/mimo.ts b/src/api/providers/fetchers/mimo.ts index c3833cfced..5f822fc5a9 100644 --- a/src/api/providers/fetchers/mimo.ts +++ b/src/api/providers/fetchers/mimo.ts @@ -3,6 +3,11 @@ import { mimoModels } from "@roo-code/types" import { DEFAULT_HEADERS } from "../constants" +// The /models endpoint also lists ASR/TTS families, which are not text chat +// models — they require modality-specific payloads this provider never +// builds, so discovery excludes them from the catalog. +const NON_TEXT_MODEL_ID = /-(asr|tts)(-|$)/i + /** * Fetches available models from the Xiaomi MiMo API and merges them with known specs. * @@ -22,7 +27,14 @@ export async function getMimoModels( // The base URL from settings already includes /v1 (e.g. https://token-plan-sgp.xiaomimimo.com/v1), // so we keep it as-is and append /models directly. const base = (baseUrl || "https://token-plan-sgp.xiaomimimo.com/v1").replace(/\/+$/, "") - const url = `${base}/models` + const url = new URL(`${base}/models`) + + // The settings schema only allows https:// endpoints, but this fetcher also + // receives unsaved webview values — enforce the contract at the boundary so + // the bearer key is never sent over plaintext HTTP. + if (url.protocol !== "https:") { + throw new Error(`MiMo model fetch requires an https:// base URL (received "${url.protocol}")`) + } const headers: Record = { "Content-Type": "application/json", @@ -33,7 +45,7 @@ export async function getMimoModels( headers["Authorization"] = `Bearer ${apiKey}` } - const response = await fetch(url, { + const response = await fetch(url.toString(), { headers, signal: opts?.signal, }) @@ -49,7 +61,7 @@ export async function getMimoModels( console.error(`[getMimoModels] HTTP error:`, { status: response.status, statusText: response.statusText, - url, + url: url.toString(), body: errorBody, }) @@ -67,8 +79,12 @@ export async function getMimoModels( const models: ModelRecord = Object.create(null) for (const model of data.data) { + // Skip non-record elements so a single malformed entry cannot abort + // the whole catalog. + if (typeof model !== "object" || model === null) continue + const modelId = typeof model.id === "string" && model.id ? model.id : null - if (!modelId) continue + if (!modelId || NON_TEXT_MODEL_ID.test(modelId)) continue const knownSpecs = mimoModels[modelId as keyof typeof mimoModels] diff --git a/src/api/providers/fetchers/modelCache.ts b/src/api/providers/fetchers/modelCache.ts index fcce580e7f..19a950285c 100644 --- a/src/api/providers/fetchers/modelCache.ts +++ b/src/api/providers/fetchers/modelCache.ts @@ -125,6 +125,7 @@ const KEY_SCOPED_PROVIDERS: ReadonlySet = new Set([ providerIdentifiers.poe, // Per-account model availability providerIdentifiers.requesty, // Per-account custom model policies providerIdentifiers.moonshot, // Per-key model visibility (api.moonshot.ai vs api.moonshot.cn) + providerIdentifiers.mimo, // Token-plan and PAYG catalogs are authenticated per key providerIdentifiers.zooGateway, // Per-session-token account identity providerIdentifiers.kimiCode, // Per-session-token account identity providerIdentifiers.nanogpt, // Public catalog can still vary by API-key allowlist diff --git a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts index 337aba0615..ccd0989efb 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts @@ -709,6 +709,34 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { }) }) + it("posts a MiMo failure response and still posts routerModels when the refresh rejects", async () => { + flushModelsMock.mockRejectedValue(new Error("MiMo refresh failed")) + getModelsMock.mockResolvedValue({}) + + await webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.requestRouterModels, + values: { + mimoApiKey: "new-mimo-key", + mimoBaseUrl: "https://token-plan-sgp.xiaomimimo.com/v1", + }, + }) + + const errorCall = mockProvider.postMessageToWebview.mock.calls.find( + (call) => + call[0]?.type === RouterModelsMessageType.singleRouterModelFetchResponse && + call[0]?.values?.provider === providerIdentifiers.mimo, + ) + expect(errorCall).toBeDefined() + if (!errorCall) throw new Error("Expected MiMo failure response") + expect(errorCall[0].success).toBe(false) + expect(errorCall[0].error).toBe("MiMo refresh failed") + + const response = mockProvider.postMessageToWebview.mock.calls.find( + (call) => call[0]?.type === RouterModelsMessageType.routerModels, + ) + expect(response).toBeDefined() + }) + it("posts a Moonshot provider error and keeps an empty aggregate entry when fetch fails", async () => { mockProvider.getState.mockResolvedValue({ apiConfiguration: { diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 76bd2ab6ae..6af0980177 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1276,20 +1276,34 @@ export const webviewMessageHandler = async ( const mimoBaseUrl = message?.values?.mimoBaseUrl ?? apiConfiguration.mimoBaseUrl if (mimoApiKey) { + const mimoOptions = { + provider: providerIdentifiers.mimo, + apiKey: mimoApiKey, + baseUrl: mimoBaseUrl, + } + if (message?.values?.mimoApiKey || message?.values?.mimoBaseUrl) { - await flushModels( - { provider: providerIdentifiers.mimo, apiKey: mimoApiKey, baseUrl: mimoBaseUrl }, - true, - ) + // A refresh failure (bad key/endpoint) must not abort the + // aggregate fetch — surface the normal MiMo failure response + // and continue so routerModels is still posted. + try { + await flushModels(mimoOptions, true) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + console.error(`Error refreshing models for ${providerIdentifiers.mimo}:`, error) + + await provider.postMessageToWebview({ + type: RouterModelsMessageType.singleRouterModelFetchResponse, + success: false, + error: errorMessage, + values: { provider: providerIdentifiers.mimo }, + }) + } } candidates.push({ key: providerIdentifiers.mimo, - options: { - provider: providerIdentifiers.mimo, - apiKey: mimoApiKey, - baseUrl: mimoBaseUrl, - }, + options: mimoOptions, }) } From 45497706ace4e38b40a8c86e9a53ca2f9ef6821c Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Tue, 22 Sep 2026 08:41:27 +0900 Subject: [PATCH 03/46] chore: retrigger automated review and pre-merge checks From a4f26b543dfb9bc974006db2850e025fc90286fd Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Tue, 22 Sep 2026 16:37:41 +0900 Subject: [PATCH 04/46] fix(mimo): enforce endpoint allowlist inside getMimoModels before sending credentials CodeRabbit pre-merge ERROR (PR #1748): unsaved webview mimoBaseUrl bypassed the persisted 4-URL Xiaomi allowlist and the fetcher only checked the https scheme, so a crafted requestRouterModels message could exfiltrate the saved API key to an arbitrary HTTPS origin. Enforce the allowlist (mirrored from the zod schema, drift-pinned by a sync test) and reject userinfo-bearing URLs inside getMimoModels as the network boundary, before any request or Authorization header is constructed. Regression tests: attacker origin (fetch not called), userinfo/authority confusion on allowed and attacker hosts, all 4 allowed URLs still fetch, schema-drift sync. --- .../providers/fetchers/__tests__/mimo.spec.ts | 84 ++++++++++++++++++- src/api/providers/fetchers/mimo.ts | 47 +++++++++-- 2 files changed, 122 insertions(+), 9 deletions(-) diff --git a/src/api/providers/fetchers/__tests__/mimo.spec.ts b/src/api/providers/fetchers/__tests__/mimo.spec.ts index 3ae0bafb76..28df8ba545 100644 --- a/src/api/providers/fetchers/__tests__/mimo.spec.ts +++ b/src/api/providers/fetchers/__tests__/mimo.spec.ts @@ -1,6 +1,6 @@ -import { mimoModels } from "@roo-code/types" +import { mimoModels, providerSettingsSchema } from "@roo-code/types" -import { getMimoModels } from "../mimo" +import { getMimoModels, ALLOWED_BASE_URLS } from "../mimo" describe("getMimoModels", () => { const originalFetch = globalThis.fetch @@ -157,12 +157,90 @@ describe("getMimoModels", () => { const fetchSpy = vi.fn() globalThis.fetch = fetchSpy as unknown as typeof fetch + // An http:// endpoint fails the exact-match allowlist, which subsumes the + // previous scheme-only guard. await expect(getMimoModels("http://token-plan-sgp.xiaomimimo.com/v1", "my-secret-key")).rejects.toThrow( - "requires an https:// base URL", + "not an allowed Xiaomi MiMo endpoint", ) expect(fetchSpy).not.toHaveBeenCalled() }) + it("rejects an arbitrary https URL outside the allowlist and never sends the bearer key", async () => { + const fetchSpy = vi.fn() + globalThis.fetch = fetchSpy as unknown as typeof fetch + + await expect(getMimoModels("https://attacker.example/v1", "my-secret-key")).rejects.toThrow( + "not an allowed Xiaomi MiMo endpoint", + ) + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it("rejects an allowed host reached through userinfo authority-confusion before any request", async () => { + const fetchSpy = vi.fn() + globalThis.fetch = fetchSpy as unknown as typeof fetch + + // classic authority trick: credentials make everything before the last @ + // the userinfo, so the real host is attacker.example. + await expect(getMimoModels("https://token-plan-sgp.xiaomimimo.com@attacker.example/v1", "key")).rejects.toThrow( + "must not contain credentials", + ) + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it("rejects embedded credentials on an allowed endpoint and never echoes the secret", async () => { + const fetchSpy = vi.fn() + globalThis.fetch = fetchSpy as unknown as typeof fetch + + const error = await getMimoModels( + "https://user:hunter2@token-plan-sgp.xiaomimimo.com/v1", + "my-secret-key", + ).catch((thrown: unknown) => (thrown instanceof Error ? thrown : new Error(String(thrown)))) + + expect(error.message).toContain("must not contain credentials") + expect(error.message).not.toContain("hunter2") + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it("rejects embedded credentials on an arbitrary host and never echoes the secret", async () => { + const fetchSpy = vi.fn() + globalThis.fetch = fetchSpy as unknown as typeof fetch + + const error = await getMimoModels("https://user:hunter2@attacker.example/v1", "my-secret-key").catch( + (thrown: unknown) => (thrown instanceof Error ? thrown : new Error(String(thrown))), + ) + + expect(error.message).toContain("must not contain credentials") + expect(error.message).not.toContain("hunter2") + expect(fetchSpy).not.toHaveBeenCalled() + }) + + // Regression guard for the security gate: every legitimate endpoint must keep + // working exactly as before the allowlist was introduced. + it.each([...ALLOWED_BASE_URLS])("fetches /models normally for allowed endpoint %s", async (allowedUrl) => { + const fetchSpy = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ data: [] }), + }) + globalThis.fetch = fetchSpy as unknown as typeof fetch + + await expect(getMimoModels(allowedUrl, "mock-key")).resolves.toEqual({}) + + expect(fetchSpy).toHaveBeenCalledWith(`${allowedUrl}/models`, expect.any(Object)) + }) + + it("keeps the fetcher allowlist in sync with the persisted settings schema", () => { + // The fetcher mirrors the zod literal union from + // packages/types/src/provider-settings/mimo.ts because the definition is + // not re-exported publicly. If the schema ever rejects one of the fetcher's + // URLs, the two sources have drifted and this test must fail. + const mimoBaseUrlField = providerSettingsSchema.shape.mimoBaseUrl + + for (const allowedUrl of ALLOWED_BASE_URLS) { + expect(mimoBaseUrlField.safeParse(allowedUrl).success).toBe(true) + } + expect(mimoBaseUrlField.safeParse("https://attacker.example/v1").success).toBe(false) + }) + it("skips null and non-object entries in the model list", async () => { globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, diff --git a/src/api/providers/fetchers/mimo.ts b/src/api/providers/fetchers/mimo.ts index 5f822fc5a9..3ba9a7daba 100644 --- a/src/api/providers/fetchers/mimo.ts +++ b/src/api/providers/fetchers/mimo.ts @@ -8,6 +8,33 @@ import { DEFAULT_HEADERS } from "../constants" // builds, so discovery excludes them from the catalog. const NON_TEXT_MODEL_ID = /-(asr|tts)(-|$)/i +// Network-boundary allowlist: the same four Xiaomi MiMo endpoints the persisted +// settings schema validates `mimoBaseUrl` against via a zod union of literals +// (packages/types/src/provider-settings/mimo.ts). The definition itself is not +// re-exported through the @roo-code/types public entry points, so the literals +// are mirrored here; __tests__/mimo.spec.ts cross-checks this exported set +// against providerSettingsSchema so the two sources cannot silently drift. The +// fetcher also receives unsaved webview values that bypass the schema entirely, +// so this exact-match gate is the only thing guaranteeing the bearer key never +// leaves a Xiaomi origin. Exported for that drift test only. +export const ALLOWED_BASE_URLS: ReadonlySet = new Set([ + "https://api.xiaomimimo.com/v1", + "https://token-plan-cn.xiaomimimo.com/v1", + "https://token-plan-sgp.xiaomimimo.com/v1", + "https://token-plan-ams.xiaomimimo.com/v1", +]) + +// True when the authority component carries `user[:pass]@` credentials. Input +// without a `scheme://` prefix is treated as bare authority so credential-like +// strings cannot slip past the userinfo check into the allowlist rejection +// message below. The raw URL is only echoed for credential-free rejections. +function containsUserinfo(rawUrl: string): boolean { + const schemeEnd = rawUrl.indexOf("://") + const rest = schemeEnd === -1 ? rawUrl : rawUrl.slice(schemeEnd + 3) + const authority = rest.split(/[/?#]/)[0] + return authority.includes("@") +} + /** * Fetches available models from the Xiaomi MiMo API and merges them with known specs. * @@ -27,14 +54,22 @@ export async function getMimoModels( // The base URL from settings already includes /v1 (e.g. https://token-plan-sgp.xiaomimimo.com/v1), // so we keep it as-is and append /models directly. const base = (baseUrl || "https://token-plan-sgp.xiaomimimo.com/v1").replace(/\/+$/, "") - const url = new URL(`${base}/models`) - // The settings schema only allows https:// endpoints, but this fetcher also - // receives unsaved webview values — enforce the contract at the boundary so - // the bearer key is never sent over plaintext HTTP. - if (url.protocol !== "https:") { - throw new Error(`MiMo model fetch requires an https:// base URL (received "${url.protocol}")`) + // Reject embedded credentials first, then pin the request to the four allowed + // endpoints with an exact match. This subsumes the previous https-only guard + // (every allowed URL is https, so plaintext HTTP, arbitrary hosts, and path + // tricks all fail the allowlist) and runs before any header is built, so a + // partial or off-list request is never sent. + if (containsUserinfo(base)) { + throw new Error("MIMO/getMimoModels/001: MiMo model fetch rejected: base URL must not contain credentials.") } + if (!ALLOWED_BASE_URLS.has(base)) { + throw new Error( + `MIMO/getMimoModels/002: MiMo model fetch rejected: "${base}" is not an allowed Xiaomi MiMo endpoint.`, + ) + } + + const url = new URL(`${base}/models`) const headers: Record = { "Content-Type": "application/json", From 079f9d767040dd6dc2e875c226c27c2d71b9f98c Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Tue, 22 Sep 2026 16:43:02 +0900 Subject: [PATCH 05/46] test(webview-ui): guard MiMo router-only model selection in useSelectedModel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit Regression Evidence sub-claim 3b (verified): the two existing MiMo rows key on mimoDefaultModelId/null-catalog, so a regression of the merged catalog {...mimoModels, ...routerModels.mimo} to static-only would still pass both. This test selects a router-returned ID absent from the static catalog and asserts the selected model carries router metadata — the only guard for the merged-catalog wiring. Includes a premise assertion so the test fails loudly if the ID is ever added to the static catalog. --- .../hooks/__tests__/useSelectedModel.spec.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts index 2b7360ff06..0d8c2517d6 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts @@ -304,6 +304,42 @@ describe("useSelectedModel", () => { }, ) + it("mimo: selects a router-only model not present in the static catalog", () => { + // Regression guard: the merged catalog `{ ...mimoModels, ...routerModels.mimo }` must admit + // model IDs that exist ONLY in the router response. A static-only regression would validate + // the configured ID against `mimoModels`, miss it, and silently reset the selection to the + // static default — which the existing MiMo tests (both keyed on `mimoDefaultModelId`) cannot + // catch. This is the only possible guard for that regression. + const routerOnlyModelId = "mimo-router-only-preview" + const routerModelInfo: ModelInfo = { + maxTokens: 8192, + contextWindow: 262144, + supportsImages: false, + supportsPromptCache: true, + description: "Router-only MiMo model", + } + + // Guard the test premise: if this ID were ever added to the static catalog, the test would + // silently degrade into the "prefers router data" coverage and must fail loudly instead. + expect(mimoModels[routerOnlyModelId as keyof typeof mimoModels]).toBeUndefined() + + mockUseRouterModels.mockReturnValue( + createRouterModelsResult({ [providerIdentifiers.mimo]: { [routerOnlyModelId]: routerModelInfo } }), + ) + mockUseOpenRouterModelProviders.mockReturnValue(createOpenRouterModelProvidersResult({})) + + const { result } = renderHook( + () => useSelectedModel({ apiProvider: providerIdentifiers.mimo, apiModelId: routerOnlyModelId }), + { wrapper: createWrapper() }, + ) + + // The router-only ID survives validation instead of resetting to the static default. + expect(result.current.id).toBe(routerOnlyModelId) + expect(result.current.id).not.toBe(mimoDefaultModelId) + // The selection carries the ROUTER metadata, not a static fallback. + expect(result.current.info).toEqual(routerModelInfo) + }) + it("uses router data for Poe", () => { const modelInfo: ModelInfo = { contextWindow: 42_000, supportsPromptCache: false } mockUseRouterModels.mockReturnValue( From 0ef947234f165dc9fd6db783aaadbc67187db4d6 Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Tue, 22 Sep 2026 17:16:17 +0900 Subject: [PATCH 06/46] test(webview-ui): assert ModelPicker receives merged static+fetched MiMo catalog CodeRabbit Regression Evidence sub-claim 2 (verified): ApiOptions.tsx spreads routerModels.mimo into the picker catalog but every ApiOptions suite null-mocked ModelPicker, leaving that spread as the only untested wiring path for fetched MiMo models. Adds 3 tests in the sole suite that renders the real ModelPicker: merged static+router-only delivery, undefined router fallback, and empty-payload fallback. Mutation-verified: reverting the merge to static-only fails the router-only assertion. Also hoists the useRouterModels mock state holder and resets the organization allow-list leak from earlier suites. --- .../ApiOptions.provider-filtering.spec.tsx | 119 ++++++++++++++++-- 1 file changed, 108 insertions(+), 11 deletions(-) diff --git a/webview-ui/src/components/settings/__tests__/ApiOptions.provider-filtering.spec.tsx b/webview-ui/src/components/settings/__tests__/ApiOptions.provider-filtering.spec.tsx index 58e23511a7..20bb451a66 100644 --- a/webview-ui/src/components/settings/__tests__/ApiOptions.provider-filtering.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ApiOptions.provider-filtering.spec.tsx @@ -2,7 +2,13 @@ import { screen } from "@testing-library/react" import { renderWithExtensionState } from "@/utils/test-utils" -import { providerIdentifiers, type ProviderSettings, type OrganizationAllowList } from "@roo-code/types" +import { + providerIdentifiers, + mimoDefaultModelId, + type ModelInfo, + type ProviderSettings, + type OrganizationAllowList, +} from "@roo-code/types" import { useExtensionState } from "@src/context/ExtensionStateContext" import { useRouterModels } from "@src/components/ui/hooks/useRouterModels" @@ -11,13 +17,26 @@ import { useSelectedModel } from "@src/components/ui/hooks/useSelectedModel" import ApiOptions from "../ApiOptions" import { MODELS_BY_PROVIDER, PROVIDERS } from "../constants" -// Mock the extension state context +// Mock the extension state context. The hoisted state holder lets the MiMo suite +// reset the allow list to the unfiltered default without re-typing the full +// ExtensionStateContextType (previous suites pin mockReturnValue via `as any`, +// and vi.clearAllMocks() does not undo that). +const { useExtensionStateMock, setOrganizationAllowList } = vi.hoisted(() => { + const mock = vi.fn(() => ({ + organizationAllowList: undefined as OrganizationAllowList | undefined, + cloudIsAuthenticated: false, + })) + return { + useExtensionStateMock: mock, + setOrganizationAllowList: (list: OrganizationAllowList | undefined) => { + mock.mockReturnValue({ organizationAllowList: list, cloudIsAuthenticated: false }) + }, + } +}) + vi.mock("@src/context/ExtensionStateContext", () => ({ ExtensionStateContextProvider: ({ children }: any) => children, - useExtensionState: vi.fn(() => ({ - organizationAllowList: undefined, - cloudIsAuthenticated: false, - })), + useExtensionState: useExtensionStateMock, })) // Mock the translation hook @@ -34,12 +53,20 @@ vi.mock("@src/utils/vscode", () => ({ }, })) -// Mock the router models hook +// Mock the router models hook. The hoisted state holder lets tests drive `data` +// without re-typing the full react-query UseQueryResult at each call site. +const { useRouterModelsMock, setRouterModelsData } = vi.hoisted(() => { + const state = { data: undefined as Record> | undefined } + return { + useRouterModelsMock: vi.fn(() => ({ data: state.data, refetch: vi.fn() })), + setRouterModelsData: (data: typeof state.data) => { + state.data = data + }, + } +}) + vi.mock("@src/components/ui/hooks/useRouterModels", () => ({ - useRouterModels: vi.fn(() => ({ - data: null, - refetch: vi.fn(), - })), + useRouterModels: useRouterModelsMock, })) // Mock the selected model hook @@ -343,4 +370,74 @@ describe("ApiOptions Provider Filtering", () => { delete (MODELS_BY_PROVIDER as any).testEmptyProvider PROVIDERS.pop() }) + + describe("MiMo dynamic model catalog (ApiOptions -> ModelPicker wiring)", () => { + // Regression guard: the generic ModelPicker must receive the host-fetched router + // catalog for MiMo merged on top of the static MODELS_BY_PROVIDER fallback in + // ApiOptions. ModelPicker itself performs no router merging, so if the merge in + // ApiOptions regressed to static-only, router-only models newer than the shipped + // catalog would silently become unselectable and no other suite would catch it. + const routerOnlyMimoModelId = "mimo-v2.7-ultra" + + const routerOnlyMimoModelInfo: ModelInfo = { + maxTokens: 131_072, + contextWindow: 262_144, + supportsPromptCache: false, + } + + const renderWithMimoSelected = (routerData: Record> | undefined) => { + // Earlier allow-list suites leave a restrictive organizationAllowList on this + // mock (clearAllMocks keeps mockReturnValue); reset to the unfiltered default + // so the MiMo catalog reaches the picker in full. + setOrganizationAllowList(undefined) + setRouterModelsData(routerData) + vi.mocked(useSelectedModel).mockReturnValue({ + provider: providerIdentifiers.mimo, + id: mimoDefaultModelId, + info: undefined, + isLoading: false, + isError: false, + }) + + return renderWithProviders({ + ...defaultProps, + apiConfiguration: { + apiProvider: providerIdentifiers.mimo, + apiModelId: mimoDefaultModelId, + } as ProviderSettings, + }) + } + + afterEach(() => { + // Restore the default (no fetched models) for any suites that run later. + setRouterModelsData(undefined) + }) + + it("passes both static and router-fetched MiMo models to the generic ModelPicker", () => { + // The router payload contains ONLY a model id that does not exist in the + // shipped static mimo catalog; it can reach the picker solely via the merge. + renderWithMimoSelected({ [providerIdentifiers.mimo]: { [routerOnlyMimoModelId]: routerOnlyMimoModelInfo } }) + + // Static default from getStaticModelsForProvider(mimo) / MODELS_BY_PROVIDER. + expect(screen.getByTestId(`model-option-${mimoDefaultModelId}`)).toBeInTheDocument() + // Router-only model: present in the picker only if ApiOptions merged routerModels[mimo]. + expect(screen.getByTestId(`model-option-${routerOnlyMimoModelId}`)).toBeInTheDocument() + }) + + it("keeps the static MiMo catalog selectable when no router models are available", () => { + // useRouterModels data is undefined while the fetch is pending/failed; the + // spread of routerModels?.[mimo] must not crash or drop the static defaults. + renderWithMimoSelected(undefined) + + expect(screen.getByTestId(`model-option-${mimoDefaultModelId}`)).toBeInTheDocument() + expect(screen.queryByTestId(`model-option-${routerOnlyMimoModelId}`)).not.toBeInTheDocument() + }) + + it("keeps the static MiMo catalog selectable when the router catalog is empty", () => { + renderWithMimoSelected({}) + + expect(screen.getByTestId(`model-option-${mimoDefaultModelId}`)).toBeInTheDocument() + expect(screen.queryByTestId(`model-option-${routerOnlyMimoModelId}`)).not.toBeInTheDocument() + }) + }) }) From f719fbf88b8f81ba69bbcaa3fc974b86072f3930 Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Tue, 22 Sep 2026 17:21:51 +0900 Subject: [PATCH 07/46] test(webview-ui): add MiMo invalid model ID fallback case in useSelectedModel CodeRabbit Regression Evidence sub-claim 3a (verified): the PR changed the configured-ID validation branch for MiMo (merge-base passed IDs through unvalidated) but only the known-default and null-catalog rows were covered. Mirrors the existing moonshot invalid-ID row: a configured ID absent from both static and router catalogs falls back to mimoDefaultModelId with its metadata. Uses the spec's typed helpers instead of the precedent block's as any casts. --- .../hooks/__tests__/useSelectedModel.spec.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts index 0d8c2517d6..7f1abba2c5 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts @@ -1632,4 +1632,24 @@ describe("useSelectedModel", () => { expect(result.current.info).toEqual(moonshotModels["kimi-k2-turbo-preview"]) }) }) + + describe("mimo provider", () => { + beforeEach(() => { + mockUseRouterModels.mockReturnValue(createRouterModelsResult({ mimo: {} })) + mockUseOpenRouterModelProviders.mockReturnValue(createOpenRouterModelProvidersResult({})) + }) + + it("should fallback to default when model ID is not in static or router models", () => { + const apiConfiguration: ProviderSettings = { + apiProvider: providerIdentifiers.mimo, + apiModelId: "non-existent-model", + } + + const wrapper = createWrapper() + const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper }) + + expect(result.current.id).toBe(mimoDefaultModelId) + expect(result.current.info).toEqual(mimoModels[mimoDefaultModelId]) + }) + }) }) From 1f6a39b0a5b6071c12b74149760dd7dbd197d5ee Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Tue, 22 Sep 2026 17:27:31 +0900 Subject: [PATCH 08/46] test(mimo): cover modelCache dispatch wiring and url+key cache isolation CodeRabbit Regression Evidence sub-claim 1 (verified): modelCache.ts added MiMo to URL_SCOPED_PROVIDERS/KEY_SCOPED_PROVIDERS plus a providerIdentifiers.mimo dispatch case, but modelCache.spec.ts had zero MiMo coverage. Adds a dispatch test pinning getMimoModels(baseUrl, apiKey, {signal}) argument wiring (beyond the exhaustive-never compile guard) and a url+key-scoped cache-isolation test mirroring the NanoGPT pattern across both scoping dimensions. --- .../fetchers/__tests__/modelCache.spec.ts | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/src/api/providers/fetchers/__tests__/modelCache.spec.ts b/src/api/providers/fetchers/__tests__/modelCache.spec.ts index 3b95f32234..9f14ae0be5 100644 --- a/src/api/providers/fetchers/__tests__/modelCache.spec.ts +++ b/src/api/providers/fetchers/__tests__/modelCache.spec.ts @@ -47,6 +47,7 @@ vi.mock("../requesty") vi.mock("../kenari") vi.mock("../nanogpt") vi.mock("../moonshot") +vi.mock("../mimo") vi.mock("../zoo-gateway") // Mock ContextProxy with a simple static instance @@ -75,6 +76,7 @@ import { getRequestyModels } from "../requesty" import { getKenariModels } from "../kenari" import { getNanoGptModels } from "../nanogpt" import { getMoonshotModels } from "../moonshot" +import { getMimoModels } from "../mimo" import { getZooGatewayModels } from "../zoo-gateway" const mockGetLiteLLMModels = getLiteLLMModels as Mock @@ -83,6 +85,7 @@ const mockGetRequestyModels = getRequestyModels as Mock const mockGetNanoGptModels = getNanoGptModels as Mock const mockGetMoonshotModels = getMoonshotModels as Mock +const mockGetMimoModels = getMimoModels as Mock const mockGetZooGatewayModels = getZooGatewayModels as Mock const DUMMY_REQUESTY_KEY = "requesty-key-for-testing" @@ -263,6 +266,29 @@ describe("getModels with new GetModelsOptions", () => { expect(result).toEqual(mockModels) }) + it("calls getMimoModels with correct parameters and forwards the abort signal", async () => { + const mockModels = { + "mimo-v2-omni": { + maxTokens: 16384, + contextWindow: 262144, + supportsPromptCache: false, + description: "MiMo model via dynamic catalog endpoint", + }, + } + mockGetMimoModels.mockResolvedValue(mockModels) + + const result = await getModels({ + provider: providerIdentifiers.mimo, + apiKey: "mimo-test-key", + baseUrl: "https://api.mimo.example/v1", + }) + + expect(mockGetMimoModels).toHaveBeenCalledWith("https://api.mimo.example/v1", "mimo-test-key", { + signal: expect.any(AbortSignal), + }) + expect(result).toEqual(mockModels) + }) + it("validates exhaustive provider checking with unknown provider", async () => { // This test ensures TypeScript catches unknown providers at compile time // In practice, the discriminated union should prevent this at compile time @@ -1136,6 +1162,48 @@ describe("NanoGPT key-scoped cache isolation", () => { }) }) +describe("MiMo url+key-scoped cache isolation", () => { + // MiMo belongs to BOTH URL_SCOPED_PROVIDERS and KEY_SCOPED_PROVIDERS (modelCache.ts), + // so distinct base URLs and distinct API keys must never collapse into a shared cache + // identity. Mirrors the NanoGPT key-scoped isolation test above, extended across the + // url dimension that NanoGPT (key-scoped only) does not exercise. + const mimoModels = { + "mimo-v2-omni": { maxTokens: 16384, contextWindow: 262144, supportsPromptCache: false }, + } + + beforeEach(() => { + vi.clearAllMocks() + mockGetMimoModels.mockResolvedValue(mimoModels) + }) + + it("separates cache identities by base URL and API key without exposing raw keys", async () => { + const mockCache = vi.mocked(new (vi.mocked(NodeCache))()) + mockCache.get.mockReturnValue(undefined) + + await getModels({ provider: providerIdentifiers.mimo }) + await getModels({ provider: providerIdentifiers.mimo, baseUrl: "https://api.mimo.example/v1" }) + await getModels({ + provider: providerIdentifiers.mimo, + baseUrl: "https://api.mimo.example/v1", + apiKey: "mimo-key-a", + }) + await getModels({ + provider: providerIdentifiers.mimo, + baseUrl: "https://api.mimo.example/v1", + apiKey: "mimo-key-b", + }) + + const cacheKeys = mockCache.set.mock.calls.map(([key]) => key as string) + expect(new Set(cacheKeys).size).toBe(4) + // Bare provider fallback when neither URL nor key is set; url-only component when + // the key is absent (MiMo PAYG vs token-plan visibility differs per key). + expect(cacheKeys).toContain("mimo") + expect(cacheKeys).toContain("mimo:https://api.mimo.example/v1") + // Raw secrets must never appear in the on-disk-bound cache keys. + expect(cacheKeys.every((key) => !key.includes("mimo-key-a") && !key.includes("mimo-key-b"))).toBe(true) + }) +}) + describe("compound cache key derivation across scoping dimensions", () => { // Exercises every branch of getCacheKey via the public getModels() entry point. // litellm is url-scoped AND key-scoped; openrouter is neither, so it hits the bare From d06f287663300b5ea9b8c17667241aeb1023648c Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Tue, 22 Sep 2026 18:29:56 +0900 Subject: [PATCH 09/46] fix(mimo): reject non-allowlisted unsaved mimoBaseUrl in router models handler --- ...webviewMessageHandler.routerModels.spec.ts | 46 +++++++++++ src/core/webview/webviewMessageHandler.ts | 79 +++++++++++++------ 2 files changed, 100 insertions(+), 25 deletions(-) diff --git a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts index ccd0989efb..f352a1948f 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts @@ -737,6 +737,52 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { expect(response).toBeDefined() }) + it("rejects an unsaved MiMo base URL outside the Xiaomi allowlist without dispatching a fetch", async () => { + mockProvider.getState.mockResolvedValue({ + apiConfiguration: { + mimoApiKey: "stored-mimo-key", + mimoBaseUrl: "https://token-plan-sgp.xiaomimimo.com/v1", + }, + }) + + await webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.requestRouterModels, + values: { + mimoBaseUrl: "https://attacker.example", + }, + }) + + // The off-list unsaved URL must never reach modelCache: no fetch and no + // cache flush for MiMo is dispatched. + const mimoCalls = getModelsMock.mock.calls.filter((c) => c[0]?.provider === providerIdentifiers.mimo) + expect(mimoCalls.length).toBe(0) + const mimoFlushCalls = flushModelsMock.mock.calls.filter((c) => c[0]?.provider === providerIdentifiers.mimo) + expect(mimoFlushCalls.length).toBe(0) + + const errorCall = mockProvider.postMessageToWebview.mock.calls.find( + (call) => + call[0]?.type === RouterModelsMessageType.singleRouterModelFetchResponse && + call[0]?.values?.provider === providerIdentifiers.mimo, + ) + expect(errorCall).toBeDefined() + if (!errorCall) throw new Error("Expected MiMo failure response") + expect(errorCall[0].success).toBe(false) + expect(errorCall[0].error).toBe( + "MIMO/requestRouterModels/001: MiMo model fetch rejected: the provided base URL is not an allowed Xiaomi MiMo endpoint.", + ) + + // Aggregation for the remaining providers still posts, with MiMo empty. + const response = mockProvider.postMessageToWebview.mock.calls.find( + (call) => call[0]?.type === RouterModelsMessageType.routerModels, + ) + expect(response).toBeDefined() + if (!response) throw new Error("Expected routerModels response") + expect(response[0].routerModels.openrouter).toEqual({ + "openrouter/qwen2.5": { contextWindow: 32768, supportsPromptCache: false }, + }) + expect(response[0].routerModels.mimo).toEqual({}) + }) + it("posts a Moonshot provider error and keeps an empty aggregate entry when fetch fails", async () => { mockProvider.getState.mockResolvedValue({ apiConfiguration: { diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 6af0980177..e4db88a6ce 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -91,6 +91,7 @@ import { generateSystemPrompt } from "./generateSystemPrompt" import { resolveDefaultSaveUri, saveLastExportPath } from "../../utils/export" import { getCommand } from "../../utils/commands" import { getLMStudioModels } from "../../api/providers/fetchers/lmstudio" +import { ALLOWED_BASE_URLS } from "../../api/providers/fetchers/mimo" const ALLOWED_VSCODE_SETTINGS = new Set(["terminal.integrated.inheritEnv"]) @@ -1275,36 +1276,64 @@ export const webviewMessageHandler = async ( const mimoApiKey = message?.values?.mimoApiKey ?? apiConfiguration.mimoApiKey const mimoBaseUrl = message?.values?.mimoBaseUrl ?? apiConfiguration.mimoBaseUrl + // Unsaved form values bypass the settings-schema validation that pins + // stored mimoBaseUrl to the four allowed Xiaomi endpoints, so gate them + // on the fetcher's allowlist before they can carry the bearer key into + // modelCache. The exact match subsumes credential-bearing URLs (no + // allowlisted literal contains userinfo), and the raw value is never + // echoed since an unsaved one may embed credentials. + const unsavedMimoBaseUrl = message?.values?.mimoBaseUrl + const mimoBaseUrlRejected = + typeof unsavedMimoBaseUrl === "string" && + unsavedMimoBaseUrl !== "" && + !ALLOWED_BASE_URLS.has(unsavedMimoBaseUrl.replace(/\/+$/, "")) + if (mimoApiKey) { - const mimoOptions = { - provider: providerIdentifiers.mimo, - apiKey: mimoApiKey, - baseUrl: mimoBaseUrl, - } + if (mimoBaseUrlRejected) { + // Same surface as a failed refresh: post the MiMo failure + // response and skip the candidate so no fetch is dispatched, + // while router aggregation for other providers continues. + const errorMessage = + "MIMO/requestRouterModels/001: MiMo model fetch rejected: the provided base URL is not an allowed Xiaomi MiMo endpoint." + console.error(`Error refreshing models for ${providerIdentifiers.mimo}: ${errorMessage}`) - if (message?.values?.mimoApiKey || message?.values?.mimoBaseUrl) { - // A refresh failure (bad key/endpoint) must not abort the - // aggregate fetch — surface the normal MiMo failure response - // and continue so routerModels is still posted. - try { - await flushModels(mimoOptions, true) - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - console.error(`Error refreshing models for ${providerIdentifiers.mimo}:`, error) + await provider.postMessageToWebview({ + type: RouterModelsMessageType.singleRouterModelFetchResponse, + success: false, + error: errorMessage, + values: { provider: providerIdentifiers.mimo }, + }) + } else { + const mimoOptions = { + provider: providerIdentifiers.mimo, + apiKey: mimoApiKey, + baseUrl: mimoBaseUrl, + } - await provider.postMessageToWebview({ - type: RouterModelsMessageType.singleRouterModelFetchResponse, - success: false, - error: errorMessage, - values: { provider: providerIdentifiers.mimo }, - }) + if (message?.values?.mimoApiKey || message?.values?.mimoBaseUrl) { + // A refresh failure (bad key/endpoint) must not abort the + // aggregate fetch — surface the normal MiMo failure response + // and continue so routerModels is still posted. + try { + await flushModels(mimoOptions, true) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + console.error(`Error refreshing models for ${providerIdentifiers.mimo}:`, error) + + await provider.postMessageToWebview({ + type: RouterModelsMessageType.singleRouterModelFetchResponse, + success: false, + error: errorMessage, + values: { provider: providerIdentifiers.mimo }, + }) + } } - } - candidates.push({ - key: providerIdentifiers.mimo, - options: mimoOptions, - }) + candidates.push({ + key: providerIdentifiers.mimo, + options: mimoOptions, + }) + } } // Opencode Go's /models endpoint is public — it returns the full model list with no From d7a6bc99630dc2b603a8806dc3b85fc073e05415 Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Thu, 24 Sep 2026 00:47:27 +0900 Subject: [PATCH 10/46] fix(webview-ui): resolve MiMo specs from static catalog while router models are unavailable What: useSelectedModel resolves deepseek/moonshot/mimo from the shipped static catalog when router models are absent (still loading, missing provider entry in the single-provider host response, or query-layer failure), mirroring the existing kimiCode fallback. Why: the extension host answers single-provider router requests with {} when a credential-gated fetch candidate is skipped, so routerModels.data carries no mimo entry and the hook stayed not-ready with info undefined. TaskHeader renders contextWindow || 1, so the task header showed "108.1k / 1" instead of the real 1M MiMo context window. Impact: the task header context window and reserved-output display are correct for MiMo before the router catalog arrives; once router data lands, getSelectedModel revalidates against the merged catalog exactly as before. --- .../hooks/__tests__/useSelectedModel.spec.ts | 46 +++++++++++++ .../components/ui/hooks/useSelectedModel.ts | 64 ++++++++++++++++++- 2 files changed, 109 insertions(+), 1 deletion(-) diff --git a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts index 7f1abba2c5..21bc5b6498 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts @@ -695,6 +695,52 @@ describe("useSelectedModel", () => { }) }) + describe("static-catalog dynamic providers while router models are unavailable", () => { + beforeEach(() => { + mockUseOpenRouterModelProviders.mockReturnValue(createOpenRouterModelProvidersResult({})) + }) + + // The extension host answers single-provider router requests with `{}` + // when the provider has no fetch candidate (e.g. no API key saved yet), + // so the response carries no entry for that provider and the hook stays + // not-ready. The selection must still resolve from the shipped static + // catalog: TaskHeader renders `contextWindow || 1` and showed "… / 1" + // while `info` was undefined. + it.each([ + [providerIdentifiers.deepseek, deepSeekDefaultModelId, deepSeekModels[deepSeekDefaultModelId]], + [providerIdentifiers.moonshot, moonshotDefaultModelId, moonshotModels[moonshotDefaultModelId]], + [providerIdentifiers.mimo, mimoDefaultModelId, mimoModels[mimoDefaultModelId]], + ])( + "falls back to the static %s catalog when the router response has no entry", + (provider, expectedId, expectedInfo) => { + mockUseRouterModels.mockReturnValue(createRouterModelsResult({})) + + const wrapper = createWrapper() + const { result } = renderHook(() => useSelectedModel({ apiProvider: provider }), { wrapper }) + + expect(result.current.id).toBe(expectedId) + expect(result.current.info).toEqual(expectedInfo) + }, + ) + + it("keeps the configured MiMo V2.6 model specs while router models are still loading", () => { + mockUseRouterModels.mockReturnValue(createRouterModelsResult(undefined, { isLoading: true })) + + const apiConfiguration: ProviderSettings = { + apiProvider: providerIdentifiers.mimo, + apiModelId: "mimo-v2.6-flash", + } + + const wrapper = createWrapper() + const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper }) + + expect(result.current.id).toBe("mimo-v2.6-flash") + expect(result.current.info).toEqual(mimoModels["mimo-v2.6-flash"]) + expect(result.current.info?.contextWindow).toBe(1_048_576) + expect(result.current.info?.maxTokens).toBe(131_072) + }) + }) + describe("default behavior", () => { it("should return OpenRouter default when no configuration is provided", () => { mockUseRouterModels.mockReturnValue({ diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index 822a8d8a2f..3022459793 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -7,9 +7,12 @@ import { anthropicModels, bedrockModels, deepSeekModels, + deepSeekDefaultModelId, moonshotModels, + moonshotDefaultModelId, minimaxModels, mimoModels, + mimoDefaultModelId, geminiModels, mistralModels, openAiModelInfoSaneDefaults, @@ -57,6 +60,60 @@ function getValidatedModelId( return configuredId && availableModels?.[configuredId] ? configuredId : defaultModelId } +/** + * Dynamic providers whose shipped static catalog carries complete specs + * (context window, max tokens). For these, resolving from the static catalog + * while router models are unavailable is safe: the ready-path merge in + * getSelectedModel already prefers router data and falls back to the same + * static entries. + */ +type StaticCatalogDynamicProvider = + | typeof providerIdentifiers.deepseek + | typeof providerIdentifiers.moonshot + | typeof providerIdentifiers.mimo + +const isStaticCatalogDynamicProvider = (provider: ProviderName): provider is StaticCatalogDynamicProvider => + provider === providerIdentifiers.deepseek || + provider === providerIdentifiers.moonshot || + provider === providerIdentifiers.mimo + +/** + * Resolves a selection from the shipped static catalog while router models are + * unavailable (first load, missing API key, or a query-layer fetch failure). + * Without this fallback these providers returned `info: undefined` until the + * router response arrived, and capability-driven UI such as TaskHeader's + * context window rendered a bogus window size of 1 (`contextWindow || 1`). + * Once router data arrives, getSelectedModel revalidates against the merged + * catalog and takes over. + */ +function getStaticCatalogSelection( + provider: StaticCatalogDynamicProvider, + apiConfiguration: ProviderSettings, +): { id: string; info: ModelInfo | undefined } { + const configuredId = apiConfiguration.apiModelId + switch (provider) { + case providerIdentifiers.deepseek: { + const id = + configuredId && deepSeekModels[configuredId as keyof typeof deepSeekModels] + ? configuredId + : deepSeekDefaultModelId + return { id, info: deepSeekModels[id as keyof typeof deepSeekModels] } + } + case providerIdentifiers.moonshot: { + const id = + configuredId && moonshotModels[configuredId as keyof typeof moonshotModels] + ? configuredId + : moonshotDefaultModelId + return { id, info: moonshotModels[id as keyof typeof moonshotModels] } + } + case providerIdentifiers.mimo: { + const id = + configuredId && mimoModels[configuredId as keyof typeof mimoModels] ? configuredId : mimoDefaultModelId + return { id, info: mimoModels[id as keyof typeof mimoModels] } + } + } +} + /** * Resolves the model currently selected for the active API provider. * @@ -131,7 +188,12 @@ export const useSelectedModel = (apiConfiguration?: ProviderSettings) => { id: apiConfiguration.apiModelId || getProviderDefaultModelId(providerIdentifiers.kimiCode), info: kimiCodeDefaultModelInfo, } - : { id: getProviderDefaultModelId(activeProvider ?? providerIdentifiers.openrouter), info: undefined } + : apiConfiguration && activeProvider && isStaticCatalogDynamicProvider(activeProvider) + ? getStaticCatalogSelection(activeProvider, apiConfiguration) + : { + id: getProviderDefaultModelId(activeProvider ?? providerIdentifiers.openrouter), + info: undefined, + } return { provider, From 7d612f631c7acdacc1a6767bb84bb1fce4c36603 Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Thu, 24 Sep 2026 01:03:28 +0900 Subject: [PATCH 11/46] test(mimo): cover no-Authorization fetch and filtered MiMo router request What: add a fetcher test asserting getMimoModels omits the Authorization header when no apiKey is provided, and a webviewMessageHandler test for a single-provider requestRouterModels call (values.provider: mimo) with stored credentials, asserting only MiMo is fetched and the filtered routerModels response carries the mimo entry. Why: CodeRabbit pre-merge Regression Evidence check flagged both gaps; the production useRouterModels path for a selected MiMo provider issues the filtered request, and the optional apiKey branch had no negative test. Impact: no behavior change, coverage only. eslint suppression counts unchanged (no new any). --- .../providers/fetchers/__tests__/mimo.spec.ts | 13 ++++++ ...webviewMessageHandler.routerModels.spec.ts | 40 +++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/src/api/providers/fetchers/__tests__/mimo.spec.ts b/src/api/providers/fetchers/__tests__/mimo.spec.ts index 28df8ba545..18eac7497c 100644 --- a/src/api/providers/fetchers/__tests__/mimo.spec.ts +++ b/src/api/providers/fetchers/__tests__/mimo.spec.ts @@ -132,6 +132,19 @@ describe("getMimoModels", () => { ) }) + it("omits the Authorization header when no apiKey is provided", async () => { + const fetchSpy = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ data: [] }), + }) + globalThis.fetch = fetchSpy as unknown as typeof fetch + + await getMimoModels("https://token-plan-sgp.xiaomimimo.com/v1") + + const fetchInit = fetchSpy.mock.calls[0]?.[1] as RequestInit + expect(fetchInit.headers).not.toHaveProperty("Authorization") + }) + it("mixes known and unknown models in same response", async () => { globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, diff --git a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts index f352a1948f..4f830d8fba 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts @@ -636,6 +636,46 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { }) }) + it("fetches only MiMo for a single-provider request with stored credentials", async () => { + mockProvider.getState.mockResolvedValue({ + apiConfiguration: { + mimoApiKey: "stored-mimo-key", + mimoBaseUrl: "https://token-plan-sgp.xiaomimimo.com/v1", + }, + }) + + getModelsMock.mockImplementation(async (options?: { provider?: string }) => { + if (options?.provider === providerIdentifiers.mimo) { + return { "mimo-v2.6-pro": { contextWindow: 1_048_576, supportsPromptCache: false } } + } + return {} + }) + + await webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.requestRouterModels, + values: { provider: providerIdentifiers.mimo }, + }) + + const mimoCalls = getModelsMock.mock.calls.filter((c) => c[0]?.provider === providerIdentifiers.mimo) + expect(mimoCalls).toHaveLength(1) + expect(mimoCalls[0][0]).toEqual({ + provider: providerIdentifiers.mimo, + apiKey: "stored-mimo-key", + baseUrl: "https://token-plan-sgp.xiaomimimo.com/v1", + }) + + const response = mockProvider.postMessageToWebview.mock.calls.find( + (call) => call[0]?.type === RouterModelsMessageType.routerModels, + ) + expect(response).toBeDefined() + if (!response) throw new Error("Expected routerModels response") + // Filtered responses carry only the requested provider's entry. + expect(Object.keys(response[0].routerModels)).toEqual([providerIdentifiers.mimo]) + expect(response[0].routerModels.mimo).toEqual({ + "mimo-v2.6-pro": { contextWindow: 1_048_576, supportsPromptCache: false }, + }) + }) + it("flushes MiMo cache when explicit credentials are provided via message values", async () => { getModelsMock.mockResolvedValue({ "mimo-v2.6-pro": { contextWindow: 1_048_576, supportsPromptCache: false }, From 041cd8254ce298afebc22daabd20d65dcef0cac0 Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Thu, 24 Sep 2026 01:46:25 +0900 Subject: [PATCH 12/46] test(mimo): address CodeRabbit regression-evidence follow-ups What: add an independent literal allowlist test exercising /models for each of the four persisted Xiaomi endpoints, a useSelectedModel test holding deepseek/moonshot/mimo static specs when the router query has errored (isError), and assert the filtered single-provider MiMo request dispatches no other provider fetches. Why: CodeRabbit's review of 7d612f631 kept the Regression Evidence pre-merge check at warning: the error-state path of the new static fallback had no MiMo coverage, and the positive allowlist test was tautological (it iterated ALLOWED_BASE_URLS itself). Impact: coverage only, no behavior change. --- .../providers/fetchers/__tests__/mimo.spec.ts | 28 +++++++++++++++++++ ...webviewMessageHandler.routerModels.spec.ts | 2 ++ .../hooks/__tests__/useSelectedModel.spec.ts | 20 +++++++++++++ 3 files changed, 50 insertions(+) diff --git a/src/api/providers/fetchers/__tests__/mimo.spec.ts b/src/api/providers/fetchers/__tests__/mimo.spec.ts index 18eac7497c..f4e38efd75 100644 --- a/src/api/providers/fetchers/__tests__/mimo.spec.ts +++ b/src/api/providers/fetchers/__tests__/mimo.spec.ts @@ -241,6 +241,34 @@ describe("getMimoModels", () => { expect(fetchSpy).toHaveBeenCalledWith(`${allowedUrl}/models`, expect.any(Object)) }) + it("accepts every independently defined persisted Xiaomi endpoint", async () => { + // Independent literal list (NOT iterated from ALLOWED_BASE_URLS) so the + // test fails if a legitimate endpoint is ever removed from the allowlist. + const expectedAllowedUrls = [ + "https://api.xiaomimimo.com/v1", + "https://token-plan-cn.xiaomimimo.com/v1", + "https://token-plan-sgp.xiaomimimo.com/v1", + "https://token-plan-ams.xiaomimimo.com/v1", + ] + + expect([...ALLOWED_BASE_URLS].sort()).toEqual([...expectedAllowedUrls].sort()) + + const fetchSpy = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ data: [] }), + }) + globalThis.fetch = fetchSpy as unknown as typeof fetch + + for (const baseUrl of expectedAllowedUrls) { + await expect(getMimoModels(baseUrl, "mock-key")).resolves.toEqual({}) + } + + expect(fetchSpy).toHaveBeenCalledTimes(expectedAllowedUrls.length) + for (const baseUrl of expectedAllowedUrls) { + expect(fetchSpy).toHaveBeenCalledWith(`${baseUrl}/models`, expect.any(Object)) + } + }) + it("keeps the fetcher allowlist in sync with the persisted settings schema", () => { // The fetcher mirrors the zod literal union from // packages/types/src/provider-settings/mimo.ts because the definition is diff --git a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts index 4f830d8fba..94583d0334 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts @@ -658,6 +658,8 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { const mimoCalls = getModelsMock.mock.calls.filter((c) => c[0]?.provider === providerIdentifiers.mimo) expect(mimoCalls).toHaveLength(1) + // The single-provider filter must not dispatch fetches for other providers. + expect(getModelsMock).toHaveBeenCalledTimes(1) expect(mimoCalls[0][0]).toEqual({ provider: providerIdentifiers.mimo, apiKey: "stored-mimo-key", diff --git a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts index 21bc5b6498..d1e50c0f20 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts @@ -739,6 +739,26 @@ describe("useSelectedModel", () => { expect(result.current.info?.contextWindow).toBe(1_048_576) expect(result.current.info?.maxTokens).toBe(131_072) }) + + it.each([providerIdentifiers.deepseek, providerIdentifiers.moonshot, providerIdentifiers.mimo])( + "keeps the static %s catalog when the router query has errored", + (provider) => { + mockUseRouterModels.mockReturnValue(createRouterModelsResult(undefined, { isError: true })) + + const wrapper = createWrapper() + const { result } = renderHook(() => useSelectedModel({ apiProvider: provider }), { wrapper }) + + const expectedId = + provider === providerIdentifiers.deepseek + ? deepSeekDefaultModelId + : provider === providerIdentifiers.moonshot + ? moonshotDefaultModelId + : mimoDefaultModelId + expect(result.current.id).toBe(expectedId) + expect(result.current.info).toBeDefined() + expect(result.current.info?.contextWindow).toBeGreaterThan(0) + }, + ) }) describe("default behavior", () => { From 5d0be3e9685f7299afafbee47879b8bda3ec8408 Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Thu, 24 Sep 2026 03:11:22 +0900 Subject: [PATCH 13/46] test(webview-ui): assert isError in the static-catalog error-state test What: extend the deepseek/moonshot/mimo router-error useSelectedModel test to assert result.current.isError is true alongside the existing static ID/spec assertions. Why: CodeRabbit review of 041cd8254 requested the error flag assertion so the test proves the hook surfaces the router query failure while the static fallback keeps the selection usable. --- .../src/components/ui/hooks/__tests__/useSelectedModel.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts index d1e50c0f20..220a2a7fcb 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts @@ -754,6 +754,7 @@ describe("useSelectedModel", () => { : provider === providerIdentifiers.moonshot ? moonshotDefaultModelId : mimoDefaultModelId + expect(result.current.isError).toBe(true) expect(result.current.id).toBe(expectedId) expect(result.current.info).toBeDefined() expect(result.current.info?.contextWindow).toBeGreaterThan(0) From d0cda7a70615697013c5167a0733c6387b23c63a Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Thu, 24 Sep 2026 03:55:05 +0900 Subject: [PATCH 14/46] fix(mimo,webview-ui): stop echoing rejected URLs and honor query abort signals What: make the MiMo fetcher allowlist rejection a constant error string instead of interpolating the raw base URL (query strings and fragments can carry secrets that the authority-only userinfo check cannot see), and teach fetchRouterModels to accept an AbortSignal from React Query so the window listener and 10s timeout are removed when the consuming component unmounts or the query is cancelled. Why: CodeRabbit pre-merge checks on 5d0be3e96 flagged the URL echo as a Security Boundaries error (a persisted malformed value could leak an api_key through the thrown error, logs, or the webview error response) and the missing cancellation as a Lifecycle Resource Cleanup warning (mount/dispose cycles could retain listeners, timers, and duplicate catalog requests). Impact: rejection errors never contain the input URL; aborted router model queries clean up immediately and stale responses can no longer resolve a disposed query. New regression tests cover query/fragment secret non-echo and abort/timeout behavior. --- .../providers/fetchers/__tests__/mimo.spec.ts | 21 +++++ src/api/providers/fetchers/mimo.ts | 9 +- .../hooks/__tests__/useRouterModels.spec.ts | 89 +++++++++++++++++++ .../components/ui/hooks/useRouterModels.ts | 36 ++++++-- 4 files changed, 143 insertions(+), 12 deletions(-) create mode 100644 webview-ui/src/components/ui/hooks/__tests__/useRouterModels.spec.ts diff --git a/src/api/providers/fetchers/__tests__/mimo.spec.ts b/src/api/providers/fetchers/__tests__/mimo.spec.ts index f4e38efd75..3f5c567fe0 100644 --- a/src/api/providers/fetchers/__tests__/mimo.spec.ts +++ b/src/api/providers/fetchers/__tests__/mimo.spec.ts @@ -200,6 +200,27 @@ describe("getMimoModels", () => { expect(fetchSpy).not.toHaveBeenCalled() }) + it.each([ + ["query string", "https://token-plan-sgp.xiaomimimo.com/v1?api_key=QUERYSECRET"], + ["fragment", "https://token-plan-sgp.xiaomimimo.com/v1#api_key=FRAGMENTSECRET"], + ])("never echoes a secret carried in a URL %s", async (_label, maliciousUrl) => { + const fetchSpy = vi.fn() + globalThis.fetch = fetchSpy as unknown as typeof fetch + + const secret = maliciousUrl.split("=")[1] + let thrown: unknown + try { + await getMimoModels(maliciousUrl, "key") + } catch (error) { + thrown = error + } + + expect(thrown).toBeInstanceOf(Error) + expect((thrown as Error).message).toContain("not an allowed Xiaomi MiMo endpoint") + expect((thrown as Error).message).not.toContain(secret) + expect(fetchSpy).not.toHaveBeenCalled() + }) + it("rejects embedded credentials on an allowed endpoint and never echoes the secret", async () => { const fetchSpy = vi.fn() globalThis.fetch = fetchSpy as unknown as typeof fetch diff --git a/src/api/providers/fetchers/mimo.ts b/src/api/providers/fetchers/mimo.ts index 3ba9a7daba..df31baf6cc 100644 --- a/src/api/providers/fetchers/mimo.ts +++ b/src/api/providers/fetchers/mimo.ts @@ -27,7 +27,8 @@ export const ALLOWED_BASE_URLS: ReadonlySet = new Set([ // True when the authority component carries `user[:pass]@` credentials. Input // without a `scheme://` prefix is treated as bare authority so credential-like // strings cannot slip past the userinfo check into the allowlist rejection -// message below. The raw URL is only echoed for credential-free rejections. +// below. Rejection messages never echo the input: query strings and fragments +// can also carry secrets and are not visible to this authority-only check. function containsUserinfo(rawUrl: string): boolean { const schemeEnd = rawUrl.indexOf("://") const rest = schemeEnd === -1 ? rawUrl : rawUrl.slice(schemeEnd + 3) @@ -59,13 +60,15 @@ export async function getMimoModels( // endpoints with an exact match. This subsumes the previous https-only guard // (every allowed URL is https, so plaintext HTTP, arbitrary hosts, and path // tricks all fail the allowlist) and runs before any header is built, so a - // partial or off-list request is never sent. + // partial or off-list request is never sent. Rejection errors are constant + // strings: the raw URL is never echoed because query strings and fragments + // can carry secrets too. if (containsUserinfo(base)) { throw new Error("MIMO/getMimoModels/001: MiMo model fetch rejected: base URL must not contain credentials.") } if (!ALLOWED_BASE_URLS.has(base)) { throw new Error( - `MIMO/getMimoModels/002: MiMo model fetch rejected: "${base}" is not an allowed Xiaomi MiMo endpoint.`, + "MIMO/getMimoModels/002: MiMo model fetch rejected: base URL is not an allowed Xiaomi MiMo endpoint.", ) } diff --git a/webview-ui/src/components/ui/hooks/__tests__/useRouterModels.spec.ts b/webview-ui/src/components/ui/hooks/__tests__/useRouterModels.spec.ts new file mode 100644 index 0000000000..f5743813c8 --- /dev/null +++ b/webview-ui/src/components/ui/hooks/__tests__/useRouterModels.spec.ts @@ -0,0 +1,89 @@ +// npx vitest src/components/ui/hooks/__tests__/useRouterModels.spec.ts + +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +import { providerIdentifiers, RouterModelsMessageType } from "@roo-code/types" + +import { vscode } from "@src/utils/vscode" + +import { fetchRouterModels } from "../useRouterModels" + +const postResponse = (provider: string | undefined, routerModels: Record) => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: RouterModelsMessageType.routerModels, + routerModels, + values: provider ? { provider } : undefined, + }, + }), + ) +} + +describe("fetchRouterModels", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("posts a provider-filtered request and resolves on the matching response", async () => { + const promise = fetchRouterModels(providerIdentifiers.mimo) + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: RouterModelsMessageType.requestRouterModels, + values: { provider: providerIdentifiers.mimo }, + }) + + postResponse(providerIdentifiers.mimo, { mimo: {} }) + await expect(promise).resolves.toEqual({ mimo: {} }) + }) + + it("ignores responses for other providers", async () => { + const promise = fetchRouterModels(providerIdentifiers.mimo) + + postResponse("openrouter", { openrouter: {} }) + postResponse(undefined, {}) + postResponse(providerIdentifiers.mimo, { mimo: { "mimo-v2.6-pro": {} } }) + + await expect(promise).resolves.toEqual({ mimo: { "mimo-v2.6-pro": {} } }) + }) + + it("rejects with an abort error when the signal aborts mid-flight", async () => { + const controller = new AbortController() + const promise = fetchRouterModels(providerIdentifiers.mimo, controller.signal) + + controller.abort() + + await expect(promise).rejects.toMatchObject({ name: "AbortError" }) + + // A late response must not reach the removed listener or resolve anything. + postResponse(providerIdentifiers.mimo, { mimo: {} }) + await Promise.resolve() + }) + + it("rejects immediately when the signal is already aborted, without posting", async () => { + const controller = new AbortController() + controller.abort() + + await expect(fetchRouterModels(providerIdentifiers.mimo, controller.signal)).rejects.toMatchObject({ + name: "AbortError", + }) + expect(vscode.postMessage).not.toHaveBeenCalled() + }) + + it("times out when no response arrives", async () => { + vi.useFakeTimers() + try { + const promise = fetchRouterModels(providerIdentifiers.mimo) + + vi.advanceTimersByTime(10_000) + + await expect(promise).rejects.toThrow("Router models request timed out") + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/webview-ui/src/components/ui/hooks/useRouterModels.ts b/webview-ui/src/components/ui/hooks/useRouterModels.ts index a7d6b36725..7108d4ded0 100644 --- a/webview-ui/src/components/ui/hooks/useRouterModels.ts +++ b/webview-ui/src/components/ui/hooks/useRouterModels.ts @@ -14,14 +14,8 @@ type UseRouterModelsOptions = { enabled?: boolean // gate fetching entirely } -export const fetchRouterModels = async (provider?: string) => +export const fetchRouterModels = async (provider?: string, signal?: AbortSignal) => new Promise((resolve, reject) => { - const cleanup = () => { - if (typeof window !== "undefined") { - window.removeEventListener("message", handler) - } - } - const timeout = setTimeout(() => { cleanup() reject(new Error("Router models request timed out")) @@ -39,7 +33,6 @@ export const fetchRouterModels = async (provider?: string) => return } - clearTimeout(timeout) cleanup() if (message.routerModels) { @@ -50,6 +43,31 @@ export const fetchRouterModels = async (provider?: string) => } } + const cleanup = () => { + clearTimeout(timeout) + if (typeof window !== "undefined") { + window.removeEventListener("message", handler) + } + signal?.removeEventListener("abort", onAbort) + } + + const onAbort = () => { + cleanup() + reject(new DOMException("Router models request aborted", "AbortError")) + } + + // React Query cancels the queryFn when the consuming component unmounts or + // the query is removed. Honour that signal so the window listener and the + // timeout do not outlive the request (and a stale response can never + // resolve a disposed query). + if (signal) { + if (signal.aborted) { + onAbort() + return + } + signal.addEventListener("abort", onAbort, { once: true }) + } + window.addEventListener("message", handler) if (provider) { vscode.postMessage({ type: RouterModelsMessageType.requestRouterModels, values: { provider } }) @@ -62,7 +80,7 @@ export const useRouterModels = (opts: UseRouterModelsOptions = {}) => { const provider = opts.provider || undefined return useQuery({ queryKey: [RouterModelsMessageType.routerModels, provider || allRouterModelsProvider], - queryFn: () => fetchRouterModels(provider), + queryFn: ({ signal }) => fetchRouterModels(provider, signal), enabled: opts.enabled !== false, }) } From bc077c290650a66fdbcf411deeab13119c2c97c2 Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Thu, 24 Sep 2026 04:40:12 +0900 Subject: [PATCH 15/46] fix(webview-ui): correlate routerModels responses by request ID What: fetchRouterModels now generates a unique request ID per call, sends it with the requestRouterModels message, and resolves only on the routerModels response echoing that ID; the extension host echoes the ID for both filtered and aggregate responses. Abort cleanup from the previous commit is preserved. Why: CodeRabbit review of d0cda7a70 flagged provider-only response matching: a remount can re-issue the same provider request while a stale response to the aborted request is still in flight, and the stale response could resolve the replacement query. It also asked the abort test to prove cleanup directly instead of inferring it from a late-response no-op. Impact: stale responses can never resolve a different pending request, even for identical provider filters. The abort spec now asserts the exact registered message handler is removed (add/removeEventListener spy reference equality) and the pending timer is cleared (vi.getTimerCount), plus concurrent same-provider disambiguation. --- src/core/webview/webviewMessageHandler.ts | 8 +- .../hooks/__tests__/useRouterModels.spec.ts | 90 ++++++++++++++----- .../components/ui/hooks/useRouterModels.ts | 23 +++-- 3 files changed, 89 insertions(+), 32 deletions(-) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index e4db88a6ce..d87065cc4a 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1443,7 +1443,13 @@ export const webviewMessageHandler = async ( await provider.postMessageToWebview({ type: RouterModelsMessageType.routerModels, routerModels, - values: providerFilter ? { provider: requestedProvider } : undefined, + // Echo the webview's request ID so fetchRouterModels can correlate + // the response to the exact pending request (a remount may re-issue + // the same provider request while a stale response is in flight). + values: { + requestId: message?.values?.requestId, + ...(providerFilter ? { provider: requestedProvider } : {}), + }, }) break } diff --git a/webview-ui/src/components/ui/hooks/__tests__/useRouterModels.spec.ts b/webview-ui/src/components/ui/hooks/__tests__/useRouterModels.spec.ts index f5743813c8..9b8d222f4c 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useRouterModels.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useRouterModels.spec.ts @@ -12,56 +12,102 @@ import { vscode } from "@src/utils/vscode" import { fetchRouterModels } from "../useRouterModels" -const postResponse = (provider: string | undefined, routerModels: Record) => { +const postMessageMock = vi.mocked(vscode.postMessage) + +const postResponse = (requestId: string | undefined, routerModels: Record) => { window.dispatchEvent( new MessageEvent("message", { data: { type: RouterModelsMessageType.routerModels, routerModels, - values: provider ? { provider } : undefined, + values: { requestId }, }, }), ) } +const startRequest = (provider?: string, signal?: AbortSignal) => { + const promise = fetchRouterModels(provider, signal) + const requestId = postMessageMock.mock.calls.at(-1)?.[0]?.values?.requestId as string + return { promise, requestId } +} + describe("fetchRouterModels", () => { beforeEach(() => { vi.clearAllMocks() }) - it("posts a provider-filtered request and resolves on the matching response", async () => { - const promise = fetchRouterModels(providerIdentifiers.mimo) + it("posts a provider-filtered request with a request ID and resolves on the matching response", async () => { + const { promise, requestId } = startRequest(providerIdentifiers.mimo) - expect(vscode.postMessage).toHaveBeenCalledWith({ + expect(requestId).toBeTruthy() + expect(postMessageMock).toHaveBeenCalledWith({ type: RouterModelsMessageType.requestRouterModels, - values: { provider: providerIdentifiers.mimo }, + values: { requestId, provider: providerIdentifiers.mimo }, }) - postResponse(providerIdentifiers.mimo, { mimo: {} }) + postResponse(requestId, { mimo: {} }) await expect(promise).resolves.toEqual({ mimo: {} }) }) - it("ignores responses for other providers", async () => { - const promise = fetchRouterModels(providerIdentifiers.mimo) + it("posts an aggregate request without a provider filter", async () => { + const { promise, requestId } = startRequest() - postResponse("openrouter", { openrouter: {} }) - postResponse(undefined, {}) - postResponse(providerIdentifiers.mimo, { mimo: { "mimo-v2.6-pro": {} } }) + expect(postMessageMock).toHaveBeenCalledWith({ + type: RouterModelsMessageType.requestRouterModels, + values: { requestId }, + }) - await expect(promise).resolves.toEqual({ mimo: { "mimo-v2.6-pro": {} } }) + postResponse(requestId, {}) + await expect(promise).resolves.toEqual({}) }) - it("rejects with an abort error when the signal aborts mid-flight", async () => { - const controller = new AbortController() - const promise = fetchRouterModels(providerIdentifiers.mimo, controller.signal) + it("ignores responses carrying a different or missing request ID", async () => { + const { promise, requestId } = startRequest(providerIdentifiers.mimo) - controller.abort() + postResponse("stale-request-id", { mimo: { stale: {} } }) + postResponse(undefined, { mimo: {} }) + + postResponse(requestId, { mimo: { fresh: {} } }) + await expect(promise).resolves.toEqual({ mimo: { fresh: {} } }) + }) - await expect(promise).rejects.toMatchObject({ name: "AbortError" }) + it("resolves concurrent same-provider requests independently by request ID", async () => { + const first = startRequest(providerIdentifiers.mimo) + const second = startRequest(providerIdentifiers.mimo) - // A late response must not reach the removed listener or resolve anything. - postResponse(providerIdentifiers.mimo, { mimo: {} }) - await Promise.resolve() + expect(first.requestId).not.toBe(second.requestId) + + postResponse(second.requestId, { mimo: { second: {} } }) + postResponse(first.requestId, { mimo: { first: {} } }) + + await expect(first.promise).resolves.toEqual({ mimo: { first: {} } }) + await expect(second.promise).resolves.toEqual({ mimo: { second: {} } }) + }) + + it("rejects on abort and removes the exact registered listener and timer", async () => { + vi.useFakeTimers() + try { + const addSpy = vi.spyOn(window, "addEventListener") + const removeSpy = vi.spyOn(window, "removeEventListener") + const controller = new AbortController() + + const { promise, requestId } = startRequest(providerIdentifiers.mimo, controller.signal) + const registeredHandler = addSpy.mock.calls.find(([eventName]) => eventName === "message")?.[1] + + expect(vi.getTimerCount()).toBe(1) + + controller.abort() + + await expect(promise).rejects.toMatchObject({ name: "AbortError" }) + expect(removeSpy).toHaveBeenCalledWith("message", registeredHandler) + expect(vi.getTimerCount()).toBe(0) + + // A stale response for the aborted request must find no listener. + postResponse(requestId, { mimo: {} }) + } finally { + vi.useRealTimers() + } }) it("rejects immediately when the signal is already aborted, without posting", async () => { @@ -71,7 +117,7 @@ describe("fetchRouterModels", () => { await expect(fetchRouterModels(providerIdentifiers.mimo, controller.signal)).rejects.toMatchObject({ name: "AbortError", }) - expect(vscode.postMessage).not.toHaveBeenCalled() + expect(postMessageMock).not.toHaveBeenCalled() }) it("times out when no response arrives", async () => { diff --git a/webview-ui/src/components/ui/hooks/useRouterModels.ts b/webview-ui/src/components/ui/hooks/useRouterModels.ts index 7108d4ded0..9073068c0f 100644 --- a/webview-ui/src/components/ui/hooks/useRouterModels.ts +++ b/webview-ui/src/components/ui/hooks/useRouterModels.ts @@ -16,6 +16,13 @@ type UseRouterModelsOptions = { export const fetchRouterModels = async (provider?: string, signal?: AbortSignal) => new Promise((resolve, reject) => { + // Correlate the response by a unique request ID instead of the provider + // alone: a remount can issue a replacement request for the same provider + // while a stale response to an aborted request is still in flight, and + // provider-only matching would let that stale response resolve the new + // query. The extension host echoes the ID in the routerModels response. + const requestId = crypto.randomUUID() + const timeout = setTimeout(() => { cleanup() reject(new Error("Router models request timed out")) @@ -25,11 +32,10 @@ export const fetchRouterModels = async (provider?: string, signal?: AbortSignal) const message: ExtensionMessage = event.data if (message.type === RouterModelsMessageType.routerModels) { - const msgProvider = message?.values?.provider as string | undefined + const msgRequestId = message?.values?.requestId as string | undefined - // Verify response matches request - if (provider !== msgProvider) { - // Not our response; ignore and wait for the matching one + // Verify the response belongs to this exact request. + if (msgRequestId !== requestId) { return } @@ -69,11 +75,10 @@ export const fetchRouterModels = async (provider?: string, signal?: AbortSignal) } window.addEventListener("message", handler) - if (provider) { - vscode.postMessage({ type: RouterModelsMessageType.requestRouterModels, values: { provider } }) - } else { - vscode.postMessage({ type: RouterModelsMessageType.requestRouterModels }) - } + vscode.postMessage({ + type: RouterModelsMessageType.requestRouterModels, + values: { requestId, ...(provider ? { provider } : {}) }, + }) }) export const useRouterModels = (opts: UseRouterModelsOptions = {}) => { From 91a2709c0aa8792c5c659588dd9a8601f9374bbe Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Thu, 24 Sep 2026 05:18:02 +0900 Subject: [PATCH 16/46] fix(core): keep legacy routerModels response shape when no request ID What: only include values.requestId in the routerModels response when the request carried one; requests without an ID keep the previous response shape (values undefined for aggregate, { provider } for filtered). Why: the unconditional requestId echo broke six existing requestRouterModels specs in ClineProvider.spec.ts and webviewMessageHandler.spec.ts that assert the exact legacy response payload (values: undefined for aggregate requests), failing the platform-unit-test ubuntu CI job on bc077c290. Impact: CI restoration; the request-ID correlation added for fetchRouterModels is unchanged for new-protocol requests. Added a host-side test asserting the ID echo for filtered requests. --- ...webviewMessageHandler.routerModels.spec.ts | 25 +++++++++++++++++++ src/core/webview/webviewMessageHandler.ts | 18 +++++++------ 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts index 94583d0334..9f2f8671b1 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts @@ -678,6 +678,31 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { }) }) + it("echoes the request ID in the routerModels response when one was provided", async () => { + mockProvider.getState.mockResolvedValue({ + apiConfiguration: { + mimoApiKey: "stored-mimo-key", + }, + }) + + getModelsMock.mockResolvedValue({}) + + await webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.requestRouterModels, + values: { provider: providerIdentifiers.mimo, requestId: "req-123" }, + }) + + const response = mockProvider.postMessageToWebview.mock.calls.find( + (call) => call[0]?.type === RouterModelsMessageType.routerModels, + ) + expect(response).toBeDefined() + if (!response) throw new Error("Expected routerModels response") + expect(response[0].values).toEqual({ + requestId: "req-123", + provider: providerIdentifiers.mimo, + }) + }) + it("flushes MiMo cache when explicit credentials are provided via message values", async () => { getModelsMock.mockResolvedValue({ "mimo-v2.6-pro": { contextWindow: 1_048_576, supportsPromptCache: false }, diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index d87065cc4a..7959c6bad7 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1440,16 +1440,20 @@ export const webviewMessageHandler = async ( } }) + // Echo the webview's request ID when one was provided so + // fetchRouterModels can correlate the response to the exact pending + // request (a remount may re-issue the same provider request while a + // stale response is in flight). Requests without an ID keep the + // previous response shape. + const requestId = message?.values?.requestId await provider.postMessageToWebview({ type: RouterModelsMessageType.routerModels, routerModels, - // Echo the webview's request ID so fetchRouterModels can correlate - // the response to the exact pending request (a remount may re-issue - // the same provider request while a stale response is in flight). - values: { - requestId: message?.values?.requestId, - ...(providerFilter ? { provider: requestedProvider } : {}), - }, + values: requestId + ? { requestId, ...(providerFilter ? { provider: requestedProvider } : {}) } + : providerFilter + ? { provider: requestedProvider } + : undefined, }) break } From eb381e7275eca89df4a2dd3d97e0d269f1e76f1c Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Sun, 27 Sep 2026 19:54:12 +0900 Subject: [PATCH 17/46] refactor(mimo): drop unreachable flushModels catch, harden router-model specs What: drop the try/catch around the MiMo flushModels call so it matches the DeepSeek/Moonshot sibling blocks, and delete the spec that mocked its impossible rejection; assert v2.5-pro stays absent from a v2.6-only API response and cover the %40-encoded userinfo form in the fetcher spec; assert exact per-provider fallback info in useSelectedModel; fix the ApiOptions picker-merge comment to state the real reason. Why: refreshModels catch-alls internally and never rejects, so the catch was unreachable and its fall-through would have double-posted had it ever fired; the comment justified the MiMo-only merge with a label that applies equally to DeepSeek/Moonshot; several assertions still passed under the regressions they were meant to guard. Impact: no runtime behavior change; the MiMo failure-response path stays covered by the allowlist-rejection test. Refs: #1748 --- .../providers/fetchers/__tests__/mimo.spec.ts | 17 +++++++++++ ...webviewMessageHandler.routerModels.spec.ts | 28 ----------------- src/core/webview/webviewMessageHandler.ts | 19 ++---------- .../src/components/settings/ApiOptions.tsx | 12 +++++--- .../hooks/__tests__/useSelectedModel.spec.ts | 30 ++++++++----------- 5 files changed, 40 insertions(+), 66 deletions(-) diff --git a/src/api/providers/fetchers/__tests__/mimo.spec.ts b/src/api/providers/fetchers/__tests__/mimo.spec.ts index 3f5c567fe0..891d56db4e 100644 --- a/src/api/providers/fetchers/__tests__/mimo.spec.ts +++ b/src/api/providers/fetchers/__tests__/mimo.spec.ts @@ -26,6 +26,11 @@ describe("getMimoModels", () => { ) expect(models["mimo-v2.6-pro"]).toEqual(mimoModels["mimo-v2.6-pro"]) expect(models["mimo-v2.6-flash"]).toEqual(mimoModels["mimo-v2.6-flash"]) + + // mimo-v2.5-pro is still served today, but this mock response omits it + // (post-EOL world): a model absent from the API response must stay absent + // from the result, guarding against a wholesale-static-map regression. + expect(models["mimo-v2.5-pro"]).toBeUndefined() }) it("provides MiMo-family defaults for unknown model IDs without pricing", async () => { @@ -200,6 +205,18 @@ describe("getMimoModels", () => { expect(fetchSpy).not.toHaveBeenCalled() }) + it("rejects a percent-encoded %40 userinfo form through the allowlist guard", async () => { + const fetchSpy = vi.fn() + globalThis.fetch = fetchSpy as unknown as typeof fetch + + // containsUserinfo only scans for a literal "@", so the encoded form + // reaches the allowlist guard, which rejects it as a non-exact match. + await expect( + getMimoModels("https://token-plan-sgp.xiaomimimo.com%40attacker.example/v1", "key"), + ).rejects.toThrow("not an allowed Xiaomi MiMo endpoint") + expect(fetchSpy).not.toHaveBeenCalled() + }) + it.each([ ["query string", "https://token-plan-sgp.xiaomimimo.com/v1?api_key=QUERYSECRET"], ["fragment", "https://token-plan-sgp.xiaomimimo.com/v1#api_key=FRAGMENTSECRET"], diff --git a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts index 9f2f8671b1..0dcb9ae674 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts @@ -776,34 +776,6 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { }) }) - it("posts a MiMo failure response and still posts routerModels when the refresh rejects", async () => { - flushModelsMock.mockRejectedValue(new Error("MiMo refresh failed")) - getModelsMock.mockResolvedValue({}) - - await webviewMessageHandler(mockProvider, { - type: RouterModelsMessageType.requestRouterModels, - values: { - mimoApiKey: "new-mimo-key", - mimoBaseUrl: "https://token-plan-sgp.xiaomimimo.com/v1", - }, - }) - - const errorCall = mockProvider.postMessageToWebview.mock.calls.find( - (call) => - call[0]?.type === RouterModelsMessageType.singleRouterModelFetchResponse && - call[0]?.values?.provider === providerIdentifiers.mimo, - ) - expect(errorCall).toBeDefined() - if (!errorCall) throw new Error("Expected MiMo failure response") - expect(errorCall[0].success).toBe(false) - expect(errorCall[0].error).toBe("MiMo refresh failed") - - const response = mockProvider.postMessageToWebview.mock.calls.find( - (call) => call[0]?.type === RouterModelsMessageType.routerModels, - ) - expect(response).toBeDefined() - }) - it("rejects an unsaved MiMo base URL outside the Xiaomi allowlist without dispatching a fetch", async () => { mockProvider.getState.mockResolvedValue({ apiConfiguration: { diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 7959c6bad7..69db328dc8 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1311,22 +1311,9 @@ export const webviewMessageHandler = async ( } if (message?.values?.mimoApiKey || message?.values?.mimoBaseUrl) { - // A refresh failure (bad key/endpoint) must not abort the - // aggregate fetch — surface the normal MiMo failure response - // and continue so routerModels is still posted. - try { - await flushModels(mimoOptions, true) - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - console.error(`Error refreshing models for ${providerIdentifiers.mimo}:`, error) - - await provider.postMessageToWebview({ - type: RouterModelsMessageType.singleRouterModelFetchResponse, - success: false, - error: errorMessage, - values: { provider: providerIdentifiers.mimo }, - }) - } + // Unsaved form values win over stored config: flush refreshes the cache + // with them before the aggregate fetch (same pattern as DeepSeek/Moonshot). + await flushModels(mimoOptions, true) } candidates.push({ diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 195d0d79a2..b979dd7bc9 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -746,10 +746,14 @@ const ApiOptions = ({ setApiConfigurationField={setApiConfigurationField} defaultModelId={getDefaultModelIdForProvider(activeSelectedProvider, apiConfiguration)} models={ - // MiMo is a dynamic provider: merge the host-fetched model - // catalog into the static fallback so models newer than the - // shipped catalog (e.g. post-V2.6 releases) are selectable - // without an extension update. + // MiMo has no dedicated picker component, so it shares the + // generic static picker with DeepSeek. MiMo ships new model + // generations frequently (the V2.5 line is EOL), so the + // host-fetched catalog is merged in here: the picker stays + // populated before the router response arrives and post-V2.6 + // releases become selectable without an extension update. + // DeepSeek uses this picker without the merge; Moonshot has + // its own router-only picker. activeSelectedProvider === providerIdentifiers.mimo ? { ...getStaticModelsForProvider( diff --git a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts index 220a2a7fcb..02cad8e60c 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts @@ -740,26 +740,20 @@ describe("useSelectedModel", () => { expect(result.current.info?.maxTokens).toBe(131_072) }) - it.each([providerIdentifiers.deepseek, providerIdentifiers.moonshot, providerIdentifiers.mimo])( - "keeps the static %s catalog when the router query has errored", - (provider) => { - mockUseRouterModels.mockReturnValue(createRouterModelsResult(undefined, { isError: true })) + it.each([ + [providerIdentifiers.deepseek, deepSeekDefaultModelId, deepSeekModels[deepSeekDefaultModelId]], + [providerIdentifiers.moonshot, moonshotDefaultModelId, moonshotModels[moonshotDefaultModelId]], + [providerIdentifiers.mimo, mimoDefaultModelId, mimoModels[mimoDefaultModelId]], + ])("keeps the static %s catalog when the router query has errored", (provider, expectedId, expectedInfo) => { + mockUseRouterModels.mockReturnValue(createRouterModelsResult(undefined, { isError: true })) - const wrapper = createWrapper() - const { result } = renderHook(() => useSelectedModel({ apiProvider: provider }), { wrapper }) + const wrapper = createWrapper() + const { result } = renderHook(() => useSelectedModel({ apiProvider: provider }), { wrapper }) - const expectedId = - provider === providerIdentifiers.deepseek - ? deepSeekDefaultModelId - : provider === providerIdentifiers.moonshot - ? moonshotDefaultModelId - : mimoDefaultModelId - expect(result.current.isError).toBe(true) - expect(result.current.id).toBe(expectedId) - expect(result.current.info).toBeDefined() - expect(result.current.info?.contextWindow).toBeGreaterThan(0) - }, - ) + expect(result.current.isError).toBe(true) + expect(result.current.id).toBe(expectedId) + expect(result.current.info).toEqual(expectedInfo) + }) }) describe("default behavior", () => { From 9fe5592c9b6438734dae448c05a89ea00a3e89a9 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sat, 3 Oct 2026 18:22:44 +0900 Subject: [PATCH 18/46] test(mimo): assert API-only model appears in the merge result What: extend the known-models merge test with an API-only model ID (mimo-v3-future) and assert it surfaces with MiMo-family defaults, alongside the existing assertion that mimo-v2.5-pro (present in the static map, absent from the mocked response) stays absent. Why: edelauna's review on #1748 noted both sides of the existing toEqual read from the same mimoModels import, so a mutation returning the static map directly would still pass. The absent-side guard landed at eb381e727; the converse direction (an API-only model must appear) was only covered in a sibling test, not in the flagged test itself. Impact: the flagged test now fails under the wholesale-static mutation in either direction (verified by temporarily returning the static map: the new assertion fires). No runtime behavior change. Refs: #1748 --- src/api/providers/fetchers/__tests__/mimo.spec.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/api/providers/fetchers/__tests__/mimo.spec.ts b/src/api/providers/fetchers/__tests__/mimo.spec.ts index 891d56db4e..aa5b31684f 100644 --- a/src/api/providers/fetchers/__tests__/mimo.spec.ts +++ b/src/api/providers/fetchers/__tests__/mimo.spec.ts @@ -14,7 +14,7 @@ describe("getMimoModels", () => { globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ - data: [{ id: "mimo-v2.6-pro" }, { id: "mimo-v2.6-flash" }], + data: [{ id: "mimo-v2.6-pro" }, { id: "mimo-v2.6-flash" }, { id: "mimo-v3-future" }], }), }) as unknown as typeof fetch @@ -27,6 +27,18 @@ describe("getMimoModels", () => { expect(models["mimo-v2.6-pro"]).toEqual(mimoModels["mimo-v2.6-pro"]) expect(models["mimo-v2.6-flash"]).toEqual(mimoModels["mimo-v2.6-flash"]) + // mimo-v3-future exists only in the API response, so it must surface with + // MiMo-family defaults: a mutation that returned the static map directly + // (ignoring the response) would leave it undefined. + expect(models["mimo-v3-future"]).toEqual({ + maxTokens: 16_000, + contextWindow: 262_144, + supportsImages: false, + supportsPromptCache: false, + preserveReasoning: true, + description: "MiMo model: mimo-v3-future", + }) + // mimo-v2.5-pro is still served today, but this mock response omits it // (post-EOL world): a model absent from the API response must stay absent // from the result, guarding against a wholesale-static-map regression. From 32ff432d8e74230c24db1f34d1254d4acde9bee1 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sat, 3 Oct 2026 20:36:29 +0900 Subject: [PATCH 19/46] fix(mimo): gate the chat-completion client behind the base-URL allowlist What: MimoHandler now validates mimoBaseUrl against the fetcher's ALLOWED_BASE_URLS (and a shared MIMO_DEFAULT_BASE_URL fallback) before calling super(), throwing a constant, non-echoing error for off-list values. Why: the PR enforced the allowlist in the model fetcher and for unsaved webview values, but the chat-completion path passed mimoBaseUrl straight into new OpenAI({ baseURL }). Since ContextProxy.getProviderSettings() fails open on a schema rejection, a value persisted by a pre-allowlist build or sent via a crafted webview message could redirect the bearer key plus the whole conversation to an arbitrary origin. Impact: an off-list mimoBaseUrl can no longer reach the network on any path; all four Xiaomi endpoints, trailing-slash normalization, and the empty-is-unset fallback keep working (pinned by new constructor tests that assert no OpenAI client is ever constructed for a rejected URL). --- src/api/providers/__tests__/mimo.spec.ts | 59 ++++++++++++++++++++++++ src/api/providers/fetchers/mimo.ts | 12 +++-- src/api/providers/mimo.ts | 19 +++++++- 3 files changed, 86 insertions(+), 4 deletions(-) diff --git a/src/api/providers/__tests__/mimo.spec.ts b/src/api/providers/__tests__/mimo.spec.ts index a4c1882146..28b06c118d 100644 --- a/src/api/providers/__tests__/mimo.spec.ts +++ b/src/api/providers/__tests__/mimo.spec.ts @@ -33,9 +33,11 @@ vi.mock("openai", () => { }) import type { Anthropic } from "@anthropic-ai/sdk" +import OpenAI from "openai" import { mimoDefaultModelId, mimoModels } from "@roo-code/types" import type { ApiHandlerOptions } from "../../../shared/api" import { MimoHandler } from "../mimo" +import { ALLOWED_BASE_URLS } from "../fetchers/mimo" import { convertToR1Format } from "../../transform/r1-format" import { sanitizeOpenAiCallId } from "../../../utils/tool-id" @@ -79,6 +81,63 @@ describe("MimoHandler", () => { }) }) + describe("base URL allowlist (chat-completion path)", () => { + // The constructor mock stands in for the real OpenAI client, so its call + // log proves whether a network object was ever built for a given base URL. + const openAiConstructor = vi.mocked(OpenAI) + + // Simulates the fail-open ContextProxy path, which can hand the handler a + // mimoBaseUrl outside the schema's literal union at runtime. + type RuntimeOptions = Omit & { mimoBaseUrl?: string } + const optionsWithBaseUrl = (mimoBaseUrl: string): ApiHandlerOptions => { + const options: RuntimeOptions = { ...mockOptions, mimoBaseUrl } + return options as ApiHandlerOptions + } + + const lastClientBaseUrl = (): string | null | undefined => { + const calls = openAiConstructor.mock.calls + return calls[calls.length - 1]?.[0]?.baseURL + } + + it("rejects an off-list base URL before any network client is constructed", () => { + const callsBefore = openAiConstructor.mock.calls.length + + expect(() => new MimoHandler(optionsWithBaseUrl("https://attacker.example/v1"))).toThrow( + "MIMO/MimoHandler/001", + ) + expect(openAiConstructor.mock.calls.length).toBe(callsBefore) + }) + + it("accepts every allowlisted Xiaomi endpoint", () => { + for (const allowedUrl of ALLOWED_BASE_URLS) { + new MimoHandler(optionsWithBaseUrl(allowedUrl)) + expect(lastClientBaseUrl()).toBe(allowedUrl) + } + }) + + it("normalizes trailing slashes before the allowlist check", () => { + new MimoHandler(optionsWithBaseUrl("https://token-plan-sgp.xiaomimimo.com/v1/")) + expect(lastClientBaseUrl()).toBe("https://token-plan-sgp.xiaomimimo.com/v1/") + }) + + it("treats an empty base URL as unset and falls back to the default cluster", () => { + new MimoHandler(optionsWithBaseUrl("")) + expect(lastClientBaseUrl()).toBe("https://token-plan-sgp.xiaomimimo.com/v1") + }) + + it("never echoes a secret carried in the rejected URL", () => { + let thrown: unknown + try { + new MimoHandler(optionsWithBaseUrl("https://token-plan-sgp.xiaomimimo.com/v1?api_key=SECRET")) + } catch (error) { + thrown = error + } + expect(thrown).toBeInstanceOf(Error) + expect((thrown as Error).message).toContain("not an allowed Xiaomi MiMo endpoint") + expect((thrown as Error).message).not.toContain("SECRET") + }) + }) + describe("getModel", () => { it("should return correct model info for mimo-v2.5-pro", () => { const model = handler.getModel() diff --git a/src/api/providers/fetchers/mimo.ts b/src/api/providers/fetchers/mimo.ts index df31baf6cc..959f813e78 100644 --- a/src/api/providers/fetchers/mimo.ts +++ b/src/api/providers/fetchers/mimo.ts @@ -8,6 +8,11 @@ import { DEFAULT_HEADERS } from "../constants" // builds, so discovery excludes them from the catalog. const NON_TEXT_MODEL_ID = /-(asr|tts)(-|$)/i +// Default cluster (Singapore), used whenever the caller leaves mimoBaseUrl +// unset or empty. Exported so the chat-completion handler resolves the exact +// same fallback instead of drifting a second literal. +export const MIMO_DEFAULT_BASE_URL = "https://token-plan-sgp.xiaomimimo.com/v1" + // Network-boundary allowlist: the same four Xiaomi MiMo endpoints the persisted // settings schema validates `mimoBaseUrl` against via a zod union of literals // (packages/types/src/provider-settings/mimo.ts). The definition itself is not @@ -15,8 +20,9 @@ const NON_TEXT_MODEL_ID = /-(asr|tts)(-|$)/i // are mirrored here; __tests__/mimo.spec.ts cross-checks this exported set // against providerSettingsSchema so the two sources cannot silently drift. The // fetcher also receives unsaved webview values that bypass the schema entirely, -// so this exact-match gate is the only thing guaranteeing the bearer key never -// leaves a Xiaomi origin. Exported for that drift test only. +// and MimoHandler (src/api/providers/mimo.ts) re-enforces this same set before +// it builds its OpenAI chat-completion client, so this exact-match gate is what +// guarantees the bearer key never leaves a Xiaomi origin on any path. export const ALLOWED_BASE_URLS: ReadonlySet = new Set([ "https://api.xiaomimimo.com/v1", "https://token-plan-cn.xiaomimimo.com/v1", @@ -54,7 +60,7 @@ export async function getMimoModels( // MiMo API uses OpenAI-compatible /v1/models endpoint. // The base URL from settings already includes /v1 (e.g. https://token-plan-sgp.xiaomimimo.com/v1), // so we keep it as-is and append /models directly. - const base = (baseUrl || "https://token-plan-sgp.xiaomimimo.com/v1").replace(/\/+$/, "") + const base = (baseUrl || MIMO_DEFAULT_BASE_URL).replace(/\/+$/, "") // Reject embedded credentials first, then pin the request to the four allowed // endpoints with an exact match. This subsumes the previous https-only guard diff --git a/src/api/providers/mimo.ts b/src/api/providers/mimo.ts index d2fba1cb48..9530d4cebd 100644 --- a/src/api/providers/mimo.ts +++ b/src/api/providers/mimo.ts @@ -16,6 +16,8 @@ import { NOT_PROVIDED } from "./constants" import type { ApiHandlerCreateMessageMetadata } from "../index" import { sanitizeOpenAiCallId } from "../../utils/tool-id" +import { ALLOWED_BASE_URLS, MIMO_DEFAULT_BASE_URL } from "./fetchers/mimo" + /** * MiMoHandler extends OpenAiHandler with MiMo-specific adaptations. * @@ -26,11 +28,26 @@ import { sanitizeOpenAiCallId } from "../../utils/tool-id" */ export class MimoHandler extends OpenAiHandler { constructor(options: ApiHandlerOptions) { + // Fail closed on the network boundary BEFORE OpenAiHandler constructs its + // OpenAI client. The persisted settings schema pins mimoBaseUrl to four + // zod literals, but ContextProxy.getProviderSettings() fails open on a + // schema rejection and returns the raw stored value — so a value written + // by a pre-allowlist build or a crafted webview message would otherwise + // reach `new OpenAI({ baseURL })` and could exfiltrate the bearer key + // plus the whole conversation to an arbitrary origin. Unset/empty falls + // back to the default cluster; trailing slashes are normalized for the + // check exactly like the model fetcher does. + const mimoBaseUrl = options.mimoBaseUrl || MIMO_DEFAULT_BASE_URL + if (!ALLOWED_BASE_URLS.has(mimoBaseUrl.replace(/\/+$/, ""))) { + throw new Error( + "MIMO/MimoHandler/001: MiMo chat completion rejected: base URL is not an allowed Xiaomi MiMo endpoint.", + ) + } super({ ...options, openAiApiKey: options.mimoApiKey ?? NOT_PROVIDED, openAiModelId: options.apiModelId ?? mimoDefaultModelId, - openAiBaseUrl: options.mimoBaseUrl || "https://token-plan-sgp.xiaomimimo.com/v1", + openAiBaseUrl: mimoBaseUrl, openAiStreamingEnabled: true, includeMaxTokens: false, }) From 0d814099911bf40e191d83ea76296a87f220ea5c Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sat, 3 Oct 2026 20:37:54 +0900 Subject: [PATCH 20/46] fix(mimo): reject redirects in the model-catalog fetch What: getMimoModels now passes redirect: "error" to fetch. Why: the exact-match allowlist is enforced on the request URL before any header is built, but the default redirect:"follow" would silently re-scope the Authorization bearer key to whatever origin a 3xx Location points at, bypassing the allowlist entirely. Impact: any redirect from a Xiaomi endpoint now fails the fetch instead of leaking the key; the mock fetch layer cannot drive undici's real redirect handling, so the request init is pinned test-side instead. --- .../providers/fetchers/__tests__/mimo.spec.ts | 19 +++++++++++++++++++ src/api/providers/fetchers/mimo.ts | 4 ++++ 2 files changed, 23 insertions(+) diff --git a/src/api/providers/fetchers/__tests__/mimo.spec.ts b/src/api/providers/fetchers/__tests__/mimo.spec.ts index aa5b31684f..0a949ee643 100644 --- a/src/api/providers/fetchers/__tests__/mimo.spec.ts +++ b/src/api/providers/fetchers/__tests__/mimo.spec.ts @@ -277,6 +277,25 @@ describe("getMimoModels", () => { expect(fetchSpy).not.toHaveBeenCalled() }) + it("pins the fetch to reject redirects so a 3xx cannot re-scope the bearer key", async () => { + // The mocked fetch layer cannot exercise undici's real redirect handling, + // so this pins the request init: redirect must be "error", which per the + // fetch spec aborts the request on any 3xx instead of following it to an + // origin the allowlist never vetted. + const fetchSpy = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ data: [] }), + }) + globalThis.fetch = fetchSpy as unknown as typeof fetch + + await getMimoModels("https://token-plan-sgp.xiaomimimo.com/v1", "my-secret-key") + + expect(fetchSpy).toHaveBeenCalledWith( + "https://token-plan-sgp.xiaomimimo.com/v1/models", + expect.objectContaining({ redirect: "error" }), + ) + }) + // Regression guard for the security gate: every legitimate endpoint must keep // working exactly as before the allowlist was introduced. it.each([...ALLOWED_BASE_URLS])("fetches /models normally for allowed endpoint %s", async (allowedUrl) => { diff --git a/src/api/providers/fetchers/mimo.ts b/src/api/providers/fetchers/mimo.ts index 959f813e78..95cca57be0 100644 --- a/src/api/providers/fetchers/mimo.ts +++ b/src/api/providers/fetchers/mimo.ts @@ -92,6 +92,10 @@ export async function getMimoModels( const response = await fetch(url.toString(), { headers, signal: opts?.signal, + // The exact-match allowlist is enforced on the request URL only; following + // a 3xx would silently re-scope the bearer key to an un-vetted origin. + // Reject redirects instead (per fetch spec, "error" aborts on any 3xx). + redirect: "error", }) if (!response.ok) { From ba0d7bb6cbf3842115aa7455f3fe295bbafb656f Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sat, 3 Oct 2026 20:41:10 +0900 Subject: [PATCH 21/46] fix(mimo): validate mimoBaseUrl at the settings persistence boundary What: the updateSettings path now re-checks mimoBaseUrl against ALLOWED_BASE_URLS before it can enter storage (off-list non-empty values are dropped with a warning; empty string is normalized to unset; trailing slashes are canonicalized), and requestRouterModels treats an unsaved empty mimoBaseUrl as unset instead of letting it override the stored cluster. Why: ContextProxy.getProviderSettings() fails open on a schema rejection, so the zod literal union alone cannot keep an off-list value out of persisted state; and an unsaved "" passed the ?? fallback and skipped both the allowlist gate and the cache flush, silently rerouting the stored key to the default Singapore cluster. Impact: bad values can no longer persist or masquerade as cluster selections; empty-means-unset is now consistent across the persistence, router, and chat-completion paths. The upstream fail-open ContextProxy behavior is pre-existing and out of scope (recommended upstream issue). --- ...webviewMessageHandler.routerModels.spec.ts | 49 ++++++++++++++ .../__tests__/webviewMessageHandler.spec.ts | 66 +++++++++++++++++++ src/core/webview/webviewMessageHandler.ts | 41 ++++++++++-- 3 files changed, 150 insertions(+), 6 deletions(-) diff --git a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts index 0dcb9ae674..e05d3b558d 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts @@ -822,6 +822,55 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { expect(response[0].routerModels.mimo).toEqual({}) }) + it("treats an unsaved empty mimoBaseUrl as unset and keeps using the stored cluster", async () => { + mockProvider.getState.mockResolvedValue({ + apiConfiguration: { + mimoApiKey: "stored-mimo-key", + mimoBaseUrl: "https://token-plan-cn.xiaomimimo.com/v1", + }, + }) + + await webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.requestRouterModels, + values: { mimoBaseUrl: "" }, + }) + + // Empty means unset: the stored China cluster must keep applying instead + // of being silently rerouted to the default Singapore endpoint. + expect(getModelsMock).toHaveBeenCalledWith({ + provider: providerIdentifiers.mimo, + apiKey: "stored-mimo-key", + baseUrl: "https://token-plan-cn.xiaomimimo.com/v1", + }) + + // No rejection is posted and no refresh is flushed for the empty value. + const errorCall = mockProvider.postMessageToWebview.mock.calls.find( + (call) => call[0]?.type === RouterModelsMessageType.singleRouterModelFetchResponse, + ) + expect(errorCall).toBeUndefined() + const mimoFlushCalls = flushModelsMock.mock.calls.filter((c) => c[0]?.provider === providerIdentifiers.mimo) + expect(mimoFlushCalls.length).toBe(0) + }) + + it("falls back to an undefined base URL when an empty unsaved value has no stored cluster", async () => { + mockProvider.getState.mockResolvedValue({ + apiConfiguration: { + mimoApiKey: "stored-mimo-key", + }, + }) + + await webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.requestRouterModels, + values: { mimoBaseUrl: "" }, + }) + + expect(getModelsMock).toHaveBeenCalledWith({ + provider: providerIdentifiers.mimo, + apiKey: "stored-mimo-key", + baseUrl: undefined, + }) + }) + it("posts a Moonshot provider error and keeps an empty aggregate entry when fetch fails", async () => { mockProvider.getState.mockResolvedValue({ apiConfiguration: { diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index dde4eb3dbe..860e049500 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -1313,6 +1313,72 @@ describe("webviewMessageHandler - destructiveCommandGuardEnabled", () => { }) }) +describe("webviewMessageHandler - updateSettings mimoBaseUrl persistence gate", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Simulates a crafted webview message carrying a value outside the schema's + // literal union (the updateSettings boundary is validated at runtime, not by + // the message type). + type WebviewMessageArg = Parameters[1] + const updateSettingsMessageWithBaseUrl = (mimoBaseUrl: string): WebviewMessageArg => + ({ + type: "updateSettings", + updatedSettings: { mimoBaseUrl }, + }) as WebviewMessageArg + + it("persists an allowlisted Xiaomi endpoint", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { mimoBaseUrl: "https://token-plan-ams.xiaomimimo.com/v1" }, + }) + + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith( + "mimoBaseUrl", + "https://token-plan-ams.xiaomimimo.com/v1", + ) + }) + + it("canonicalizes a trailing slash onto the allowlisted form", async () => { + await webviewMessageHandler( + mockClineProvider, + updateSettingsMessageWithBaseUrl("https://token-plan-ams.xiaomimimo.com/v1/"), + ) + + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith( + "mimoBaseUrl", + "https://token-plan-ams.xiaomimimo.com/v1", + ) + }) + + it("drops an off-list value with a warning and never persists it", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + await webviewMessageHandler(mockClineProvider, updateSettingsMessageWithBaseUrl("https://attacker.example/v1")) + + expect(mockClineProvider.contextProxy.setValue).not.toHaveBeenCalled() + expect(warnSpy).toHaveBeenCalledOnce() + + warnSpy.mockRestore() + }) + + it("treats an empty string as unset and persists undefined", async () => { + await webviewMessageHandler(mockClineProvider, updateSettingsMessageWithBaseUrl("")) + + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("mimoBaseUrl", undefined) + }) + + it("persists an explicit unset", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { mimoBaseUrl: undefined }, + }) + + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("mimoBaseUrl", undefined) + }) +}) + // Both allowlists are normalized by the same branch, so both are held to the // same contract. describe.each(["allowedReadFiles", "allowedWriteFiles"] as const)("webviewMessageHandler - %s", (key) => { diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 3223381824..a063d5d3d4 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -854,6 +854,28 @@ export const webviewMessageHandler = async ( if (!value) { continue } + } else if (key === "mimoBaseUrl") { + // Persistence-boundary gate. The settings schema pins this key to + // the four allowed Xiaomi endpoints, but ContextProxy fails open on + // a schema rejection and returns raw stored values — so validate + // again here, at the only place an off-list value could newly enter + // storage. Empty string means "unset" (mirroring the handler and + // router fallbacks) and is normalized to undefined; an off-list + // non-empty value is dropped with a warning so it can never reach + // the chat-completion client via persisted state. Trailing slashes + // are tolerated for the check and canonicalized away on save. + if (typeof value === "string" && value !== "") { + const normalized = value.replace(/\/+$/, "") + if (!ALLOWED_BASE_URLS.has(normalized)) { + console.warn( + "[webviewMessageHandler] Rejected mimoBaseUrl outside the allowed Xiaomi MiMo endpoints; the value was not persisted.", + ) + continue + } + newValue = normalized + } else { + newValue = undefined + } } await provider.contextProxy.setValue(key as keyof RooCodeSettings, newValue) @@ -1274,7 +1296,17 @@ export const webviewMessageHandler = async ( // (cn/sgp/ams token-plan or pay-as-you-go), so unsaved form values are // honored the same way as DeepSeek/Moonshot above. const mimoApiKey = message?.values?.mimoApiKey ?? apiConfiguration.mimoApiKey - const mimoBaseUrl = message?.values?.mimoBaseUrl ?? apiConfiguration.mimoBaseUrl + + // An unsaved empty string means "unset" (the form's cleared state), + // matching the handler-side fallback: it must not override the stored + // cluster value and silently reroute the stored key to the default + // Singapore endpoint. + const unsavedMimoBaseUrlValue = message?.values?.mimoBaseUrl + const unsavedMimoBaseUrl = + typeof unsavedMimoBaseUrlValue === "string" && unsavedMimoBaseUrlValue !== "" + ? unsavedMimoBaseUrlValue + : undefined + const mimoBaseUrl = unsavedMimoBaseUrl ?? apiConfiguration.mimoBaseUrl // Unsaved form values bypass the settings-schema validation that pins // stored mimoBaseUrl to the four allowed Xiaomi endpoints, so gate them @@ -1282,11 +1314,8 @@ export const webviewMessageHandler = async ( // modelCache. The exact match subsumes credential-bearing URLs (no // allowlisted literal contains userinfo), and the raw value is never // echoed since an unsaved one may embed credentials. - const unsavedMimoBaseUrl = message?.values?.mimoBaseUrl const mimoBaseUrlRejected = - typeof unsavedMimoBaseUrl === "string" && - unsavedMimoBaseUrl !== "" && - !ALLOWED_BASE_URLS.has(unsavedMimoBaseUrl.replace(/\/+$/, "")) + unsavedMimoBaseUrl !== undefined && !ALLOWED_BASE_URLS.has(unsavedMimoBaseUrl.replace(/\/+$/, "")) if (mimoApiKey) { if (mimoBaseUrlRejected) { @@ -1310,7 +1339,7 @@ export const webviewMessageHandler = async ( baseUrl: mimoBaseUrl, } - if (message?.values?.mimoApiKey || message?.values?.mimoBaseUrl) { + if (message?.values?.mimoApiKey || unsavedMimoBaseUrl) { // Unsaved form values win over stored config: flush refreshes the cache // with them before the aggregate fetch (same pattern as DeepSeek/Moonshot). await flushModels(mimoOptions, true) From 0b931f4b1ce7485e7a2ab77f820daa912f1120d2 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sat, 3 Oct 2026 20:46:57 +0900 Subject: [PATCH 22/46] refactor(modelCache): key the digest memo map by a hash of the input What: cacheDigestCache is now keyed by a SHA-256 of bytes+input instead of the raw input string. Why: deriveApiKeyDiscriminator feeds raw API keys through deriveCacheDigest on every cache lookup; memoizing on the raw key kept the secret alive as a long-lived map key for the whole session. The memo key is internal-only (never persisted or compared across processes), so a fast digest is sufficient and the PBKDF2 derivation remains the only one whose output leaves this process. Impact: no functional change; same digest outputs and cache-key shapes, raw keys no longer retained in the memo map. --- src/api/providers/fetchers/modelCache.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/api/providers/fetchers/modelCache.ts b/src/api/providers/fetchers/modelCache.ts index 19a950285c..7ea6d75c30 100644 --- a/src/api/providers/fetchers/modelCache.ts +++ b/src/api/providers/fetchers/modelCache.ts @@ -1,7 +1,7 @@ import * as path from "path" import fs from "fs/promises" import * as fsSync from "fs" -import { pbkdf2Sync } from "crypto" +import { createHash, pbkdf2Sync } from "crypto" import NodeCache from "node-cache" import { z } from "zod" @@ -146,6 +146,9 @@ function isAuthScopedProvider(provider: RouterName): boolean { // Memoize derived digests so the deliberately-structureless KDF runs at most once per // distinct input per session (getCacheKey / cacheKeyToFilename run on every cache lookup). +// Keys are digests of the input, never the raw value: deriveApiKeyDiscriminator feeds +// raw API keys through here, and retaining those as long-lived map keys would keep the +// secrets alive for the whole session. const cacheDigestCache = new Map() // Fixed, non-secret application salt. This is NOT credential storage: it derives short, @@ -170,7 +173,12 @@ const CACHE_DIGEST_ITERATIONS = 10_000 * cache filename cannot be reversed to identify the API key it was derived from. */ function deriveCacheDigest(value: string, bytes: number): string { - const memoKey = `${bytes}:${value}` + // The memo key is a fast, non-reversing digest of the input so the raw API key + // is processed but never retained (see cacheDigestCache above). It lives only + // in this in-memory map and is never persisted or compared across processes, + // so the expensive KDF below remains the only derivation whose output leaves + // this process (cache keys and on-disk filenames). + const memoKey = createHash("sha256").update(`${bytes}:${value}`).digest("hex") // lgtm[js/insufficient-password-hash] const cached = cacheDigestCache.get(memoKey) if (cached) return cached const digest = pbkdf2Sync(value, CACHE_DIGEST_SALT, CACHE_DIGEST_ITERATIONS, bytes, "sha256").toString("hex") From e0ef51b10dc276f0dbb4127925eba1d32c764bb2 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sat, 3 Oct 2026 20:48:06 +0900 Subject: [PATCH 23/46] docs(mimo): correct stale fallback comment and cross-reference the allowlist What: the MimoHandler getModel comment now names mimo-v2.6-pro as the fallback (matching mimoDefaultModelId), and the provider-settings zod literals point at the fetcher ALLOWED_BASE_URLS set that mirrors them. Why: the old comment still referenced mimo-v2.5-pro after the default moved to the V2.6 series, and the two allowlist sources had no pointer at each other, making silent drift easier. Impact: comments only; no behavior change. --- packages/types/src/provider-settings/mimo.ts | 6 ++++++ src/api/providers/mimo.ts | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/types/src/provider-settings/mimo.ts b/packages/types/src/provider-settings/mimo.ts index 690ede3839..fc311deec7 100644 --- a/packages/types/src/provider-settings/mimo.ts +++ b/packages/types/src/provider-settings/mimo.ts @@ -14,6 +14,12 @@ export const mimoProviderDefinition = createProviderDefinition({ getModelId: createModelIdAccessor(API_MODEL_ID_FIELD), schema: { ...apiModelIdProviderModelShape, + // The four allowed Xiaomi MiMo endpoints. Runtime paths that read this + // value BEFORE schema validation can apply — unsaved webview values, the + // fail-open ContextProxy path, the chat-completion client — all enforce + // the same set via ALLOWED_BASE_URLS in + // src/api/providers/fetchers/mimo.ts; keep these literals in sync with + // that set (fetcher tests cross-check both directions of the mapping). mimoBaseUrl: z .union([ z.literal("https://api.xiaomimimo.com/v1"), diff --git a/src/api/providers/mimo.ts b/src/api/providers/mimo.ts index 9530d4cebd..4cac458aa2 100644 --- a/src/api/providers/mimo.ts +++ b/src/api/providers/mimo.ts @@ -55,7 +55,7 @@ export class MimoHandler extends OpenAiHandler { /** * Maps the configured model ID to its MiMo model info and parameters. - * Falls back to the default model (mimo-v2.5-pro) if the stored ID + * Falls back to the default model (mimo-v2.6-pro) if the stored ID * doesn't match any known model — this can happen when users manually * type a model name in settings. */ From 2356a31a8e4e9ec7bcdde5bc3f0811e6f4f26823 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sat, 3 Oct 2026 21:22:43 +0900 Subject: [PATCH 24/46] fix(mimo): store mimoApiKey in SecretStorage like every sibling API key What: add mimoApiKey to SECRET_STATE_KEYS and add a one-time ContextProxy migration that moves a plaintext globalState value into SecretStorage (without overwriting an existing secret) and clears the plaintext copy. Why: every sibling provider API key is a secret-state key, so ContextProxy routed mimoApiKey through updateGlobalState instead: the key persisted in plaintext state.vscdb, and the public getConfiguration() extension API returned it verbatim to any co-installed extension (it only filters isSecretStateKey). The MiMo settings UI already claimed the key is stored in VS Code Secret Storage, which was false. MiMo already shipped in earlier builds, so a plain add would orphan existing plaintext keys. Impact: saves/reads now route through SecretStorage automatically (setValue/storeSecret, getValues, getProviderSettings); existing plaintext keys migrate on first launch; getConfiguration no longer exposes the key. New tests: secret classification, migration (move/keep/clear, no-op), mimo setValue routing + getProviderSettings resolution, getConfiguration filter. --- packages/types/src/__tests__/mimo.test.ts | 23 ++++++ packages/types/src/global-settings.ts | 1 + src/core/config/ContextProxy.ts | 28 +++++++ .../config/__tests__/ContextProxy.spec.ts | 75 ++++++++++++++++++- .../__tests__/api-configuration.spec.ts | 23 ++++++ 5 files changed, 146 insertions(+), 4 deletions(-) create mode 100644 packages/types/src/__tests__/mimo.test.ts diff --git a/packages/types/src/__tests__/mimo.test.ts b/packages/types/src/__tests__/mimo.test.ts new file mode 100644 index 0000000000..d1696bf65d --- /dev/null +++ b/packages/types/src/__tests__/mimo.test.ts @@ -0,0 +1,23 @@ +import { SECRET_STATE_KEYS, isSecretStateKey, providerIdentifiers, providerSettingsSchema } from "../index.js" + +describe("MiMo provider secret classification", () => { + it("classifies mimoApiKey as a secret state key alongside its sibling provider keys", () => { + expect(SECRET_STATE_KEYS).toContain("mimoApiKey") + expect(isSecretStateKey("mimoApiKey")).toBe(true) + }) + + it("keeps the non-secret MiMo settings out of the secret set", () => { + expect(isSecretStateKey("mimoBaseUrl")).toBe(false) + expect(SECRET_STATE_KEYS).not.toContain("mimoBaseUrl") + }) + + it("still parses and exposes mimoApiKey through the provider settings schema", () => { + const parsed = providerSettingsSchema.parse({ + apiProvider: providerIdentifiers.mimo, + mimoApiKey: "mimo-key", + mimoBaseUrl: "https://token-plan-sgp.xiaomimimo.com/v1", + }) + expect(parsed.mimoApiKey).toBe("mimo-key") + expect(parsed.mimoBaseUrl).toBe("https://token-plan-sgp.xiaomimimo.com/v1") + }) +}) diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 692798d00d..2049ae70c8 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -318,6 +318,7 @@ export const SECRET_STATE_KEYS = [ "openAiNativeApiKey", "deepSeekApiKey", "moonshotApiKey", + "mimoApiKey", "kimiCodeApiKey", "mistralApiKey", "minimaxApiKey", diff --git a/src/core/config/ContextProxy.ts b/src/core/config/ContextProxy.ts index 97d4104afc..0a9bb2a9a6 100644 --- a/src/core/config/ContextProxy.ts +++ b/src/core/config/ContextProxy.ts @@ -104,9 +104,37 @@ export class ContextProxy { // Migration: Clear old default condensing prompt so users get the improved v2 default await this.migrateOldDefaultCondensingPrompt() + // Migration: Move a MiMo API key persisted in plaintext globalState (from + // builds before the key joined SECRET_STATE_KEYS) into SecretStorage. + await this.migratePlaintextMimoApiKey() + this._isInitialized = true } + /** + * MiMo shipped with its API key in plaintext globalState. Once the key became a + * secret-state key, globalState stopped being read for it, so an existing + * plaintext value would sit orphaned (and exposed) while SecretStorage stayed + * empty. Move it into SecretStorage once, without overwriting a value that is + * already there, and clear the plaintext copy. + */ + private async migratePlaintextMimoApiKey() { + try { + const plaintextKey = this.originalContext.globalState.get("mimoApiKey") + if (!plaintextKey) { + return + } + if (!this.secretCache.mimoApiKey) { + await this.originalContext.secrets.store("mimoApiKey", plaintextKey) + this.secretCache.mimoApiKey = plaintextKey + logger.info("Migrated mimoApiKey to secrets") + } + await this.originalContext.globalState.update("mimoApiKey", undefined) + } catch (error) { + logger.error(`Error during mimoApiKey migration: ${error instanceof Error ? error.message : String(error)}`) + } + } + /** * Migrates the legacy customCondensingPrompt to the new customSupportPrompts structure * and removes the legacy field. diff --git a/src/core/config/__tests__/ContextProxy.spec.ts b/src/core/config/__tests__/ContextProxy.spec.ts index 2319a6b1a5..a86c29c407 100644 --- a/src/core/config/__tests__/ContextProxy.spec.ts +++ b/src/core/config/__tests__/ContextProxy.spec.ts @@ -76,11 +76,12 @@ describe("ContextProxy", () => { describe("constructor", () => { it("should initialize state cache with all global state keys", () => { - // +3 for the migration checks: + // +4 for the migration checks: // 1. openRouterImageGenerationSettings // 2. customCondensingPrompt // 3. customSupportPrompts (for migrateOldDefaultCondensingPrompt) - expect(mockGlobalState.get).toHaveBeenCalledTimes(GLOBAL_STATE_KEYS.length + 3) + // 4. mimoApiKey (for migratePlaintextMimoApiKey) + expect(mockGlobalState.get).toHaveBeenCalledTimes(GLOBAL_STATE_KEYS.length + 4) for (const key of GLOBAL_STATE_KEYS) { expect(mockGlobalState.get).toHaveBeenCalledWith(key) } @@ -88,6 +89,7 @@ describe("ContextProxy", () => { expect(mockGlobalState.get).toHaveBeenCalledWith("openRouterImageGenerationSettings") expect(mockGlobalState.get).toHaveBeenCalledWith("customCondensingPrompt") expect(mockGlobalState.get).toHaveBeenCalledWith("customSupportPrompts") + expect(mockGlobalState.get).toHaveBeenCalledWith("mimoApiKey") }) it("should initialize secret cache with all secret keys", () => { @@ -110,8 +112,8 @@ describe("ContextProxy", () => { const result = proxy.getGlobalState("apiProvider") expect(result).toBe("deepseek") - // Original context should be called once during updateGlobalState (+3 for migration checks) - expect(mockGlobalState.get).toHaveBeenCalledTimes(GLOBAL_STATE_KEYS.length + 3) // From initialization + migration checks + // Original context should be called once during updateGlobalState (+4 for migration checks) + expect(mockGlobalState.get).toHaveBeenCalledTimes(GLOBAL_STATE_KEYS.length + 4) // From initialization + migration checks }) it("should handle default values correctly", async () => { @@ -247,6 +249,19 @@ describe("ContextProxy", () => { expect(storedValue).toBe("test-api-key") }) + it("routes mimoApiKey to SecretStorage and resolves it through getValues/getProviderSettings", async () => { + const storeSecretSpy = vi.spyOn(proxy, "storeSecret") + const updateGlobalStateSpy = vi.spyOn(proxy, "updateGlobalState") + + await proxy.setValue("mimoApiKey", "mimo-test-key") + + expect(storeSecretSpy).toHaveBeenCalledWith("mimoApiKey", "mimo-test-key") + expect(updateGlobalStateSpy).not.toHaveBeenCalledWith("mimoApiKey", expect.anything()) + expect(proxy.getSecret("mimoApiKey")).toBe("mimo-test-key") + expect(proxy.getValues()["mimoApiKey"]).toBe("mimo-test-key") + expect(proxy.getProviderSettings().mimoApiKey).toBe("mimo-test-key") + }) + it("should route global state keys to updateGlobalState", async () => { // Spy on updateGlobalState const updateGlobalStateSpy = vi.spyOn(proxy, "updateGlobalState") @@ -539,6 +554,58 @@ describe("ContextProxy", () => { }) }) + describe("mimoApiKey plaintext migration", () => { + const plaintextKey = "plaintext-mimo-key" + + const setupContext = ({ secretValue }: { secretValue: string | undefined }) => { + mockGlobalState.get.mockImplementation((key: string) => (key === "mimoApiKey" ? plaintextKey : undefined)) + mockSecrets.get.mockImplementation((key: string) => + Promise.resolve(key === "mimoApiKey" ? secretValue : "test-secret"), + ) + return mockContext + } + + it("moves a plaintext globalState key into SecretStorage and clears the plaintext copy", async () => { + clearAllMocks() + setupContext({ secretValue: undefined }) + + const migratedProxy = new ContextProxy(mockContext) + await migratedProxy.initialize() + + expect(mockSecrets.store).toHaveBeenCalledWith("mimoApiKey", plaintextKey) + expect(mockGlobalState.update).toHaveBeenCalledWith("mimoApiKey", undefined) + expect(migratedProxy.getSecret("mimoApiKey")).toBe(plaintextKey) + // Both the flat values view and the parsed provider settings must resolve + // the key from SecretStorage after the migration. + expect(migratedProxy.getValues()["mimoApiKey"]).toBe(plaintextKey) + expect(migratedProxy.getProviderSettings().mimoApiKey).toBe(plaintextKey) + }) + + it("keeps an existing SecretStorage value and only clears the plaintext copy", async () => { + clearAllMocks() + setupContext({ secretValue: "already-secret-value" }) + + const migratedProxy = new ContextProxy(mockContext) + await migratedProxy.initialize() + + expect(mockSecrets.store).not.toHaveBeenCalledWith("mimoApiKey", expect.anything()) + expect(mockGlobalState.update).toHaveBeenCalledWith("mimoApiKey", undefined) + expect(migratedProxy.getSecret("mimoApiKey")).toBe("already-secret-value") + }) + + it("does nothing when no plaintext key exists", async () => { + clearAllMocks() + mockGlobalState.get.mockImplementation(() => undefined) + mockSecrets.get.mockResolvedValue("test-secret") + + const freshProxy = new ContextProxy(mockContext) + await freshProxy.initialize() + + expect(mockSecrets.store).not.toHaveBeenCalled() + expect(mockGlobalState.update).not.toHaveBeenCalledWith("mimoApiKey", undefined) + }) + }) + describe("getProviderSettings", () => { it("should sanitize invalid apiProvider before parsing", async () => { // Reset and create a new proxy with an unknown provider in state diff --git a/src/extension/__tests__/api-configuration.spec.ts b/src/extension/__tests__/api-configuration.spec.ts index 80f8cad9cd..98decdb52e 100644 --- a/src/extension/__tests__/api-configuration.spec.ts +++ b/src/extension/__tests__/api-configuration.spec.ts @@ -1,6 +1,8 @@ import { describe, expect, it, vi } from "vitest" import type * as vscode from "vscode" +import { providerIdentifiers } from "@roo-code/types" + import { API } from "../api" import type { ClineProvider } from "../../core/webview/ClineProvider" @@ -62,4 +64,25 @@ describe("API - configuration", () => { expect(setModeConfig).not.toHaveBeenCalled() expect(postStateToWebview).toHaveBeenCalledOnce() }) + + it("excludes mimoApiKey from getConfiguration while keeping non-secret MiMo settings", () => { + const provider = { + context: {}, + on: vi.fn(), + getValues: () => ({ + apiProvider: providerIdentifiers.mimo, + mimoApiKey: "mimo-secret-key", + mimoBaseUrl: "https://token-plan-sgp.xiaomimimo.com/v1", + apiModelId: "mimo-v2.6-pro", + }), + } as unknown as ClineProvider + const outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel + const api = new API(outputChannel, provider) + + const configuration = api.getConfiguration() + + expect(configuration).not.toHaveProperty("mimoApiKey") + expect(configuration.mimoBaseUrl).toBe("https://token-plan-sgp.xiaomimimo.com/v1") + expect(configuration.apiModelId).toBe("mimo-v2.6-pro") + }) }) From b32c98f68760bfcf4bb9e974eee0e9701fc9b0eb Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sat, 3 Oct 2026 21:27:30 +0900 Subject: [PATCH 25/46] fix(settings): preserve a router-only model ID while the router query is unsettled What: getStaticCatalogSelection (deepseek/moonshot/mimo) now returns the configured apiModelId as-is whenever one is set, resolving info from the static catalog only when the ID is a static entry (undefined otherwise), instead of substituting the provider default for IDs absent from the static map. Why: for a router-only MiMo model ID, the loading/failed window resolved the selection to mimoDefaultModelId, and ApiOptions' apiModelId sync effect persisted that reset into cachedState - clicking Save baked the default into settings, contradicting the ready-path guard that admits router-only IDs against the merged catalog. Impact: the configured selection survives the unsettled window for all three static-catalog dynamic providers; capability UI shows its own fallbacks until router data lands (info undefined). New regression test parametrized across deepseek/moonshot/mimo for both the loading and the errored-no-cache states (mutation-verified: fails against the old helper, 3/3). --- .../hooks/__tests__/useSelectedModel.spec.ts | 24 +++++++++++++++++++ .../components/ui/hooks/useSelectedModel.ts | 20 ++++++++-------- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts index 02cad8e60c..bdce393ed3 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts @@ -740,6 +740,30 @@ describe("useSelectedModel", () => { expect(result.current.info?.maxTokens).toBe(131_072) }) + it.each([providerIdentifiers.deepseek, providerIdentifiers.moonshot, providerIdentifiers.mimo])( + "%s: preserves a router-only configured ID while the router query is unsettled", + (provider) => { + const routerOnlyModelId = `${provider}-router-only-unsettled` + // Loading (no cached data) and a failed fetch with no cache both leave + // the query unsettled. The configured ID exists only in the router + // catalog, so substituting the static default here would leak into + // ApiOptions' apiModelId sync effect and bake the default into saved + // settings on Save. Info stays undefined until router data lands. + for (const queryState of [{ isLoading: true }, { isError: true }] as const) { + mockUseRouterModels.mockReturnValue(createRouterModelsResult(undefined, queryState)) + mockUseOpenRouterModelProviders.mockReturnValue(createOpenRouterModelProvidersResult({})) + + const { result } = renderHook( + () => useSelectedModel({ apiProvider: provider, apiModelId: routerOnlyModelId }), + { wrapper: createWrapper() }, + ) + + expect(result.current.id).toBe(routerOnlyModelId) + expect(result.current.info).toBeUndefined() + } + }, + ) + it.each([ [providerIdentifiers.deepseek, deepSeekDefaultModelId, deepSeekModels[deepSeekDefaultModelId]], [providerIdentifiers.moonshot, moonshotDefaultModelId, moonshotModels[moonshotDefaultModelId]], diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index 3022459793..4cc17e74c2 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -85,6 +85,13 @@ const isStaticCatalogDynamicProvider = (provider: ProviderName): provider is Sta * context window rendered a bogus window size of 1 (`contextWindow || 1`). * Once router data arrives, getSelectedModel revalidates against the merged * catalog and takes over. + * + * The configured ID is preserved even when it is absent from the static + * catalog (a router-only model): while the query is unsettled, substituting + * the default here would leak into ApiOptions' apiModelId sync effect and + * silently bake the default into saved settings. Info stays undefined for IDs + * the static catalog cannot describe; capability UI falls back to its own + * defaults until router data lands. */ function getStaticCatalogSelection( provider: StaticCatalogDynamicProvider, @@ -93,22 +100,15 @@ function getStaticCatalogSelection( const configuredId = apiConfiguration.apiModelId switch (provider) { case providerIdentifiers.deepseek: { - const id = - configuredId && deepSeekModels[configuredId as keyof typeof deepSeekModels] - ? configuredId - : deepSeekDefaultModelId + const id = configuredId ?? deepSeekDefaultModelId return { id, info: deepSeekModels[id as keyof typeof deepSeekModels] } } case providerIdentifiers.moonshot: { - const id = - configuredId && moonshotModels[configuredId as keyof typeof moonshotModels] - ? configuredId - : moonshotDefaultModelId + const id = configuredId ?? moonshotDefaultModelId return { id, info: moonshotModels[id as keyof typeof moonshotModels] } } case providerIdentifiers.mimo: { - const id = - configuredId && mimoModels[configuredId as keyof typeof mimoModels] ? configuredId : mimoDefaultModelId + const id = configuredId ?? mimoDefaultModelId return { id, info: mimoModels[id as keyof typeof mimoModels] } } } From a073635f769a387a9c80860e954c812fcb2f06a5 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sat, 3 Oct 2026 21:30:58 +0900 Subject: [PATCH 26/46] feat(mimo): add a Refresh Models button to the MiMo settings What: Mimo.tsx now posts requestRouterModels with the provider-correlated MiMo credentials and handles the refresh lifecycle (loading / success / error) exactly like Moonshot, including unmount-safe listener cleanup and invalidation of only the MiMo and shared router-model query caches. Why: MiMo had no refresh sender at all, and the modelCache disk cache never expires - so a post-V2.6 release only became selectable after a cache-busting event elsewhere, undermining the dynamic-catalog goal of the PR. No sibling provider flushes on settings save, so there is no save-time mirror to add (verified: SettingsView posts no router flush). Impact: users can force a MiMo catalog refresh from settings; the allowlist gate in webviewMessageHandler still rejects off-list base URLs before any fetch is dispatched. New Mimo.spec.tsx mirrors Moonshot's refresh specs (12 tests: rendering, disabled/enabled, loading, success, error, other-provider isolation, pre-refresh ignore, message payload, cache invalidation, get-key link). --- .../components/settings/providers/Mimo.tsx | 106 +++++- .../providers/__tests__/Mimo.spec.tsx | 338 ++++++++++++++++++ 2 files changed, 442 insertions(+), 2 deletions(-) create mode 100644 webview-ui/src/components/settings/providers/__tests__/Mimo.spec.tsx diff --git a/webview-ui/src/components/settings/providers/Mimo.tsx b/webview-ui/src/components/settings/providers/Mimo.tsx index 9060b837d3..74f9804e48 100644 --- a/webview-ui/src/components/settings/providers/Mimo.tsx +++ b/webview-ui/src/components/settings/providers/Mimo.tsx @@ -1,10 +1,21 @@ -import { useCallback } from "react" +import { useCallback, useState, useEffect, useRef } from "react" import { VSCodeTextField, VSCodeDropdown, VSCodeOption } from "@vscode/webview-ui-toolkit/react" +import { useQueryClient } from "@tanstack/react-query" -import type { ProviderSettings } from "@roo-code/types" +import { + type ProviderSettings, + type ExtensionMessage, + providerIdentifiers, + allRouterModelsProvider, + RouterModelsMessageType, +} from "@roo-code/types" + +import { RouterName } from "@roo/api" import { useAppTranslation } from "@src/i18n/TranslationContext" import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink" +import { vscode } from "@src/utils/vscode" +import { Button } from "@src/components/ui" import { inputEventTransform } from "../transforms" import { cn } from "@/lib/utils" @@ -14,8 +25,50 @@ type MimoProps = { setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void } +enum RefreshStatus { + Idle = "idle", + Loading = "loading", + Success = "success", + Error = "error", +} + export const Mimo = ({ apiConfiguration, setApiConfigurationField }: MimoProps) => { const { t } = useAppTranslation() + const queryClient = useQueryClient() + const [refreshStatus, setRefreshStatus] = useState(RefreshStatus.Idle) + const [refreshError, setRefreshError] = useState() + const mimoErrorJustReceived = useRef(false) + + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + const message = event.data + if (message.type === RouterModelsMessageType.singleRouterModelFetchResponse && !message.success) { + const providerName = message.values?.provider as RouterName + if (providerName === providerIdentifiers.mimo && refreshStatus === RefreshStatus.Loading) { + mimoErrorJustReceived.current = true + setRefreshStatus(RefreshStatus.Error) + setRefreshError(message.error) + } + } else if (message.type === RouterModelsMessageType.routerModels) { + if (refreshStatus === RefreshStatus.Loading) { + if (!mimoErrorJustReceived.current) { + setRefreshStatus(RefreshStatus.Success) + void queryClient.invalidateQueries({ + queryKey: [RouterModelsMessageType.routerModels, providerIdentifiers.mimo], + }) + void queryClient.invalidateQueries({ + queryKey: [RouterModelsMessageType.routerModels, allRouterModelsProvider], + }) + } + } + } + } + + window.addEventListener("message", handleMessage) + return () => { + window.removeEventListener("message", handleMessage) + } + }, [refreshStatus, queryClient]) const handleInputChange = useCallback( ( @@ -28,6 +81,29 @@ export const Mimo = ({ apiConfiguration, setApiConfigurationField }: MimoProps) [setApiConfigurationField], ) + const handleRefreshModels = useCallback(() => { + mimoErrorJustReceived.current = false + setRefreshStatus(RefreshStatus.Loading) + setRefreshError(undefined) + + const key = apiConfiguration.mimoApiKey + + if (!key) { + setRefreshStatus(RefreshStatus.Error) + setRefreshError(t("settings:providers.refreshModels.missingConfig")) + return + } + + vscode.postMessage({ + type: RouterModelsMessageType.requestRouterModels, + values: { + provider: providerIdentifiers.mimo, + mimoApiKey: key, + mimoBaseUrl: apiConfiguration.mimoBaseUrl, + }, + }) + }, [apiConfiguration, t]) + return ( <>
@@ -68,6 +144,32 @@ export const Mimo = ({ apiConfiguration, setApiConfigurationField }: MimoProps) )}
+ + {refreshStatus === RefreshStatus.Loading && ( +
+ {t("settings:providers.refreshModels.loading")} +
+ )} + {refreshStatus === RefreshStatus.Success && ( +
{t("settings:providers.refreshModels.success")}
+ )} + {refreshStatus === RefreshStatus.Error && ( +
+ {refreshError || t("settings:providers.refreshModels.error")} +
+ )} ) } diff --git a/webview-ui/src/components/settings/providers/__tests__/Mimo.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/Mimo.spec.tsx new file mode 100644 index 0000000000..4857e516ec --- /dev/null +++ b/webview-ui/src/components/settings/providers/__tests__/Mimo.spec.tsx @@ -0,0 +1,338 @@ +// npx vitest src/components/settings/providers/__tests__/Mimo.spec.tsx + +import { render, screen, fireEvent, waitFor, act } from "@/utils/test-utils" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { allRouterModelsProvider, providerIdentifiers, type ProviderSettings } from "@roo-code/types" + +import { Mimo } from "../Mimo" + +// Mock the translation hook +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string) => key, + }), +})) + +// Mock VSCode webview toolkit components using importOriginal +vi.mock("@vscode/webview-ui-toolkit/react", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + VSCodeTextField: ({ children, value, onInput, type, placeholder }: any) => ( +
+ {children} + onInput?.(e)} + placeholder={placeholder} + data-testid="mimo-api-key-input" + /> +
+ ), + VSCodeDropdown: ({ children, value, onChange }: any) => ( +
+ {children} + +
+ ), + VSCodeOption: ({ children, value }: any) => ( +
+ {children} +
+ ), + } +}) + +// Mock vscode - factory must not reference outer scope variables +vi.mock("@src/utils/vscode", () => { + const mockPostMessage = vi.fn() + return { + vscode: { postMessage: mockPostMessage }, + } +}) + +// Mock @src/components/ui using importOriginal to get all real exports +vi.mock("@src/components/ui", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + Button: ({ children, onClick, disabled, variant }: any) => ( + + ), + } +}) + +// Mock VSCodeButtonLink +vi.mock("@src/components/common/VSCodeButtonLink", () => ({ + VSCodeButtonLink: ({ href, children }: any) => ( + + {children} + + ), +})) + +import { vscode } from "@src/utils/vscode" + +const findRefreshButton = () => + screen.getAllByTestId("button").find((b) => b.getAttribute("data-variant") === "outline") + +describe("Mimo Component", () => { + const mockSetApiConfigurationField = vi.fn() + + const createDefaultApiConfiguration = (overrides?: Partial): ProviderSettings => ({ + apiProvider: providerIdentifiers.mimo, + mimoBaseUrl: "https://token-plan-sgp.xiaomimimo.com/v1", + ...overrides, + }) + + beforeEach(() => { + vi.clearAllMocks() + }) + + it("renders API key input, base URL dropdown, and refresh button", () => { + render( + , + ) + + expect(screen.getByTestId("mimo-api-key-input")).toBeInTheDocument() + expect(screen.getByTestId("vscode-dropdown")).toBeInTheDocument() + expect(findRefreshButton()).toBeInTheDocument() + }) + + it("refresh button is disabled when no API key is provided", () => { + render( + , + ) + + expect(findRefreshButton()).toBeDisabled() + }) + + it("refresh button is enabled when API key is provided", () => { + render( + , + ) + + expect(findRefreshButton()).not.toBeDisabled() + }) + + it("shows loading state when refresh is clicked", () => { + render( + , + ) + + const refreshButton = findRefreshButton()! + fireEvent.click(refreshButton) + + expect(refreshButton).toBeDisabled() + expect(screen.getByText("settings:providers.refreshModels.loading")).toBeInTheDocument() + }) + + it("shows success state after routerModels message received", async () => { + render( + , + ) + + fireEvent.click(findRefreshButton()!) + + window.postMessage( + { + type: "routerModels", + values: { mimo: { "mimo-v2.6-pro": { maxTokens: 131072 } } }, + }, + "*", + ) + + await waitFor(() => { + expect(screen.getByText("settings:providers.refreshModels.success")).toBeInTheDocument() + }) + }) + + it("invalidates only the MiMo and shared router-model caches after a successful refresh", async () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") + + render( + + + , + ) + + fireEvent.click(findRefreshButton()!) + act(() => { + window.dispatchEvent(new MessageEvent("message", { data: { type: "routerModels" } })) + }) + + await waitFor(() => { + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: ["routerModels", providerIdentifiers.mimo], + }) + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: ["routerModels", allRouterModelsProvider], + }) + expect(invalidateQueries).not.toHaveBeenCalledWith({ queryKey: ["routerModels"] }) + }) + }) + + it("shows error state after a MiMo singleRouterModelFetchResponse error message", async () => { + render( + , + ) + + await act(async () => { + fireEvent.click(findRefreshButton()!) + }) + + await waitFor(() => { + expect(screen.getByText("settings:providers.refreshModels.loading")).toBeInTheDocument() + }) + + // Delay so the handler closure captures refreshStatus === "loading". + await act(async () => { + return new Promise((resolve) => { + setTimeout(() => { + window.postMessage( + { + type: "singleRouterModelFetchResponse", + success: false, + error: "API connection failed", + values: { provider: providerIdentifiers.mimo }, + }, + "*", + ) + resolve(undefined) + }, 0) + }) + }) + + await waitFor(() => { + expect(screen.getByText("API connection failed")).toBeInTheDocument() + }) + }) + + it("ignores another provider's failed refresh response while loading", async () => { + render( + , + ) + + fireEvent.click(findRefreshButton()!) + await waitFor(() => expect(screen.getByText("settings:providers.refreshModels.loading")).toBeInTheDocument()) + + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "singleRouterModelFetchResponse", + success: false, + error: "Moonshot unavailable", + values: { provider: providerIdentifiers.moonshot }, + }, + }), + ) + }) + + expect(screen.queryByText("Moonshot unavailable")).not.toBeInTheDocument() + expect(screen.getByText("settings:providers.refreshModels.loading")).toBeInTheDocument() + }) + + it("ignores a MiMo failure response before refresh starts", () => { + render( + , + ) + + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "singleRouterModelFetchResponse", + success: false, + error: "MiMo unavailable", + values: { provider: providerIdentifiers.mimo }, + }, + }), + ) + }) + + expect(screen.queryByText("MiMo unavailable")).not.toBeInTheDocument() + expect(screen.queryByText("settings:providers.refreshModels.loading")).not.toBeInTheDocument() + }) + + it("sends the provider-correlated MiMo credentials when refresh is clicked", () => { + render( + , + ) + + fireEvent.click(findRefreshButton()!) + + expect(vscode.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "requestRouterModels", + values: { + provider: providerIdentifiers.mimo, + mimoApiKey: "test-key", + mimoBaseUrl: "https://token-plan-ams.xiaomimimo.com/v1", + }, + }), + ) + }) + + it("shows 'Get MiMo API Key' link when no API key is set", () => { + render( + , + ) + + expect(screen.getByTestId("vscode-button-link")).toBeInTheDocument() + expect(screen.getByTestId("vscode-button-link")).toHaveAttribute("href", "https://platform.xiaomimimo.com") + }) + + it("hides 'Get MiMo API Key' link when API key is set", () => { + render( + , + ) + + expect(screen.queryByTestId("vscode-button-link")).not.toBeInTheDocument() + }) +}) From fbcd2fe6a76ab4d99924729d905efccac99ffb2f Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sat, 3 Oct 2026 21:32:20 +0900 Subject: [PATCH 27/46] test(mimo): pin static-spec precedence over conflicting API payload fields What: new fetcher test feeds a /models entry for a known static ID whose payload fields (description, prices, windows, image support) all conflict with the curated spec, plus an API-only ID. Why: the merge emits {...knownSpecs} for known IDs and family defaults for unknown ones, so payload fields can never leak into the catalog - but only the positive merge path was tested. A regression that spreads payload fields over the static spec (or drops API-only models) would silently corrupt the picker and pricing. Impact: test-only; 30/30 fetcher tests pass. --- .../providers/fetchers/__tests__/mimo.spec.ts | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/api/providers/fetchers/__tests__/mimo.spec.ts b/src/api/providers/fetchers/__tests__/mimo.spec.ts index 0a949ee643..724307b40f 100644 --- a/src/api/providers/fetchers/__tests__/mimo.spec.ts +++ b/src/api/providers/fetchers/__tests__/mimo.spec.ts @@ -65,6 +65,44 @@ describe("getMimoModels", () => { }) }) + it("keeps the static spec authoritative for known IDs while still surfacing API-only models", async () => { + // Negative precedence: the /models payload may carry fields that conflict + // with the curated static spec (stale prices, wrong windows, injected + // text). For a known ID every emitted field must come from mimoModels, + // never from the wire payload; an API-only ID must still appear. + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + data: [ + { + id: "mimo-v2.6-pro", + description: "CONFLICTING payload description", + contextWindow: 999, + maxTokens: 999, + inputPrice: 999, + outputPrice: 999, + supportsImages: false, + }, + { id: "mimo-v9-only-in-api", description: "CONFLICTING payload description", contextWindow: 12345 }, + ], + }), + }) as unknown as typeof fetch + + const models = await getMimoModels("https://token-plan-sgp.xiaomimimo.com/v1", "mock-key") + + expect(models["mimo-v2.6-pro"]).toEqual(mimoModels["mimo-v2.6-pro"]) + expect(models["mimo-v2.6-pro"].description).not.toContain("CONFLICTING") + + expect(models["mimo-v9-only-in-api"]).toEqual({ + maxTokens: 16_000, + contextWindow: 262_144, + supportsImages: false, + supportsPromptCache: false, + preserveReasoning: true, + description: "MiMo model: mimo-v9-only-in-api", + }) + }) + it("throws for HTTP errors", async () => { globalThis.fetch = vi.fn().mockResolvedValue({ ok: false, From 06cea82cf86c8b878927f44c3e312290718efee5 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sat, 3 Oct 2026 21:34:41 +0900 Subject: [PATCH 28/46] fix(mimo): log response metadata only on fetcher error paths What: the HTTP-error log no longer reads and includes the response body, and the unexpected-format log records the shape (types) instead of the raw payload. Why: error bodies can echo credentials or conversation fragments back at whatever sink hosts the logs; status, statusText, URL, and payload shape are enough to diagnose failures. Impact: thrown errors unchanged (HTTP status message and format message identical); behavior change is log-content only. --- src/api/providers/fetchers/mimo.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/api/providers/fetchers/mimo.ts b/src/api/providers/fetchers/mimo.ts index 95cca57be0..6106b3f8eb 100644 --- a/src/api/providers/fetchers/mimo.ts +++ b/src/api/providers/fetchers/mimo.ts @@ -99,18 +99,12 @@ export async function getMimoModels( }) if (!response.ok) { - let errorBody = "" - try { - errorBody = await response.text() - } catch { - errorBody = "(unable to read response body)" - } - + // Metadata only: response bodies can echo credentials or conversation + // fragments back at whatever sinks the logs, so the body is never logged. console.error(`[getMimoModels] HTTP error:`, { status: response.status, statusText: response.statusText, url: url.toString(), - body: errorBody, }) throw new Error(`HTTP ${response.status}: ${response.statusText}`) @@ -119,7 +113,11 @@ export async function getMimoModels( const data = await response.json() if (!data?.data || !Array.isArray(data.data)) { - console.error("[getMimoModels] Unexpected response format:", data) + // Same reason: log the shape mismatch, never the payload. + console.error("[getMimoModels] Unexpected response format:", { + receivedType: Array.isArray(data) ? "array" : typeof data, + dataFieldType: data?.data === null ? "null" : typeof data?.data, + }) throw new Error("Failed to fetch MiMo models: Unexpected response format.") } From aa032f135488bdf6c16f2c08eaca03494f42ba68 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sat, 3 Oct 2026 21:51:53 +0900 Subject: [PATCH 29/46] fix(mimo): clear CodeQL alerts on the round-2 digest memo and base-URL regex What: (1) remove the cacheDigestCache memo layer in modelCache.ts -- deriveCacheDigest now calls PBKDF2 directly; (2) replace the input-driven trailing-slash regex with a plain string loop, exported as stripTrailingSlashes from fetchers/mimo.ts and used by the chat-completion gate, the fetcher, and both webviewMessageHandler mimoBaseUrl gates. Why: CodeQL flagged js/insufficient-password-hash on the sha256 memo key hashing API-key-tainted input (the lgtm comment did not suppress it), and js/polynomial-redos on mimo.ts's replace(/\/+$/, ""). The digest callers are catalog-fetch-path only, so per-call 10k-iteration PBKDF2 costs a few ms and removal eliminates both the alert and the key-retention concern. The loop is exactly equivalent to the regex (empty string terminates it). Impact: no functional change -- same digests, same cache keys, same allowlist decisions; multi-slash and single-slash normalization pinned by the existing 261 tests in the touched suites. Remaining /\/+$/ regexes in litellm/moonshot/modelCache-urlPart/extension/qdrant files are upstream-untouched and out of the PR diff. --- src/api/providers/fetchers/mimo.ts | 14 +++++++++++- src/api/providers/fetchers/modelCache.ts | 28 ++++++++--------------- src/api/providers/mimo.ts | 4 ++-- src/core/webview/webviewMessageHandler.ts | 6 ++--- 4 files changed, 27 insertions(+), 25 deletions(-) diff --git a/src/api/providers/fetchers/mimo.ts b/src/api/providers/fetchers/mimo.ts index 6106b3f8eb..26ba7b3697 100644 --- a/src/api/providers/fetchers/mimo.ts +++ b/src/api/providers/fetchers/mimo.ts @@ -42,6 +42,18 @@ function containsUserinfo(rawUrl: string): boolean { return authority.includes("@") } +// Trailing-slash normalization as a plain string loop, not a regex: CodeQL's +// polynomial-redos family flags input-driven regexes, and a loop is exactly +// equivalent here (every allowlisted literal carries no trailing slash, and the +// empty string terminates the loop). +export function stripTrailingSlashes(value: string): string { + let result = value + while (result.endsWith("/")) { + result = result.slice(0, -1) + } + return result +} + /** * Fetches available models from the Xiaomi MiMo API and merges them with known specs. * @@ -60,7 +72,7 @@ export async function getMimoModels( // MiMo API uses OpenAI-compatible /v1/models endpoint. // The base URL from settings already includes /v1 (e.g. https://token-plan-sgp.xiaomimimo.com/v1), // so we keep it as-is and append /models directly. - const base = (baseUrl || MIMO_DEFAULT_BASE_URL).replace(/\/+$/, "") + const base = stripTrailingSlashes(baseUrl || MIMO_DEFAULT_BASE_URL) // Reject embedded credentials first, then pin the request to the four allowed // endpoints with an exact match. This subsumes the previous https-only guard diff --git a/src/api/providers/fetchers/modelCache.ts b/src/api/providers/fetchers/modelCache.ts index 7ea6d75c30..d335256ea4 100644 --- a/src/api/providers/fetchers/modelCache.ts +++ b/src/api/providers/fetchers/modelCache.ts @@ -1,7 +1,7 @@ import * as path from "path" import fs from "fs/promises" import * as fsSync from "fs" -import { createHash, pbkdf2Sync } from "crypto" +import { pbkdf2Sync } from "crypto" import NodeCache from "node-cache" import { z } from "zod" @@ -144,13 +144,6 @@ function isAuthScopedProvider(provider: RouterName): boolean { return AUTH_SCOPED_PROVIDERS.has(provider) } -// Memoize derived digests so the deliberately-structureless KDF runs at most once per -// distinct input per session (getCacheKey / cacheKeyToFilename run on every cache lookup). -// Keys are digests of the input, never the raw value: deriveApiKeyDiscriminator feeds -// raw API keys through here, and retaining those as long-lived map keys would keep the -// secrets alive for the whole session. -const cacheDigestCache = new Map() - // Fixed, non-secret application salt. This is NOT credential storage: it derives short, // stable cache-key components from the API key and the compound cache key so that distinct // inputs map to distinct cache entries / filenames. PBKDF2 is used (over a plain hash) only @@ -171,19 +164,16 @@ const CACHE_DIGEST_ITERATIONS = 10_000 * ~ n^2 / 2^(8*bytes)), while the truncated output is small enough that any preimage search * yields an astronomically large set of candidate inputs -- so a value written to an on-disk * cache filename cannot be reversed to identify the API key it was derived from. + * + * No memoization layer: the only callers (getCacheKey / cacheKeyToFilename) run on the + * catalog-fetch path, which is user- or scheduler-driven and infrequent, so a 10k-iteration + * PBKDF2 per call costs a few ms. A fast-hash memo key over API-key-tainted input is not + * worth it -- that pattern retains key-derived state for the session's lifetime and trips + * CodeQL's js/insufficient-password-hash. PBKDF2 remains the only derivation whose output + * leaves this process (cache keys and on-disk filenames). */ function deriveCacheDigest(value: string, bytes: number): string { - // The memo key is a fast, non-reversing digest of the input so the raw API key - // is processed but never retained (see cacheDigestCache above). It lives only - // in this in-memory map and is never persisted or compared across processes, - // so the expensive KDF below remains the only derivation whose output leaves - // this process (cache keys and on-disk filenames). - const memoKey = createHash("sha256").update(`${bytes}:${value}`).digest("hex") // lgtm[js/insufficient-password-hash] - const cached = cacheDigestCache.get(memoKey) - if (cached) return cached - const digest = pbkdf2Sync(value, CACHE_DIGEST_SALT, CACHE_DIGEST_ITERATIONS, bytes, "sha256").toString("hex") - cacheDigestCache.set(memoKey, digest) - return digest + return pbkdf2Sync(value, CACHE_DIGEST_SALT, CACHE_DIGEST_ITERATIONS, bytes, "sha256").toString("hex") } // 4 bytes (8 hex chars) = 32 bits for the per-API-key discriminator embedded in the cache key. diff --git a/src/api/providers/mimo.ts b/src/api/providers/mimo.ts index 4cac458aa2..2ea4573181 100644 --- a/src/api/providers/mimo.ts +++ b/src/api/providers/mimo.ts @@ -16,7 +16,7 @@ import { NOT_PROVIDED } from "./constants" import type { ApiHandlerCreateMessageMetadata } from "../index" import { sanitizeOpenAiCallId } from "../../utils/tool-id" -import { ALLOWED_BASE_URLS, MIMO_DEFAULT_BASE_URL } from "./fetchers/mimo" +import { ALLOWED_BASE_URLS, MIMO_DEFAULT_BASE_URL, stripTrailingSlashes } from "./fetchers/mimo" /** * MiMoHandler extends OpenAiHandler with MiMo-specific adaptations. @@ -38,7 +38,7 @@ export class MimoHandler extends OpenAiHandler { // back to the default cluster; trailing slashes are normalized for the // check exactly like the model fetcher does. const mimoBaseUrl = options.mimoBaseUrl || MIMO_DEFAULT_BASE_URL - if (!ALLOWED_BASE_URLS.has(mimoBaseUrl.replace(/\/+$/, ""))) { + if (!ALLOWED_BASE_URLS.has(stripTrailingSlashes(mimoBaseUrl))) { throw new Error( "MIMO/MimoHandler/001: MiMo chat completion rejected: base URL is not an allowed Xiaomi MiMo endpoint.", ) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index a063d5d3d4..0f9bf68d3c 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -91,7 +91,7 @@ import { generateSystemPrompt } from "./generateSystemPrompt" import { resolveDefaultSaveUri, saveLastExportPath } from "../../utils/export" import { getCommand } from "../../utils/commands" import { getLMStudioModels } from "../../api/providers/fetchers/lmstudio" -import { ALLOWED_BASE_URLS } from "../../api/providers/fetchers/mimo" +import { ALLOWED_BASE_URLS, stripTrailingSlashes } from "../../api/providers/fetchers/mimo" const ALLOWED_VSCODE_SETTINGS = new Set(["terminal.integrated.inheritEnv"]) @@ -865,7 +865,7 @@ export const webviewMessageHandler = async ( // the chat-completion client via persisted state. Trailing slashes // are tolerated for the check and canonicalized away on save. if (typeof value === "string" && value !== "") { - const normalized = value.replace(/\/+$/, "") + const normalized = stripTrailingSlashes(value) if (!ALLOWED_BASE_URLS.has(normalized)) { console.warn( "[webviewMessageHandler] Rejected mimoBaseUrl outside the allowed Xiaomi MiMo endpoints; the value was not persisted.", @@ -1315,7 +1315,7 @@ export const webviewMessageHandler = async ( // allowlisted literal contains userinfo), and the raw value is never // echoed since an unsaved one may embed credentials. const mimoBaseUrlRejected = - unsavedMimoBaseUrl !== undefined && !ALLOWED_BASE_URLS.has(unsavedMimoBaseUrl.replace(/\/+$/, "")) + unsavedMimoBaseUrl !== undefined && !ALLOWED_BASE_URLS.has(stripTrailingSlashes(unsavedMimoBaseUrl)) if (mimoApiKey) { if (mimoBaseUrlRejected) { From c7d444743379ec5b0ee51df4985313555b50b6d3 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sat, 3 Oct 2026 22:28:21 +0900 Subject: [PATCH 30/46] fix: final review polish for the mimo dynamic-catalog PR What: (1) restore a bounded FIFO memo (cap 16, keyed by the raw input) for deriveCacheDigest so the per-message hot path (PoeHandler.getModel -> getModelsFromCache -> getCacheKey) does not run a 10k-iteration PBKDF2 per request; (2) migratePlaintextMimoApiKey checks SecretStorage with a fresh read instead of the initialize() snapshot and repairs the cache from it; (3) getStaticCatalogSelection falls back with || (repo convention) so an empty-string apiModelId resolves to the default; (4) document the as-unknown-as double assertion in api-configuration.spec. Why: removing the memo entirely (round 3) moved KDF cost onto a hot per-message path; the snapshot-based migration guard could clobber a secret written after the snapshot; ?? preserved an empty-string ID that || convention treats as unset; the double assertion lacked the comment AGENTS.md requires. Impact: same digest outputs and cache keys, now memoized across repeat per-request calls with a bounded retention window (no fast-hash sink, so the CodeQL alert stays cleared); migration stays idempotent and crash-safe with a fresher overwrite guard. New tests: memo hit / fresh derivation / FIFO eviction + recent retention (pbkdf2Sync call-count spy), fresh-read migration path, empty-ID unsettled fallback. --- .../fetchers/__tests__/modelCache.spec.ts | 65 +++++++++++++++++++ src/api/providers/fetchers/modelCache.ts | 37 +++++++++-- src/core/config/ContextProxy.ts | 17 ++++- .../config/__tests__/ContextProxy.spec.ts | 25 +++++++ .../__tests__/api-configuration.spec.ts | 3 + .../hooks/__tests__/useSelectedModel.spec.ts | 18 +++++ .../components/ui/hooks/useSelectedModel.ts | 6 +- 7 files changed, 158 insertions(+), 13 deletions(-) diff --git a/src/api/providers/fetchers/__tests__/modelCache.spec.ts b/src/api/providers/fetchers/__tests__/modelCache.spec.ts index 9f14ae0be5..518765f994 100644 --- a/src/api/providers/fetchers/__tests__/modelCache.spec.ts +++ b/src/api/providers/fetchers/__tests__/modelCache.spec.ts @@ -40,6 +40,13 @@ vi.mock("fs", () => ({ readFileSync: vi.fn().mockReturnValue("{}"), })) +// Wrap (not replace) pbkdf2Sync so tests can count KDF invocations while real digests +// are still produced. +vi.mock("crypto", async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, pbkdf2Sync: vi.fn(actual.pbkdf2Sync) } +}) + // Mock all the model fetchers vi.mock("../litellm") vi.mock("../openrouter") @@ -67,6 +74,7 @@ import type { Mock, Mocked } from "vitest" import type { ModelRecord } from "@roo-code/types" import { providerIdentifiers } from "@roo-code/types" import * as fsSync from "fs" +import { pbkdf2Sync } from "crypto" import NodeCache from "node-cache" import { TelemetryService } from "@roo-code/telemetry" import { getModels, getModelsFromCache } from "../modelCache" @@ -87,6 +95,7 @@ const mockGetNanoGptModels = getNanoGptModels as Mock const mockGetMoonshotModels = getMoonshotModels as Mock const mockGetMimoModels = getMimoModels as Mock const mockGetZooGatewayModels = getZooGatewayModels as Mock +const mockPbkdf2Sync = vi.mocked(pbkdf2Sync) const DUMMY_REQUESTY_KEY = "requesty-key-for-testing" @@ -1137,6 +1146,62 @@ describe("key-scoped cache key derivation", () => { }) }) +describe("cache digest derivation memoization", () => { + // The bounded FIFO memo exists because getCacheKey/deriveApiKeyDiscriminator run on the + // per-message hot path (PoeHandler.getModel -> getModelsFromCache -> getCacheKey): the KDF + // must not run per request, but the memo must not grow unbounded either. Keys are unique + // per test because the memo is module-level state shared across the file. + const memoProvider = providerIdentifiers.requesty + const memoModels = { + "memo/model": { maxTokens: 4096, contextWindow: 200_000, supportsPromptCache: false }, + } + + beforeEach(() => { + vi.clearAllMocks() + mockGetRequestyModels.mockResolvedValue(memoModels) + const mockCache = vi.mocked(new (vi.mocked(NodeCache))()) + mockCache.get.mockReturnValue(undefined) + }) + + it("reuses the derived digest across repeated calls with the same key", async () => { + await getModels({ provider: memoProvider, apiKey: "memo-repeat-key" }) + const afterFirst = mockPbkdf2Sync.mock.calls.length + expect(afterFirst).toBeGreaterThan(0) + + // Per-request cache lookups with the same key must not re-run the KDF. + await getModels({ provider: memoProvider, apiKey: "memo-repeat-key" }) + await getModels({ provider: memoProvider, apiKey: "memo-repeat-key" }) + + expect(mockPbkdf2Sync.mock.calls.length).toBe(afterFirst) + }) + + it("derives a fresh digest for each distinct key", async () => { + await getModels({ provider: memoProvider, apiKey: "memo-distinct-a" }) + const afterA = mockPbkdf2Sync.mock.calls.length + expect(afterA).toBeGreaterThan(0) + + await getModels({ provider: memoProvider, apiKey: "memo-distinct-b" }) + expect(mockPbkdf2Sync.mock.calls.length).toBeGreaterThan(afterA) + }) + + it("bounds the memo and recomputes inputs that fell out of the FIFO window", async () => { + // Each call introduces two memo inputs (the raw key and its compound cache key), + // so 12 distinct keys overflow the 16-entry cap: the earliest pairs are evicted + // while the most recent ones are retained. + for (let i = 0; i < 12; i++) { + await getModels({ provider: memoProvider, apiKey: `memo-flood-${i}` }) + } + + const beforeEvicted = mockPbkdf2Sync.mock.calls.length + await getModels({ provider: memoProvider, apiKey: "memo-flood-0" }) + expect(mockPbkdf2Sync.mock.calls.length).toBeGreaterThan(beforeEvicted) + + const beforeRecent = mockPbkdf2Sync.mock.calls.length + await getModels({ provider: memoProvider, apiKey: "memo-flood-11" }) + expect(mockPbkdf2Sync.mock.calls.length).toBe(beforeRecent) + }) +}) + describe("NanoGPT key-scoped cache isolation", () => { const nanoGptModels = { "openai/gpt-5.6-sol": { maxTokens: 128000, contextWindow: 1050000, supportsPromptCache: false }, diff --git a/src/api/providers/fetchers/modelCache.ts b/src/api/providers/fetchers/modelCache.ts index d335256ea4..ed61ea78e4 100644 --- a/src/api/providers/fetchers/modelCache.ts +++ b/src/api/providers/fetchers/modelCache.ts @@ -144,6 +144,15 @@ function isAuthScopedProvider(provider: RouterName): boolean { return AUTH_SCOPED_PROVIDERS.has(provider) } +// Recently derived digests, so the 10k-iteration KDF does not run per request: +// getCacheKey/deriveApiKeyDiscriminator sit on the per-message hot path (e.g. +// PoeHandler.getModel -> getModelsFromCache -> getCacheKey). Bounded FIFO: once +// more than MAX_CACHED_DIGESTS distinct inputs have been seen, the oldest entry +// is dropped, so at most this many raw key strings are retained beyond their +// original owners. +const MAX_CACHED_DIGESTS = 16 +const cacheDigestCache = new Map() + // Fixed, non-secret application salt. This is NOT credential storage: it derives short, // stable cache-key components from the API key and the compound cache key so that distinct // inputs map to distinct cache entries / filenames. PBKDF2 is used (over a plain hash) only @@ -165,15 +174,29 @@ const CACHE_DIGEST_ITERATIONS = 10_000 * yields an astronomically large set of candidate inputs -- so a value written to an on-disk * cache filename cannot be reversed to identify the API key it was derived from. * - * No memoization layer: the only callers (getCacheKey / cacheKeyToFilename) run on the - * catalog-fetch path, which is user- or scheduler-driven and infrequent, so a 10k-iteration - * PBKDF2 per call costs a few ms. A fast-hash memo key over API-key-tainted input is not - * worth it -- that pattern retains key-derived state for the session's lifetime and trips - * CodeQL's js/insufficient-password-hash. PBKDF2 remains the only derivation whose output - * leaves this process (cache keys and on-disk filenames). + * No fast-hash memo key: the map is keyed by the raw input because the API key is + * already resident in memory as handler options, and bounding the map keeps the extra + * retention window tiny. A fast hash of key-tainted input would only re-introduce the + * CodeQL js/insufficient-password-hash sink. PBKDF2 remains the only derivation whose + * output leaves this process (cache keys and on-disk filenames). */ function deriveCacheDigest(value: string, bytes: number): string { - return pbkdf2Sync(value, CACHE_DIGEST_SALT, CACHE_DIGEST_ITERATIONS, bytes, "sha256").toString("hex") + const memoKey = `${bytes}:${value}` + const cached = cacheDigestCache.get(memoKey) + if (cached) { + return cached + } + const digest = pbkdf2Sync(value, CACHE_DIGEST_SALT, CACHE_DIGEST_ITERATIONS, bytes, "sha256").toString("hex") + cacheDigestCache.set(memoKey, digest) + // FIFO eviction: bound the retention window so plaintext keys never accumulate + // for the whole session. + if (cacheDigestCache.size > MAX_CACHED_DIGESTS) { + const oldest = cacheDigestCache.keys().next().value + if (oldest !== undefined) { + cacheDigestCache.delete(oldest) + } + } + return digest } // 4 bytes (8 hex chars) = 32 bits for the per-API-key discriminator embedded in the cache key. diff --git a/src/core/config/ContextProxy.ts b/src/core/config/ContextProxy.ts index 0a9bb2a9a6..a14c1f47be 100644 --- a/src/core/config/ContextProxy.ts +++ b/src/core/config/ContextProxy.ts @@ -115,8 +115,14 @@ export class ContextProxy { * MiMo shipped with its API key in plaintext globalState. Once the key became a * secret-state key, globalState stopped being read for it, so an existing * plaintext value would sit orphaned (and exposed) while SecretStorage stayed - * empty. Move it into SecretStorage once, without overwriting a value that is - * already there, and clear the plaintext copy. + * empty. Move it into SecretStorage once and clear the plaintext copy. + * + * The existence check reads SecretStorage fresh rather than trusting the + * initialize() snapshot: a secret written after the snapshot (or a transient + * secrets.get failure during the load) would otherwise be clobbered by older + * plaintext. Crash-safe: the plaintext is only cleared after the checks and + * store resolve, so a failure leaves both copies intact and the migration + * retries on next launch. */ private async migratePlaintextMimoApiKey() { try { @@ -124,10 +130,15 @@ export class ContextProxy { if (!plaintextKey) { return } - if (!this.secretCache.mimoApiKey) { + const existingSecret = await this.originalContext.secrets.get("mimoApiKey") + if (!existingSecret) { await this.originalContext.secrets.store("mimoApiKey", plaintextKey) this.secretCache.mimoApiKey = plaintextKey logger.info("Migrated mimoApiKey to secrets") + } else { + // Repair the snapshot cache if the fresh read found a secret the + // initial load missed. + this.secretCache.mimoApiKey = existingSecret } await this.originalContext.globalState.update("mimoApiKey", undefined) } catch (error) { diff --git a/src/core/config/__tests__/ContextProxy.spec.ts b/src/core/config/__tests__/ContextProxy.spec.ts index a86c29c407..88c8e770b2 100644 --- a/src/core/config/__tests__/ContextProxy.spec.ts +++ b/src/core/config/__tests__/ContextProxy.spec.ts @@ -593,6 +593,31 @@ describe("ContextProxy", () => { expect(migratedProxy.getSecret("mimoApiKey")).toBe("already-secret-value") }) + it("trusts a fresh SecretStorage read over the stale snapshot before deciding to store", async () => { + // Simulates a secret written after the initialize() snapshot (or a transient + // secrets.get failure during the load): the snapshot sees no secret, the + // migration's fresh read sees one. The newer secret must win and the cache + // must be repaired with it. + clearAllMocks() + let mimoGetCount = 0 + mockGlobalState.get.mockImplementation((key: string) => (key === "mimoApiKey" ? plaintextKey : undefined)) + mockSecrets.get.mockImplementation((key: string) => { + if (key !== "mimoApiKey") { + return Promise.resolve("test-secret") + } + mimoGetCount += 1 + return Promise.resolve(mimoGetCount === 1 ? undefined : "freshly-written-secret") + }) + + const migratedProxy = new ContextProxy(mockContext) + await migratedProxy.initialize() + + expect(mockSecrets.store).not.toHaveBeenCalledWith("mimoApiKey", expect.anything()) + expect(mockGlobalState.update).toHaveBeenCalledWith("mimoApiKey", undefined) + expect(migratedProxy.getSecret("mimoApiKey")).toBe("freshly-written-secret") + expect(migratedProxy.getValues()["mimoApiKey"]).toBe("freshly-written-secret") + }) + it("does nothing when no plaintext key exists", async () => { clearAllMocks() mockGlobalState.get.mockImplementation(() => undefined) diff --git a/src/extension/__tests__/api-configuration.spec.ts b/src/extension/__tests__/api-configuration.spec.ts index 98decdb52e..147047c16b 100644 --- a/src/extension/__tests__/api-configuration.spec.ts +++ b/src/extension/__tests__/api-configuration.spec.ts @@ -75,6 +75,9 @@ describe("API - configuration", () => { mimoBaseUrl: "https://token-plan-sgp.xiaomimimo.com/v1", apiModelId: "mimo-v2.6-pro", }), + // Double assertion: this filter-focused spec only needs the two members + // the API surface touches; a full ClineProvider test double is out of + // scope (the upstream siblings above use the same pattern). } as unknown as ClineProvider const outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel const api = new API(outputChannel, provider) diff --git a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts index bdce393ed3..2937179b75 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts @@ -764,6 +764,24 @@ describe("useSelectedModel", () => { }, ) + it.each([ + [providerIdentifiers.deepseek, deepSeekDefaultModelId], + [providerIdentifiers.moonshot, moonshotDefaultModelId], + [providerIdentifiers.mimo, mimoDefaultModelId], + ])("%s: treats an empty configured ID as unset while the router query is unsettled", (provider, defaultId) => { + // Repo convention (cf. router-provider fallbacks) is `||`, so an + // empty-string apiModelId resolves to the default instead of being + // preserved as a phantom selection. + mockUseRouterModels.mockReturnValue(createRouterModelsResult(undefined, { isLoading: true })) + mockUseOpenRouterModelProviders.mockReturnValue(createOpenRouterModelProvidersResult({})) + + const { result } = renderHook(() => useSelectedModel({ apiProvider: provider, apiModelId: "" }), { + wrapper: createWrapper(), + }) + + expect(result.current.id).toBe(defaultId) + }) + it.each([ [providerIdentifiers.deepseek, deepSeekDefaultModelId, deepSeekModels[deepSeekDefaultModelId]], [providerIdentifiers.moonshot, moonshotDefaultModelId, moonshotModels[moonshotDefaultModelId]], diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index 4cc17e74c2..02a496ddbb 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -100,15 +100,15 @@ function getStaticCatalogSelection( const configuredId = apiConfiguration.apiModelId switch (provider) { case providerIdentifiers.deepseek: { - const id = configuredId ?? deepSeekDefaultModelId + const id = configuredId || deepSeekDefaultModelId return { id, info: deepSeekModels[id as keyof typeof deepSeekModels] } } case providerIdentifiers.moonshot: { - const id = configuredId ?? moonshotDefaultModelId + const id = configuredId || moonshotDefaultModelId return { id, info: moonshotModels[id as keyof typeof moonshotModels] } } case providerIdentifiers.mimo: { - const id = configuredId ?? mimoDefaultModelId + const id = configuredId || mimoDefaultModelId return { id, info: mimoModels[id as keyof typeof mimoModels] } } } From c9cea124cd7456383d6869a8b7bf67ac1aedbff1 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Fri, 9 Oct 2026 08:52:02 +0900 Subject: [PATCH 31/46] test(mimo): pass providerFactory to API constructor in config spec Upstream PR #1946 added a required providerFactory parameter to the API constructor; the MiMo getConfiguration test still called it with two arguments, failing the compile CI job with TS2554. --- src/extension/__tests__/api-configuration.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/extension/__tests__/api-configuration.spec.ts b/src/extension/__tests__/api-configuration.spec.ts index cd20cbc42c..6cc6c72c7a 100644 --- a/src/extension/__tests__/api-configuration.spec.ts +++ b/src/extension/__tests__/api-configuration.spec.ts @@ -81,7 +81,7 @@ describe("API - configuration", () => { // scope (the upstream siblings above use the same pattern). } as unknown as ClineProvider const outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel - const api = new API(outputChannel, provider) + const api = new API(outputChannel, provider, makeClineProviderFactory()) const configuration = api.getConfiguration() From b9c05ebe041e2f17ab18ae123aca9d0c72c10b78 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Fri, 30 Oct 2026 09:58:41 +0900 Subject: [PATCH 32/46] fix(mimo): gate the resolved base URL, not just the unsaved one What: requestRouterModels now rejects an off-list MiMo base URL whether it came from the unsaved form values or from stored settings. Why: The merge re-ported flushModels() onto the (options, handler, refresh) signature, so the MiMo refresh path builds its handler eagerly at the call site. MimoHandler's constructor throws on a base URL outside the four allowed Xiaomi endpoints, and that throw escaped the whole requestRouterModels case: the webview received neither the MiMo failure response nor the aggregate routerModels response, so the Refresh Models button hung until its 10s timeout. A stored off-list value is reachable because ContextProxy.getProviderSettings() fails open on a schema rejection, so a value written by a pre-allowlist build survives. Impact: MiMo refresh with an off-list stored endpoint now posts the documented MIMO/requestRouterModels/001 failure response and still posts the aggregate response. Adds a regression spec that fails on the pre-fix guard. --- ...webviewMessageHandler.routerModels.spec.ts | 53 +++++++++++++++++++ src/core/webview/webviewMessageHandler.ts | 12 ++++- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts index 85894b8685..dd7e3a4306 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts @@ -993,6 +993,59 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { expect(response[0].routerModels.mimo).toEqual({}) }) + // Regression: flushModels() gained an eagerly-built handler argument, so an + // off-list STORED mimoBaseUrl made MimoHandler's constructor throw from inside + // this case. That throw escaped requestRouterModels entirely, so the webview got + // neither the MiMo failure response nor the aggregate routerModels response (the + // refresh button just hung until its 10s timeout). A stored value can be off-list + // because ContextProxy.getProviderSettings() fails open on a schema rejection. + it("rejects an off-list STORED MiMo base URL without letting the handler constructor throw", async () => { + mockProvider.getState.mockResolvedValue({ + apiConfiguration: { + mimoApiKey: "stored-mimo-key", + mimoBaseUrl: "https://attacker.example/v1", + }, + }) + + // Mirrors Mimo.tsx handleRefreshModels: provider + key, no refresh flag. + await expect( + webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.requestRouterModels, + values: { provider: providerIdentifiers.mimo, mimoApiKey: "stored-mimo-key" }, + }), + ).resolves.toBeUndefined() + + // The off-list stored URL must never reach modelCache: no flush (which would + // build the throwing handler) and no fetch. + const mimoFlushCalls = flushModelsMock.mock.calls.filter((c) => c[0]?.provider === providerIdentifiers.mimo) + expect(mimoFlushCalls.length).toBe(0) + const mimoCalls = getModelsMock.mock.calls.filter((c) => c[0]?.provider === providerIdentifiers.mimo) + expect(mimoCalls.length).toBe(0) + + const errorCall = mockProvider.postMessageToWebview.mock.calls.find( + (call) => + call[0]?.type === RouterModelsMessageType.singleRouterModelFetchResponse && + call[0]?.values?.provider === providerIdentifiers.mimo, + ) + expect(errorCall).toBeDefined() + if (!errorCall) throw new Error("Expected MiMo failure response") + expect(errorCall[0].success).toBe(false) + expect(errorCall[0].error).toBe( + "MIMO/requestRouterModels/001: MiMo model fetch rejected: the provided base URL is not an allowed Xiaomi MiMo endpoint.", + ) + + // The aggregate response still reaches the webview, so the refresh resolves. + // A filtered response only carries entries for dispatched candidates, so the + // skipped provider is absent rather than empty; the webview then falls back + // to the static catalog instead of hanging. + const response = mockProvider.postMessageToWebview.mock.calls.find( + (call) => call[0]?.type === RouterModelsMessageType.routerModels, + ) + expect(response).toBeDefined() + if (!response) throw new Error("Expected routerModels response") + expect(response[0].routerModels.mimo).toBeUndefined() + }) + it("treats an unsaved empty mimoBaseUrl as unset and keeps using the stored cluster", async () => { mockProvider.getState.mockResolvedValue({ apiConfiguration: { diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index b12ac2f685..313642e3de 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1342,8 +1342,18 @@ export const webviewMessageHandler = async ( // modelCache. The exact match subsumes credential-bearing URLs (no // allowlisted literal contains userinfo), and the raw value is never // echoed since an unsaved one may embed credentials. + // + // The resolved value is gated too, not just the unsaved one: a stored + // value can be off-list (ContextProxy fails open on a schema rejection, + // so one written by a pre-allowlist build survives), and flushModels() + // now takes a handler that is built eagerly at this call site -- + // MimoHandler's constructor rejects off-list endpoints, and that throw + // escapes the whole requestRouterModels case, skipping both the MiMo + // failure response and the aggregate routerModels response. const mimoBaseUrlRejected = - unsavedMimoBaseUrl !== undefined && !ALLOWED_BASE_URLS.has(stripTrailingSlashes(unsavedMimoBaseUrl)) + mimoBaseUrl !== undefined && + mimoBaseUrl !== "" && + !ALLOWED_BASE_URLS.has(stripTrailingSlashes(mimoBaseUrl)) if (mimoApiKey) { if (mimoBaseUrlRejected) { From 7bc5eb9b9262491abb79d52fd3d177df0e430c9e Mon Sep 17 00:00:00 2001 From: myk1yt Date: Fri, 30 Oct 2026 11:58:36 +0900 Subject: [PATCH 33/46] fix(mimo): close review findings on handler gating, schema drift, prototype keys What: - Build every router-models handler from the resolved base URL (`handlerConfiguration`) instead of the raw stored config. - Enforce MimoHandler's real constructor contract in the routerModels spec's buildApiHandler double and add a mixed-state regression case. - Replace the one-directional schema/allowlist cross-check with a bidirectional set-equality assertion over the zod union literals. - Add `getMimoModelInfo()` (own-property check) in packages/types and use it in the fetcher, MimoHandler.getModel(), and both useSelectedModel MiMo paths; make getValidatedModelId own-property based. - Correct the routerModels.spec comment that claimed a 10s refresh timeout. - Add a Mimo.visual.tsx gallery snapshot for the new Refresh Models affordance. Why: - Mixed state (valid unsaved mimoBaseUrl + off-list stored value) passed the allowlist guard, which runs on the resolved value, while handlerFor() spread the raw stored config. MimoHandler's constructor then threw and the throw escaped the whole requestRouterModels case, so neither the MiMo failure response nor the aggregate routerModels response was posted and the Refresh button stuck at Loading. The spec's non-throwing buildApiHandler double had masked this. - The schema cross-check only proved "every allowlisted URL parses", so adding a fifth zod literal without an allowlist entry tripped no test. - Model IDs arrive from the provider's /models response, so an ID such as `constructor` resolved an inherited Object.prototype member and surfaced as a model with no metadata instead of the MiMo-family defaults. - The Refresh Models button is a user-visible affordance, so it needs a pixel receipt; the PR body claimed "No UI changes". Impact: - Refresh no longer hangs in mixed state; the handler always validates the same value the guard accepted. - Schema/allowlist drift now fails in both directions. - Prototype-key IDs fall back to family defaults at every layer. - No behavior change for other providers (mimoBaseUrl is inert for them). - Baselines for Mimo.visual.tsx must be generated by the container workflow; host-rendered screenshots are not authoritative per webview-ui/AGENTS.md. --- packages/types/src/__tests__/mimo.test.ts | 34 +++++- packages/types/src/providers/mimo.ts | 13 ++ src/api/providers/__tests__/mimo.spec.ts | 13 ++ .../providers/fetchers/__tests__/mimo.spec.ts | 52 +++++++- src/api/providers/fetchers/mimo.ts | 7 +- src/api/providers/mimo.ts | 8 +- ...webviewMessageHandler.routerModels.spec.ts | 115 +++++++++++++++--- src/core/webview/webviewMessageHandler.ts | 24 +++- webview-ui/playwright/gallery/stories.tsx | 38 ++++++ .../providers/__tests__/Mimo.visual.tsx | 32 +++++ .../hooks/__tests__/useSelectedModel.spec.ts | 23 ++++ .../components/ui/hooks/useSelectedModel.ts | 25 ++-- 12 files changed, 346 insertions(+), 38 deletions(-) create mode 100644 webview-ui/src/components/settings/providers/__tests__/Mimo.visual.tsx diff --git a/packages/types/src/__tests__/mimo.test.ts b/packages/types/src/__tests__/mimo.test.ts index d1696bf65d..68a91c3027 100644 --- a/packages/types/src/__tests__/mimo.test.ts +++ b/packages/types/src/__tests__/mimo.test.ts @@ -1,4 +1,12 @@ -import { SECRET_STATE_KEYS, isSecretStateKey, providerIdentifiers, providerSettingsSchema } from "../index.js" +import { + SECRET_STATE_KEYS, + getMimoModelInfo, + isSecretStateKey, + mimoDefaultModelInfo, + mimoModels, + providerIdentifiers, + providerSettingsSchema, +} from "../index.js" describe("MiMo provider secret classification", () => { it("classifies mimoApiKey as a secret state key alongside its sibling provider keys", () => { @@ -21,3 +29,27 @@ describe("MiMo provider secret classification", () => { expect(parsed.mimoBaseUrl).toBe("https://token-plan-sgp.xiaomimimo.com/v1") }) }) + +describe("getMimoModelInfo", () => { + it("returns the curated spec for a known model ID", () => { + expect(getMimoModelInfo("mimo-v2.6-pro")).toBe(mimoModels["mimo-v2.6-pro"]) + }) + + it("returns undefined for an unknown model ID", () => { + expect(getMimoModelInfo("mimo-v99-not-real")).toBeUndefined() + }) + + // Regression: model IDs reach this lookup from the provider's /models response, + // so a plain `mimoModels[id]` read resolved Object.prototype members and handed + // callers a function (or `{}` after spreading) instead of the family default. + it.each(["constructor", "toString", "valueOf", "hasOwnProperty", "__proto__"])( + "returns undefined for the inherited Object.prototype key %s", + (key) => { + expect(getMimoModelInfo(key)).toBeUndefined() + }, + ) + + it("keeps the default model resolvable so the fallback path has a spec", () => { + expect(getMimoModelInfo("mimo-v2.6-pro")).toBe(mimoDefaultModelInfo) + }) +}) diff --git a/packages/types/src/providers/mimo.ts b/packages/types/src/providers/mimo.ts index 664a23d8ff..f92c411027 100644 --- a/packages/types/src/providers/mimo.ts +++ b/packages/types/src/providers/mimo.ts @@ -88,4 +88,17 @@ export const mimoModels = { export const mimoDefaultModelInfo: ModelInfo = mimoModels[mimoDefaultModelId] +/** + * Looks up a curated spec by model ID, or `undefined` when the ID is not in the + * catalog. A plain `mimoModels[id]` read is not safe for untrusted IDs: the + * dynamic catalog feeds IDs straight from the provider's `/models` response, so + * an ID like `constructor` or `toString` resolves to an inherited + * `Object.prototype` member instead of a spec, and spreading it yields `{}` -- + * a model with no metadata at all. Callers fall back to family defaults on + * `undefined`. + */ +export function getMimoModelInfo(modelId: string): ModelInfo | undefined { + return Object.hasOwn(mimoModels, modelId) ? mimoModels[modelId as MimoModelId] : undefined +} + export const MIMO_DEFAULT_TEMPERATURE = 1.0 diff --git a/src/api/providers/__tests__/mimo.spec.ts b/src/api/providers/__tests__/mimo.spec.ts index fe9171b2c9..3b9b726d09 100644 --- a/src/api/providers/__tests__/mimo.spec.ts +++ b/src/api/providers/__tests__/mimo.spec.ts @@ -197,6 +197,19 @@ describe("MimoHandler", () => { expect(model.id).toBe("unknown-model") expect(model.info).toBe(mimoModels[mimoDefaultModelId]) }) + + // Regression: `mimoModels[id]` resolved Object.prototype members, so a stored + // ID like `constructor` produced a function (or `{}` once spread) as the model + // spec instead of the family default. + it.each(["constructor", "toString", "valueOf", "__proto__"])( + "should fallback to the family default for the prototype-key ID %s", + (prototypeKey) => { + const h = new MimoHandler({ ...mockOptions, apiModelId: prototypeKey }) + const model = h.getModel() + expect(model.id).toBe(prototypeKey) + expect(model.info).toBe(mimoModels[mimoDefaultModelId]) + }, + ) }) describe("convertMessagesForMiMo (via convertToR1Format)", () => { diff --git a/src/api/providers/fetchers/__tests__/mimo.spec.ts b/src/api/providers/fetchers/__tests__/mimo.spec.ts index 724307b40f..627232428d 100644 --- a/src/api/providers/fetchers/__tests__/mimo.spec.ts +++ b/src/api/providers/fetchers/__tests__/mimo.spec.ts @@ -1,3 +1,5 @@ +import { z } from "zod" + import { mimoModels, providerSettingsSchema } from "@roo-code/types" import { getMimoModels, ALLOWED_BASE_URLS } from "../mimo" @@ -65,6 +67,31 @@ describe("getMimoModels", () => { }) }) + // Regression: IDs come straight from the provider's /models response, so an ID + // that names an Object.prototype member resolved the inherited member instead of + // a curated spec and emitted `{}` -- a model with no metadata at all. The family + // defaults are what an unknown ID must get. + it.each(["constructor", "toString", "valueOf", "__proto__"])( + "gives the prototype-key ID %s MiMo-family defaults instead of inherited metadata", + async (prototypeKey) => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ data: [{ id: prototypeKey }] }), + }) as unknown as typeof fetch + + const models = await getMimoModels("https://token-plan-sgp.xiaomimimo.com/v1", "mock-key") + + expect(models[prototypeKey]).toEqual({ + maxTokens: 16_000, + contextWindow: 262_144, + supportsImages: false, + supportsPromptCache: false, + preserveReasoning: true, + description: `MiMo model: ${prototypeKey}`, + }) + }, + ) + it("keeps the static spec authoritative for known IDs while still surfacing API-only models", async () => { // Negative precedence: the /models payload may carry fields that conflict // with the curated static spec (stale prices, wrong windows, injected @@ -379,13 +406,30 @@ describe("getMimoModels", () => { it("keeps the fetcher allowlist in sync with the persisted settings schema", () => { // The fetcher mirrors the zod literal union from // packages/types/src/provider-settings/mimo.ts because the definition is - // not re-exported publicly. If the schema ever rejects one of the fetcher's - // URLs, the two sources have drifted and this test must fail. + // not re-exported publicly. Enumerating the union's literals and asserting + // set equality in BOTH directions is what makes drift fail here: a + // one-directional "every allowlisted URL parses" check still passes when a + // fifth literal is added to the schema without an allowlist entry, and the + // fetcher would then reject a URL the settings form accepts. const mimoBaseUrlField = providerSettingsSchema.shape.mimoBaseUrl - for (const allowedUrl of ALLOWED_BASE_URLS) { - expect(mimoBaseUrlField.safeParse(allowedUrl).success).toBe(true) + // The field is `.optional()`, so unwrap before reaching the union. Read the + // members off the runtime shape rather than the declared type: the declared + // type is already the literal union, so a narrowing check on it collapses to + // `never` and cannot guard a future change to a non-union schema. + const union = mimoBaseUrlField.unwrap() as unknown + if (!(union instanceof z.ZodUnion)) { + throw new Error("Expected mimoBaseUrl to be a zod union") } + + const schemaUrls = union.options.map((option) => { + if (!(option instanceof z.ZodLiteral)) { + throw new Error("Expected every mimoBaseUrl union member to be a zod literal") + } + return option.value as string + }) + + expect([...schemaUrls].sort()).toEqual([...ALLOWED_BASE_URLS].sort()) expect(mimoBaseUrlField.safeParse("https://attacker.example/v1").success).toBe(false) }) diff --git a/src/api/providers/fetchers/mimo.ts b/src/api/providers/fetchers/mimo.ts index 26ba7b3697..e57fe9de61 100644 --- a/src/api/providers/fetchers/mimo.ts +++ b/src/api/providers/fetchers/mimo.ts @@ -1,5 +1,5 @@ import type { ModelRecord } from "@roo-code/types" -import { mimoModels } from "@roo-code/types" +import { getMimoModelInfo } from "@roo-code/types" import { DEFAULT_HEADERS } from "../constants" @@ -144,7 +144,10 @@ export async function getMimoModels( const modelId = typeof model.id === "string" && model.id ? model.id : null if (!modelId || NON_TEXT_MODEL_ID.test(modelId)) continue - const knownSpecs = mimoModels[modelId as keyof typeof mimoModels] + // Prototype-safe: IDs come straight from the provider's response, so an ID + // such as `constructor` must fall through to the family defaults below + // instead of resolving to an inherited Object.prototype member. + const knownSpecs = getMimoModelInfo(modelId) if (knownSpecs) { models[modelId] = { ...knownSpecs } diff --git a/src/api/providers/mimo.ts b/src/api/providers/mimo.ts index 47c1d4f088..d1eaba93a0 100644 --- a/src/api/providers/mimo.ts +++ b/src/api/providers/mimo.ts @@ -1,7 +1,8 @@ import OpenAI from "openai" import { - mimoModels, + getMimoModelInfo, + mimoDefaultModelInfo, mimoDefaultModelId, MIMO_DEFAULT_TEMPERATURE, type ModelInfo, @@ -81,7 +82,10 @@ export class MimoHandler extends OpenAiHandler { */ override getModel() { const id = this.options.apiModelId ?? mimoDefaultModelId - const info: ModelInfo = mimoModels[id as keyof typeof mimoModels] || mimoModels[mimoDefaultModelId] + // getMimoModelInfo is prototype-safe: a plain `mimoModels[id]` read would + // resolve an ID like `constructor` to an inherited member and hand + // getModelParams an empty spec instead of the family default. + const info: ModelInfo = getMimoModelInfo(id) ?? mimoDefaultModelInfo const params = getModelParams({ format: "openai", modelId: id, diff --git a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts index dd7e3a4306..f04913b041 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts @@ -64,24 +64,44 @@ vi.mock("../../../api/providers/fetchers/modelCache", () => ({ // Real handlers eagerly fetch models in their constructors, so use lightweight doubles. // One double per provider lets tests assert each call receives its own provider's handler. -const { handlersByProvider } = vi.hoisted(() => ({ +// +// The MiMo double enforces MimoHandler's real constructor contract: it throws unless the +// base URL it is handed passes the same allowlist the real handler applies. A non-throwing +// double would mask the mixed-state bug where the guard validated the resolved base URL but +// the handler was built from the raw stored config. +const { handlersByProvider, MIMO_HANDLER_REJECTION } = vi.hoisted(() => ({ handlersByProvider: new Map(), + MIMO_HANDLER_REJECTION: + "MIMO/MimoHandler/001: MiMo chat completion rejected: base URL is not an allowed Xiaomi MiMo endpoint.", })) -vi.mock("../../../api", () => ({ - buildApiHandler: vi.fn((configuration: { apiProvider?: string }) => { - const providerName = configuration.apiProvider - let handler = handlersByProvider.get(providerName) - if (!handler) { - handler = { - providerName, - getModel: vi.fn().mockReturnValue({ id: "claude-3-sonnet" }), - } as { providerName: string | undefined } - handlersByProvider.set(providerName, handler) - } - return handler - }), -})) +vi.mock("../../../api", async () => { + const { ALLOWED_BASE_URLS, MIMO_DEFAULT_BASE_URL, stripTrailingSlashes } = + await import("../../../api/providers/fetchers/mimo") + + return { + buildApiHandler: vi.fn((configuration: { apiProvider?: string; mimoBaseUrl?: string }) => { + const providerName = configuration.apiProvider + + if (providerName === providerIdentifiers.mimo) { + const mimoBaseUrl = configuration.mimoBaseUrl || MIMO_DEFAULT_BASE_URL + if (!ALLOWED_BASE_URLS.has(stripTrailingSlashes(mimoBaseUrl))) { + throw new Error(MIMO_HANDLER_REJECTION) + } + } + + let handler = handlersByProvider.get(providerName) + if (!handler) { + handler = { + providerName, + getModel: vi.fn().mockReturnValue({ id: "claude-3-sonnet" }), + } as { providerName: string | undefined } + handlersByProvider.set(providerName, handler) + } + return handler + }), + } +}) const handlerForProvider = (providerName: string) => { const handler = handlersByProvider.get(providerName) @@ -996,9 +1016,11 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { // Regression: flushModels() gained an eagerly-built handler argument, so an // off-list STORED mimoBaseUrl made MimoHandler's constructor throw from inside // this case. That throw escaped requestRouterModels entirely, so the webview got - // neither the MiMo failure response nor the aggregate routerModels response (the - // refresh button just hung until its 10s timeout). A stored value can be off-list - // because ContextProxy.getProviderSettings() fails open on a schema rejection. + // neither the MiMo failure response nor the aggregate routerModels response, and + // the Refresh button stuck at Loading indefinitely (Mimo.tsx clears that state + // only on a routerModels response; the 10s timeout lives in fetchRouterModels, + // which this button does not use). A stored value can be off-list because + // ContextProxy.getProviderSettings() fails open on a schema rejection. it("rejects an off-list STORED MiMo base URL without letting the handler constructor throw", async () => { mockProvider.getState.mockResolvedValue({ apiConfiguration: { @@ -1046,6 +1068,63 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { expect(response[0].routerModels.mimo).toBeUndefined() }) + // Regression (mixed state): a VALID unsaved base URL alongside an INVALID stored + // one. The allowlist guard runs on the resolved value, so it accepts the request, + // but handlerFor() spreads the raw stored config -- so the handler was built from + // the off-list stored URL and MimoHandler's constructor threw, escaping the case. + // The mock above enforces the real constructor contract, so this fails if the + // handler is ever built from anything but the guard-validated resolved value. + it("builds the MiMo handler from the resolved base URL when the stored one is off-list", async () => { + mockProvider.getState.mockResolvedValue({ + apiConfiguration: { + mimoApiKey: "stored-mimo-key", + mimoBaseUrl: "https://attacker.example/v1", + }, + }) + getModelsMock.mockImplementation(async (options: { provider?: string }) => + options?.provider === providerIdentifiers.mimo + ? { "mimo-v2.6-pro": { contextWindow: 1_048_576, supportsPromptCache: false } } + : {}, + ) + + await expect( + webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.requestRouterModels, + values: { + provider: providerIdentifiers.mimo, + mimoApiKey: "stored-mimo-key", + mimoBaseUrl: "https://token-plan-ams.xiaomimimo.com/v1", + }, + }), + ).resolves.toBeUndefined() + + // The accepted unsaved URL is the one that reaches the cache and the fetch. + const mimoOptions = { + provider: providerIdentifiers.mimo, + apiKey: "stored-mimo-key", + baseUrl: "https://token-plan-ams.xiaomimimo.com/v1", + } + expect(flushModelsMock).toHaveBeenCalledWith(mimoOptions, handlerForProvider(providerIdentifiers.mimo), true) + expect(getModelsMock).toHaveBeenCalledWith(mimoOptions, handlerForProvider(providerIdentifiers.mimo)) + + // No failure notification: the guard accepted the request and the handler built. + const errorCall = mockProvider.postMessageToWebview.mock.calls.find( + (call) => + call[0]?.type === RouterModelsMessageType.singleRouterModelFetchResponse && + call[0]?.values?.provider === providerIdentifiers.mimo, + ) + expect(errorCall).toBeUndefined() + + const response = mockProvider.postMessageToWebview.mock.calls.find( + (call) => call[0]?.type === RouterModelsMessageType.routerModels, + ) + expect(response).toBeDefined() + if (!response) throw new Error("Expected routerModels response") + expect(response[0].routerModels.mimo).toEqual({ + "mimo-v2.6-pro": { contextWindow: 1_048_576, supportsPromptCache: false }, + }) + }) + it("treats an unsaved empty mimoBaseUrl as unset and keeps using the stored cluster", async () => { mockProvider.getState.mockResolvedValue({ apiConfiguration: { diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 313642e3de..91246c6607 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1336,6 +1336,24 @@ export const webviewMessageHandler = async ( : undefined const mimoBaseUrl = unsavedMimoBaseUrl ?? apiConfiguration.mimoBaseUrl + // handlerFor() spreads the RAW stored config, so a handler built for MiMo + // would re-validate the stored base URL instead of the one this request + // actually uses. In mixed state (valid unsaved URL + off-list stored URL) + // the guard below accepts the resolved value while MimoHandler's + // constructor rejects the stored one, and that throw escapes this case -- + // so every handler built here takes the resolved value. mimoBaseUrl is + // inert for the other providers. + // + // The cast is sound because the value only reaches MimoHandler after the + // allowlist guard below accepts it, and MimoHandler re-applies the same + // stripTrailingSlashes normalization before building its client. The + // declared type is the four-literal union, but an unsaved form value is a + // plain string that the guard (not the schema) is what vets. + const handlerConfiguration: ProviderSettings = { + ...apiConfiguration, + mimoBaseUrl: mimoBaseUrl as ProviderSettings["mimoBaseUrl"], + } + // Unsaved form values bypass the settings-schema validation that pins // stored mimoBaseUrl to the four allowed Xiaomi endpoints, so gate them // on the fetcher's allowlist before they can carry the bearer key into @@ -1380,7 +1398,7 @@ export const webviewMessageHandler = async ( if (message?.values?.mimoApiKey || unsavedMimoBaseUrl) { // Unsaved form values win over stored config: flush refreshes the cache // with them before the aggregate fetch (same pattern as DeepSeek/Moonshot). - await flushModels(mimoOptions, handlerFor(apiConfiguration, providerIdentifiers.mimo), true) + await flushModels(mimoOptions, handlerFor(handlerConfiguration, providerIdentifiers.mimo), true) } candidates.push({ @@ -1489,12 +1507,12 @@ export const webviewMessageHandler = async ( // If refresh flag is set and we have a specific provider, flush its cache first if (shouldRefresh && providerFilter && modelFetchPromises.length > 0) { const targetCandidate = modelFetchPromises[0] - await flushModels(targetCandidate.options, handlerFor(apiConfiguration, targetCandidate.key), true) + await flushModels(targetCandidate.options, handlerFor(handlerConfiguration, targetCandidate.key), true) } const results = await Promise.allSettled( modelFetchPromises.map(async ({ key, options }) => { - const models = await safeGetModels(options, handlerFor(apiConfiguration, key)) + const models = await safeGetModels(options, handlerFor(handlerConfiguration, key)) return { key, models } // The key is `ProviderName` here. }), ) diff --git a/webview-ui/playwright/gallery/stories.tsx b/webview-ui/playwright/gallery/stories.tsx index 85d6919db4..c5d45bed53 100644 --- a/webview-ui/playwright/gallery/stories.tsx +++ b/webview-ui/playwright/gallery/stories.tsx @@ -269,6 +269,44 @@ export const stories: Record = { ) }, + // The MiMo settings gained a Refresh Models affordance with idle/loading/success/ + // error render states, so the surface needs a pixel receipt: the button sits in a + // vertical stack with the entrypoint dropdown and API key field, and the status + // line changes the block's height. Props stay serializable; the state lives here. + "mimo-settings": async ({ configured = false } = {}) => { + const [{ AppProviders }, { Mimo }, { providerIdentifiers }] = await Promise.all([ + import("../AppProviders"), + import("@/components/settings/providers/Mimo"), + import("@roo-code/types"), + ]) + type ProviderSettings = React.ComponentProps["apiConfiguration"] + + function MimoSettingsStory() { + const [apiConfiguration, setApiConfiguration] = useState({ + apiProvider: providerIdentifiers.mimo, + mimoBaseUrl: "https://token-plan-sgp.xiaomimimo.com/v1", + ...(configured ? { mimoApiKey: "mimo-test-key" } : {}), + }) + return ( +
+
+ + setApiConfiguration((current) => ({ ...current, [field]: value })) + } + /> +
+
+ ) + } + + return ( + + + + ) + }, "layout-clipped-text": () => (
diff --git a/webview-ui/src/components/settings/providers/__tests__/Mimo.visual.tsx b/webview-ui/src/components/settings/providers/__tests__/Mimo.visual.tsx new file mode 100644 index 0000000000..f8359410ca --- /dev/null +++ b/webview-ui/src/components/settings/providers/__tests__/Mimo.visual.tsx @@ -0,0 +1,32 @@ +import { expect, test } from "../../../../../playwright/coverage-fixture" +import { mountedStory } from "../../../../../playwright/mounted-story" + +// The MiMo settings surface gained a Refresh Models affordance: a button that swaps +// its icon for a spinner while loading and appends a status line for the loading, +// success, and error states. That changes the block's height and the button's +// disabled appearance, which JSDOM cannot verify — hence the snapshot. + +test("renders the unconfigured MiMo settings in the VS Code dark theme", async ({ mount, page }) => { + await page.setViewportSize({ width: 480, height: 480 }) + const component = mountedStory(await mount("mimo-settings")) + const story = component.getByTestId("mimo-settings-story") + + // Unconfigured: no API key yet, so the "Get MiMo API Key" link renders and the + // Refresh Models button is disabled. + await expect(story.getByRole("button", { name: /Refresh Models/ })).toBeDisabled() + await expect(story.getByText("Get MiMo API Key")).toBeVisible() + + await expect(story).toHaveScreenshot("mimo-settings-unconfigured-dark.png") +}) + +test("renders the configured MiMo settings with an enabled refresh affordance", async ({ mount, page }) => { + await page.setViewportSize({ width: 480, height: 480 }) + const component = mountedStory(await mount("mimo-settings", { configured: true })) + const story = component.getByTestId("mimo-settings-story") + + // Configured: the refresh button is actionable and the API key link is gone. + await expect(story.getByRole("button", { name: /Refresh Models/ })).toBeEnabled() + await expect(story.getByText("Get MiMo API Key")).toHaveCount(0) + + await expect(story).toHaveScreenshot("mimo-settings-configured-dark.png") +}) diff --git a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts index 8a06bb2ee0..c5b8eba4a4 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts @@ -360,6 +360,29 @@ describe("useSelectedModel", () => { expect(result.current.info).toEqual(routerModelInfo) }) + // Regression: both the ID validation and the metadata lookup used plain bracket + // reads, so an ID naming an Object.prototype member was "found" in the catalog and + // surfaced as a model with no metadata instead of falling back to the static spec. + it.each(["constructor", "toString", "valueOf", "__proto__"])( + "mimo: falls back to the static default for the prototype-key ID %s", + (prototypeKey) => { + mockUseRouterModels.mockReturnValue(createRouterModelsResult({ [providerIdentifiers.mimo]: {} })) + mockUseOpenRouterModelProviders.mockReturnValue(createOpenRouterModelProvidersResult({})) + + const { result } = renderHook( + () => useSelectedModel({ apiProvider: providerIdentifiers.mimo, apiModelId: prototypeKey }), + { wrapper: createWrapper() }, + ) + + // A settled-but-empty router catalog reaches the ready path, where the ID is + // validated against the merged catalog. The ID is not in it, so validation + // resets to the default and the metadata is the default's curated spec + // (never an inherited Object.prototype member). + expect(result.current.id).toBe(mimoDefaultModelId) + expect(result.current.info).toEqual(mimoModels[mimoDefaultModelId]) + }, + ) + it("uses router data for Poe", () => { const modelInfo: ModelInfo = { contextWindow: 42_000, supportsPromptCache: false } mockUseRouterModels.mockReturnValue( diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index 46a5b9fff9..a63a7b6268 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -13,6 +13,7 @@ import { minimaxModels, mimoModels, mimoDefaultModelId, + getMimoModelInfo, geminiModels, mistralModels, openAiModelInfoSaneDefaults, @@ -59,13 +60,19 @@ import { useVsCodeLmModels, type VsCodeLmModels } from "./useVsCodeLmModels" /** * Helper to get a validated model ID for dynamic providers. * Returns the configured model ID if it exists in the available models, otherwise returns the default. + * + * Uses an own-property check rather than a truthy read: router catalogs carry model + * IDs straight from provider responses, so a configured ID such as `constructor` + * would otherwise be "found" via Object.prototype and kept as a valid selection. */ function getValidatedModelId( configuredId: string | undefined, availableModels: ModelRecord | undefined, defaultModelId: string, ): string { - return configuredId && availableModels?.[configuredId] ? configuredId : defaultModelId + return configuredId && availableModels && Object.hasOwn(availableModels, configuredId) + ? configuredId + : defaultModelId } /** @@ -115,7 +122,7 @@ function getStaticCatalogSelection( } case providerIdentifiers.mimo: { const id = configuredId || mimoDefaultModelId - return { id, info: mimoModels[id as keyof typeof mimoModels] } + return { id, info: getMimoModelInfo(id) } } } } @@ -447,13 +454,15 @@ function getSelectedModel({ return { id, info } } case providerIdentifiers.mimo: { - const availableModels = routerModels[providerIdentifiers.mimo] - ? { ...mimoModels, ...routerModels[providerIdentifiers.mimo] } - : mimoModels + const routerCatalog = routerModels[providerIdentifiers.mimo] + const availableModels = routerCatalog ? { ...mimoModels, ...routerCatalog } : mimoModels const id = getValidatedModelId(apiConfiguration.apiModelId, availableModels, defaultModelId) - const routerInfo = routerModels[providerIdentifiers.mimo]?.[id] - const staticInfo = mimoModels[id as keyof typeof mimoModels] - return { id, info: routerInfo ?? staticInfo } + // Prototype-safe on both sides: the router catalog carries IDs straight + // from the provider's /models response, so `catalog["constructor"]` + // would otherwise resolve to an inherited member and surface as a model + // with no metadata instead of falling back to the static spec. + const routerInfo = routerCatalog && Object.hasOwn(routerCatalog, id) ? routerCatalog[id] : undefined + return { id, info: routerInfo ?? getMimoModelInfo(id) } } case providerIdentifiers.zai: { const apiLine = apiConfiguration.zaiApiLine ?? "international_coding" From 58ca62dfa04a9bfa385019c44191bfc2fab47bc0 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Fri, 30 Oct 2026 12:17:36 +0900 Subject: [PATCH 34/46] test(mimo): cover the filtered refresh flush and commit visual baselines What: add the two MiMo settings snapshot baselines (unconfigured and configured dark) generated by the pinned Playwright container, and add a mixed-state regression case that drives `refresh: true` through the filtered-flush site. Why: the visual spec referenced two baselines that existed nowhere, so the required `webview-visual` check failed on every run. The existing mixed-state test only reached the fetch path, so a regression at the shared refresh flush site would have passed unnoticed. Impact: `webview-visual` has its baselines. Reverting the flush site to the raw stored config now fails the new test with MIMO/MimoHandler/001, which the pre-existing mixed-state test does not catch. No production code changed. --- ...webviewMessageHandler.routerModels.spec.ts | 74 ++++++++++++++++++ .../mimo-settings-configured-dark.png | Bin 0 -> 11883 bytes .../mimo-settings-unconfigured-dark.png | Bin 0 -> 14381 bytes 3 files changed, 74 insertions(+) create mode 100644 webview-ui/src/components/settings/providers/__tests__/__screenshots__/mimo-settings-configured-dark.png create mode 100644 webview-ui/src/components/settings/providers/__tests__/__screenshots__/mimo-settings-unconfigured-dark.png diff --git a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts index f04913b041..ada5256d9a 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts @@ -1125,6 +1125,80 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { }) }) + // Regression (mixed state + refresh): the same valid-unsaved / off-list-stored + // pair, but through the filtered-flush path. With `refresh: true` and a provider + // filter, the handler flushes the cache before the aggregate fetch, and that + // flush builds its handler eagerly at the call site. The existing mixed-state + // test above only reaches the fetch path, so it would still pass if the flush + // site kept spreading the raw stored config. + it("flushes the MiMo cache from the resolved base URL when refresh is set and the stored one is off-list", async () => { + mockProvider.getState.mockResolvedValue({ + apiConfiguration: { + mimoApiKey: "stored-mimo-key", + mimoBaseUrl: "https://attacker.example/v1", + }, + }) + getModelsMock.mockImplementation(async (options: { provider?: string }) => + options?.provider === providerIdentifiers.mimo + ? { "mimo-v2.6-pro": { contextWindow: 1_048_576, supportsPromptCache: false } } + : {}, + ) + + await expect( + webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.requestRouterModels, + values: { + provider: providerIdentifiers.mimo, + mimoApiKey: "stored-mimo-key", + mimoBaseUrl: "https://token-plan-ams.xiaomimimo.com/v1", + refresh: true, + }, + }), + ).resolves.toBeUndefined() + + // Two flushes: the unsaved-values flush in the MiMo block and the filtered + // flush at the shared refresh site. Both must receive the guard-validated + // resolved URL and the MiMo handler built from it; spreading the raw stored + // config at the shared site throws MIMO_HANDLER_REJECTION out of the whole + // requestRouterModels case. + const mimoOptions = { + provider: providerIdentifiers.mimo, + apiKey: "stored-mimo-key", + baseUrl: "https://token-plan-ams.xiaomimimo.com/v1", + } + const mimoFlushCalls = flushModelsMock.mock.calls.filter((c) => c[0]?.provider === providerIdentifiers.mimo) + expect(mimoFlushCalls).toHaveLength(2) + for (const [options, handler, refresh] of mimoFlushCalls) { + expect(options).toEqual(mimoOptions) + expect(handler).toBe(handlerForProvider(providerIdentifiers.mimo)) + expect(refresh).toBe(true) + } + + // The fetches use the same resolved options and handler. + const mimoFetchCalls = getModelsMock.mock.calls.filter((c) => c[0]?.provider === providerIdentifiers.mimo) + expect(mimoFetchCalls).toHaveLength(1) + expect(mimoFetchCalls[0][0]).toEqual(mimoOptions) + expect(mimoFetchCalls[0][1]).toBe(handlerForProvider(providerIdentifiers.mimo)) + + // The request completed: no failure response, and the aggregate response + // carries the refreshed catalog instead of hanging the Refresh button. + const errorCall = mockProvider.postMessageToWebview.mock.calls.find( + (call) => + call[0]?.type === RouterModelsMessageType.singleRouterModelFetchResponse && + call[0]?.values?.provider === providerIdentifiers.mimo, + ) + expect(errorCall).toBeUndefined() + + const response = mockProvider.postMessageToWebview.mock.calls.find( + (call) => call[0]?.type === RouterModelsMessageType.routerModels, + ) + expect(response).toBeDefined() + if (!response) throw new Error("Expected routerModels response") + expect(response[0].routerModels.mimo).toEqual({ + "mimo-v2.6-pro": { contextWindow: 1_048_576, supportsPromptCache: false }, + }) + }) + it("treats an unsaved empty mimoBaseUrl as unset and keeps using the stored cluster", async () => { mockProvider.getState.mockResolvedValue({ apiConfiguration: { diff --git a/webview-ui/src/components/settings/providers/__tests__/__screenshots__/mimo-settings-configured-dark.png b/webview-ui/src/components/settings/providers/__tests__/__screenshots__/mimo-settings-configured-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..acba1c578b008319d6e23ce3d2df77e05320975d GIT binary patch literal 11883 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�lz-~gW{HEo zuqg4-N+&S(Gon;{L&M?0!4go8`S!Sa1qYr`=Q{0p@fpt+17P3hD4+>6lJ>EzzJBg9 z|KoR__i%|r#%^mKbdR*YgF#^SMkyH?s<2wQ( z`Ot+1yLGO0Z>IrLH{q_wdmBc(A86*Dp1d=c8(jeaz(I%d^6^!uS~)s8Dk;TNpoGDP z=j;OBWlsPF5`d@`r8 zPk-Gy9#-+W{;546N0@z0?p=NQ=(Ib=Oi!O8kp4Ifdy}gwcZ9jA#Fp5euHb6=3D5FUEZq8sm@I>%~0d+Af(#dLVXY1*Q ze7!%SP1~9@^lJD%?xrGrI!Ic2#=Y!+Yu9=?#^5R>A3Gje710$(BqltkBBf(*UN!g zN4Hq?wA;}FY#JFv57h+Z78#s)&7cRcZ${X`*sue-mv?q{fE9!DH;#~;94pg>H&aDL zB`c3sCgmG4)h^JGYORi_r*a+cn&CY1Y*E8L?*U9Z>H4q6lGWh}Aq3OG}mw%YC9CmkRY7cxG*2sJP7QyoN|@UeD`w0Cx2I z_cz7Hnwn`TA2>L!ns&1Qm-BmkT#`AI-(3xo{2ROFZYy5i2XIxQyvZy&?_xzTjWIT9 z@50vMhiM+cB4mVbD21ksd0vFnV*z8O;<;attXie3qo?-*G_!Q<(+8s7auG*KEPNqs z<2z|Y=s~uFZ~@a1LbqjMX$f`ySy548X4=I(P+i8EZu9c|q<-zy?$Tg2&yq3+z>zLO z=!0kdZl@oDf+dJUmyV9EXU!9HG)v$unaA!}rotJm4_JbV~80c{P8CewbYhnZVMe7`;AuY7Hp|ue^vjLYdJo7}eEN!$e; zFgH`|`KF}MJ+|ru5fRZsMfdWv_o40W$!aJ{r})1z+*-}&kRI6IolyFA!tsU4?pQ&W zS;wY3c*7`n`lD*M8q2z+%X=LzKR>@vp+}%T_564_F5m~xP8f%uapi^besu5q=NKbq z6|bPinLDtDog@NfQDI<889Kg@e-6mda9k3X>gaE1g!wC~Fn1Gjx|=kj6M6JtA6S1? z$K5v+vUI7`)KrBpr_P~#dA~M`RC2CEA)Wou2MT|~5nOC_HoDF9g|BaL?ArgDBGR}1 z7U|J-hapL^s-CMqQ?d9}`1+xn{y-^S$HaA6vG2ivCwe6L(5ANZxYC+261_6I_V)bD z0J0RaYZ23$taUtJw1+KPUQr<{FE8nS{;!D|?lW7eqQ2z=Jo{B0&z}D|zZPD3{&Pg_ zALcy&nu1;A`XOuzU)yca1^S{V$e+(%H;OA$4{z4nEXRJ};v%37)hP$2A$AxZ9^Qhl z?|C1ABHxO+fkCbtkAj()8jyKMoWUpEAR!?UoCIiWZN7rTyA^us1|}}V)n_-;jnbYa z79uQsSKjxT4D)nKEx~48XW94!?B7$@?2+oAbGp4_!%D0G9py$zLO&&-IG=?<#$`+5n(7@+84y=qUS6Tq zaR0!gz@WD+R!t6r_z+oyZrs1yEx*FGk=X*OpxS!d<@3G@H)|(5n0~Rp5f|hGO@@bu z5ryI?b5c58cvpd<-oKpfQUABY?5PIV~=YG1@_Ac=N=m6t^AjT7Ulji!A0@;{11pB^m&%fVy$N*r5dS<pa_ip&&>AeEEt2d4tWet04JV?1?bats4}; za1t?OK&7|Y_r=EtCjjiYA0Tl1uTbhB&n+~xreyrehd|f<^fy3oWJ{Fm8yd)g4I}og z&uTo_kpT<_+b0qLg9o@v2$Tv40FnYLoT}zc0;=F~LYNWDgYDZgCjtY2SsgVX=SDJt zVCets{rw-wBkceF+kbt5rufT?K4lLn@CNyxCq#IqQu$ynNL;Z79C`IgTu!VKs_*|l E0BGgHI{*Lx literal 0 HcmV?d00001 diff --git a/webview-ui/src/components/settings/providers/__tests__/__screenshots__/mimo-settings-unconfigured-dark.png b/webview-ui/src/components/settings/providers/__tests__/__screenshots__/mimo-settings-unconfigured-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..68ac268ddc86c232f36b706f1d85fd16704dc1ea GIT binary patch literal 14381 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 Date: Fri, 30 Oct 2026 13:27:25 +0900 Subject: [PATCH 35/46] fix(mimo): pin redirect policy on the chat client and gate every mimoBaseUrl writer What: - MimoHandler now sets `fetchOptions = { redirect: "error" }` on the OpenAI client it builds, matching the model fetcher's transport rule. - `flushRouterModels` skips the MiMo dispatch when the stored mimoBaseUrl is off-list instead of letting MimoHandler's constructor throw uncaught. - `getMimoModels` caps the catalog at 1000 entries and drops model IDs longer than 256 characters. - `ZooCodeAPI.setConfiguration` drops an off-list mimoBaseUrl instead of persisting it through ContextProxy. - The `updateSettings` gate comment no longer claims to be the only write path. Why: A security quartet confirmed that the allowlist pinned the URL the client is built with, not the URLs it can reach. The SDK's default fetch follows 3xx, so an allowlisted Xiaomi origin answering with a redirect replayed the POST body (system prompt, conversation, tool output) to an un-vetted origin, and re-scoped the bearer key wherever the fetch implementation forwards it. The same review found the flush path could throw out of the message handler, the catalog had no size ceiling, and the public extension API was a second, ungated write path into storage that the PR body claimed did not exist. Impact: - MiMo chat requests now reject a 3xx instead of following it. A MiMo endpoint that legitimately redirects would surface an error rather than silently working; no allowlisted endpoint does. - A stored off-list mimoBaseUrl no longer breaks model flushing or escapes the webview message handler. - A hostile or broken /models response can no longer inflate the cached catalog without bound. - Extensions calling setConfiguration with an off-list mimoBaseUrl get the value dropped with a warning instead of a poisoned store. --- .../providers/__tests__/mimo-redirect.spec.ts | 106 ++++++++++++++++++ .../providers/fetchers/__tests__/mimo.spec.ts | 58 +++++++++- src/api/providers/fetchers/mimo.ts | 30 +++++ src/api/providers/mimo.ts | 11 ++ ...webviewMessageHandler.routerModels.spec.ts | 85 ++++++++++++++ src/core/webview/webviewMessageHandler.ts | 50 ++++++--- .../__tests__/api-configuration.spec.ts | 88 ++++++++++++++- src/extension/api.ts | 45 +++++++- 8 files changed, 456 insertions(+), 17 deletions(-) create mode 100644 src/api/providers/__tests__/mimo-redirect.spec.ts diff --git a/src/api/providers/__tests__/mimo-redirect.spec.ts b/src/api/providers/__tests__/mimo-redirect.spec.ts new file mode 100644 index 0000000000..5ca847cc7d --- /dev/null +++ b/src/api/providers/__tests__/mimo-redirect.spec.ts @@ -0,0 +1,106 @@ +// npx vitest run api/providers/__tests__/mimo-redirect.spec.ts + +// Deliberately does NOT mock "openai": this suite proves the redirect policy the +// real SDK client actually sends, which a mocked constructor cannot show. +import type { Anthropic } from "@anthropic-ai/sdk" + +import type { ApiHandlerOptions } from "../../../shared/api" +import { MimoHandler } from "../mimo" +import { collectStream } from "../../../test-utils/stream" + +const ALLOWED_BASE_URL = "https://token-plan-sgp.xiaomimimo.com/v1" +const REDIRECT_TARGET = "https://attacker.example/collect" + +const options = (): ApiHandlerOptions => ({ + mimoApiKey: "test-api-key", + apiModelId: "mimo-v2.6-pro", + mimoBaseUrl: ALLOWED_BASE_URL, +}) + +const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }] + +/** + * Stands in for the transport, emulating the fetch redirect contract rather than + * just recording the init: a 3xx is refused when the init says redirect:"error", + * and otherwise replayed against the Location target with the same method, + * headers, and body -- exactly what undici's default redirect:"follow" does. That + * is what makes this a behavioral test: the assertion below fails on the old + * code because the request really does reach the attacker URL. + */ +const redirectingFetch = () => { + const calls: { url: string; init: RequestInit }[] = [] + + const redirect = (status: number, location: string) => + new Response(null, { status, statusText: "Redirect", headers: { location } }) + + const fetchDouble = async (input: string | URL | Request, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url + calls.push({ url, init: init ?? {} }) + + const response = url.startsWith(ALLOWED_BASE_URL) + ? redirect(307, REDIRECT_TARGET) + : new Response('data: {"choices":[{"delta":{"content":"leaked"},"index":0}]}\n\ndata: [DONE]\n\n', { + status: 200, + headers: { "content-type": "text/event-stream" }, + }) + + const location = response.headers.get("location") + if (!location) { + return response + } + + if (init?.redirect === "error") { + throw new TypeError("fetch failed: unexpected redirect") + } + + if (init?.redirect === "manual") { + return response + } + + // Default policy: follow. Re-issue the same request against the target. + return fetchDouble(new URL(location, url).toString(), init) + } + + return { fetchDouble, calls } +} + +describe("MimoHandler inference client redirect policy", () => { + const originalFetch = globalThis.fetch + + afterEach(() => { + globalThis.fetch = originalFetch + vi.restoreAllMocks() + }) + + it("pins redirect:'error' on the SDK client it builds", () => { + const handler = new MimoHandler(options()) + + // The SDK spreads client.fetchOptions into every request init, so this is + // the value the transport receives. Asserted directly as well as through + // the request below, so a change to the SDK's plumbing fails here first. + expect(handler["client"].fetchOptions).toEqual({ redirect: "error" }) + }) + + // Regression: the SDK's default fetch follows 3xx, so an allowlisted Xiaomi + // origin answering with a redirect replayed the POST body (system prompt, + // conversation, tool output) to an origin the allowlist never vetted. Before + // the fix the 307 was followed and the request resolved against the attacker + // URL; with the policy pinned, the request must reject instead. + it("rejects a 307 from an allowlisted origin instead of replaying the request cross-origin", async () => { + const { fetchDouble, calls } = redirectingFetch() + globalThis.fetch = fetchDouble as unknown as typeof fetch + + const handler = new MimoHandler(options()) + + await expect(collectStream(handler.createMessage("System prompt", messages))).rejects.toThrow() + + // The transport was told not to follow, and nothing was ever sent to the + // redirect target. + expect(calls.length).toBeGreaterThan(0) + for (const call of calls) { + expect(call.init.redirect).toBe("error") + expect(call.url.startsWith(ALLOWED_BASE_URL)).toBe(true) + } + expect(calls.some((call) => call.url.startsWith(REDIRECT_TARGET))).toBe(false) + }) +}) diff --git a/src/api/providers/fetchers/__tests__/mimo.spec.ts b/src/api/providers/fetchers/__tests__/mimo.spec.ts index 627232428d..5f60018221 100644 --- a/src/api/providers/fetchers/__tests__/mimo.spec.ts +++ b/src/api/providers/fetchers/__tests__/mimo.spec.ts @@ -2,7 +2,7 @@ import { z } from "zod" import { mimoModels, providerSettingsSchema } from "@roo-code/types" -import { getMimoModels, ALLOWED_BASE_URLS } from "../mimo" +import { getMimoModels, ALLOWED_BASE_URLS, MAX_MIMO_MODEL_ENTRIES, MAX_MIMO_MODEL_ID_LENGTH } from "../mimo" describe("getMimoModels", () => { const originalFetch = globalThis.fetch @@ -479,6 +479,62 @@ describe("getMimoModels", () => { ) }) + describe("response size caps", () => { + // The model list is remote input: without a ceiling a hostile or broken + // endpoint inflates the cached catalog and every picker that renders it. + const respondWith = (data: unknown[]) => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ data }), + }) as unknown as typeof fetch + } + + it("caps the catalog at the entry limit", async () => { + respondWith( + Array.from({ length: MAX_MIMO_MODEL_ENTRIES + 50 }, (_, index) => ({ id: `mimo-model-${index}` })), + ) + + const models = await getMimoModels("https://token-plan-sgp.xiaomimimo.com/v1", "mock-key") + + expect(Object.keys(models)).toHaveLength(MAX_MIMO_MODEL_ENTRIES) + // The cap keeps the first entries in response order, so a truncated + // catalog is still a prefix of what the endpoint served. + expect(models["mimo-model-0"]).toBeDefined() + expect(models[`mimo-model-${MAX_MIMO_MODEL_ENTRIES - 1}`]).toBeDefined() + expect(models[`mimo-model-${MAX_MIMO_MODEL_ENTRIES}`]).toBeUndefined() + }) + + it("keeps every entry when the list is exactly at the cap", async () => { + respondWith(Array.from({ length: MAX_MIMO_MODEL_ENTRIES }, (_, index) => ({ id: `mimo-model-${index}` }))) + + const models = await getMimoModels("https://token-plan-sgp.xiaomimimo.com/v1", "mock-key") + + expect(Object.keys(models)).toHaveLength(MAX_MIMO_MODEL_ENTRIES) + }) + + it("drops an over-long model ID instead of truncating it", async () => { + const longId = "m" + "x".repeat(MAX_MIMO_MODEL_ID_LENGTH) + respondWith([{ id: longId }, { id: "mimo-v2.6-pro" }]) + + const models = await getMimoModels("https://token-plan-sgp.xiaomimimo.com/v1", "mock-key") + + expect(Object.keys(models)).toEqual(["mimo-v2.6-pro"]) + // Truncating would invent an ID the provider does not serve, so no + // shortened variant may appear either. + expect(models[longId.slice(0, MAX_MIMO_MODEL_ID_LENGTH)]).toBeUndefined() + }) + + it("keeps an ID exactly at the length limit", async () => { + const maxLengthId = "m" + "x".repeat(MAX_MIMO_MODEL_ID_LENGTH - 1) + expect(maxLengthId).toHaveLength(MAX_MIMO_MODEL_ID_LENGTH) + respondWith([{ id: maxLengthId }]) + + const models = await getMimoModels("https://token-plan-sgp.xiaomimimo.com/v1", "mock-key") + + expect(Object.keys(models)).toEqual([maxLengthId]) + }) + }) + it("passes the caller's abort signal to the request", async () => { const fetchSpy = vi .spyOn(globalThis, "fetch") diff --git a/src/api/providers/fetchers/mimo.ts b/src/api/providers/fetchers/mimo.ts index e57fe9de61..4c9fa8a53d 100644 --- a/src/api/providers/fetchers/mimo.ts +++ b/src/api/providers/fetchers/mimo.ts @@ -8,6 +8,13 @@ import { DEFAULT_HEADERS } from "../constants" // builds, so discovery excludes them from the catalog. const NON_TEXT_MODEL_ID = /-(asr|tts)(-|$)/i +// Ceilings on what a provider response can add to the catalog. The response is +// remote input: without a cap, a hostile or broken endpoint could inflate the +// cached catalog (and every webview model picker that renders it) without +// bound. Both limits are far above any plausible MiMo catalog size. +export const MAX_MIMO_MODEL_ENTRIES = 1000 +export const MAX_MIMO_MODEL_ID_LENGTH = 256 + // Default cluster (Singapore), used whenever the caller leaves mimoBaseUrl // unset or empty. Exported so the chat-completion handler resolves the exact // same fallback instead of drifting a second literal. @@ -135,8 +142,19 @@ export async function getMimoModels( // Use null-prototype object to prevent prototype pollution const models: ModelRecord = Object.create(null) + let entryCount = 0 for (const model of data.data) { + // Stop at the entry ceiling rather than truncating silently at the end: + // a response this large is not a catalog, so the excess is dropped here + // with one log line instead of growing the cache past the bound. + if (entryCount >= MAX_MIMO_MODEL_ENTRIES) { + console.error("[getMimoModels] Model list exceeded the entry cap; the remainder was dropped:", { + cap: MAX_MIMO_MODEL_ENTRIES, + }) + break + } + // Skip non-record elements so a single malformed entry cannot abort // the whole catalog. if (typeof model !== "object" || model === null) continue @@ -144,6 +162,11 @@ export async function getMimoModels( const modelId = typeof model.id === "string" && model.id ? model.id : null if (!modelId || NON_TEXT_MODEL_ID.test(modelId)) continue + // An over-long ID is dropped instead of truncated: a truncated ID would + // name a model the provider does not serve, and the length cap keeps the + // cached catalog and the pickers that render it bounded. + if (modelId.length > MAX_MIMO_MODEL_ID_LENGTH) continue + // Prototype-safe: IDs come straight from the provider's response, so an ID // such as `constructor` must fall through to the family defaults below // instead of resolving to an inherited Object.prototype member. @@ -152,6 +175,11 @@ export async function getMimoModels( if (knownSpecs) { models[modelId] = { ...knownSpecs } } else { + // Unknown IDs get MiMo-family defaults on purpose: the /models response + // carries no pricing or limits, the static map is the only price source + // (so a remote entry can never set a cost), and a model Xiaomi adds + // ahead of a static-map update stays usable instead of resolving to an + // empty spec. Known IDs above keep their static spec untouched. models[modelId] = { maxTokens: 16_000, contextWindow: 262_144, @@ -161,6 +189,8 @@ export async function getMimoModels( description: `MiMo model: ${modelId}`, } } + + entryCount++ } return models diff --git a/src/api/providers/mimo.ts b/src/api/providers/mimo.ts index d1eaba93a0..16cec491d4 100644 --- a/src/api/providers/mimo.ts +++ b/src/api/providers/mimo.ts @@ -58,6 +58,17 @@ export class MimoHandler extends OpenAiHandler { openAiStreamingEnabled: true, includeMaxTokens: false, }) + + // The allowlist above pins the URL this client is BUILT with, not the URLs + // it can end up talking to: the SDK's default fetch follows 3xx, so an + // allowlisted origin answering with a redirect would replay the POST body + // (system prompt, conversation, tool output) to an origin the allowlist + // never vetted, and would re-scope the bearer key wherever the fetch + // implementation forwards it. Pin the same redirect:"error" policy the + // model fetcher uses (fetchers/mimo.ts) so both paths carry the key under + // one transport rule. Set on the client rather than by replacing `fetch` + // so proxy agents and any other transport configuration still apply. + this.client.fetchOptions = { redirect: "error" } } /** diff --git a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts index ada5256d9a..8b6d10380f 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts @@ -967,6 +967,91 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { }) }) + describe("flushRouterModels", () => { + // flushRouterModels builds its handler eagerly from stored config and has no + // surrounding catch, so an off-list stored mimoBaseUrl made MimoHandler's + // constructor throw and escape webviewMessageHandler entirely. The mock + // buildApiHandler above enforces the real constructor contract, so these + // tests fail if the guard is ever removed. + it("skips the flush for an off-list stored MiMo base URL instead of letting the constructor throw", async () => { + mockProvider.getState.mockResolvedValue({ + apiConfiguration: { + mimoApiKey: "stored-mimo-key", + mimoBaseUrl: "https://attacker.example/v1", + }, + }) + + await expect( + webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.flushRouterModels, + text: providerIdentifiers.mimo, + }), + ).resolves.toBeUndefined() + + expect(flushModelsMock).not.toHaveBeenCalled() + expect(handlersByProvider.has(providerIdentifiers.mimo)).toBe(false) + }) + + it("flushes MiMo from a stored allowlisted base URL", async () => { + mockProvider.getState.mockResolvedValue({ + apiConfiguration: { + mimoApiKey: "stored-mimo-key", + mimoBaseUrl: "https://token-plan-ams.xiaomimimo.com/v1", + }, + }) + + await webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.flushRouterModels, + text: providerIdentifiers.mimo, + }) + + expect(flushModelsMock).toHaveBeenCalledWith( + { provider: providerIdentifiers.mimo }, + handlerForProvider(providerIdentifiers.mimo), + true, + ) + }) + + it("flushes MiMo from the default cluster when no stored base URL is set", async () => { + mockProvider.getState.mockResolvedValue({ + apiConfiguration: { mimoApiKey: "stored-mimo-key" }, + }) + + await webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.flushRouterModels, + text: providerIdentifiers.mimo, + }) + + expect(flushModelsMock).toHaveBeenCalledWith( + { provider: providerIdentifiers.mimo }, + handlerForProvider(providerIdentifiers.mimo), + true, + ) + }) + + it("still flushes other providers when the stored MiMo base URL is off-list", async () => { + // The guard is MiMo-specific: an off-list MiMo value must not disable the + // generic flush for every other router. + mockProvider.getState.mockResolvedValue({ + apiConfiguration: { + mimoApiKey: "stored-mimo-key", + mimoBaseUrl: "https://attacker.example/v1", + }, + }) + + await webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.flushRouterModels, + text: providerIdentifiers.openrouter, + }) + + expect(flushModelsMock).toHaveBeenCalledWith( + { provider: providerIdentifiers.openrouter }, + handlerForProvider(providerIdentifiers.openrouter), + true, + ) + }) + }) + it("rejects an unsaved MiMo base URL outside the Xiaomi allowlist without dispatching a fetch", async () => { mockProvider.getState.mockResolvedValue({ apiConfiguration: { diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 91246c6607..9f4aec7a77 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -131,6 +131,15 @@ import { const handlerFor = (apiConfiguration: ProviderSettings, provider: RouterName): ApiHandler => buildApiHandler({ ...apiConfiguration, apiProvider: provider }) +// True when a non-empty mimoBaseUrl is outside the four Xiaomi endpoints the +// fetcher and MimoHandler both enforce. handlerFor() builds its handler eagerly +// from stored config and MimoHandler's constructor throws on such a value, so +// every router-models case that constructs a MiMo handler must consult this +// first: the throw would otherwise escape webviewMessageHandler entirely +// (onReceiveMessage has no catch) and take the whole response with it. +const isOffListMimoBaseUrl = (mimoBaseUrl: string | undefined): boolean => + mimoBaseUrl !== undefined && mimoBaseUrl !== "" && !ALLOWED_BASE_URLS.has(stripTrailingSlashes(mimoBaseUrl)) + export const webviewMessageHandler = async ( provider: ClineProvider, message: WebviewMessage, @@ -867,15 +876,19 @@ export const webviewMessageHandler = async ( continue } } else if (key === "mimoBaseUrl") { - // Persistence-boundary gate. The settings schema pins this key to - // the four allowed Xiaomi endpoints, but ContextProxy fails open on - // a schema rejection and returns raw stored values — so validate - // again here, at the only place an off-list value could newly enter - // storage. Empty string means "unset" (mirroring the handler and - // router fallbacks) and is normalized to undefined; an off-list - // non-empty value is dropped with a warning so it can never reach - // the chat-completion client via persisted state. Trailing slashes - // are tolerated for the check and canonicalized away on save. + // Persistence-boundary gate for the settings form. The settings + // schema pins this key to the four allowed Xiaomi endpoints, but + // ContextProxy fails open on a schema rejection and returns raw + // stored values, so validate again here rather than trusting + // storage. This is one of two write paths, not the only one: the + // exported ZooCodeAPI.setConfiguration applies the same allowlist + // in src/extension/api.ts, and every consumer re-checks anyway + // because a value can also predate this gate. Empty string means + // "unset" (mirroring the handler and router fallbacks) and is + // normalized to undefined; an off-list non-empty value is dropped + // with a warning so it can never reach the chat-completion client + // via persisted state. Trailing slashes are tolerated for the + // check and canonicalized away on save. if (typeof value === "string" && value !== "") { const normalized = stripTrailingSlashes(value) if (!ALLOWED_BASE_URLS.has(normalized)) { @@ -1143,6 +1156,20 @@ export const webviewMessageHandler = async ( case RouterModelsMessageType.flushRouterModels: { const { apiConfiguration } = await provider.getState() const routerNameFlush: RouterName = toRouterName(message.text) + + // Same stored-value gate as requestRouterModels: an off-list mimoBaseUrl + // (written by a pre-allowlist build, or by a caller of the exported + // ZooCodeAPI.setConfiguration, since ContextProxy fails open on a schema + // rejection) makes MimoHandler's constructor throw here. This case has no + // surrounding catch, so the rejection would escape the message handler and + // break the flush for good; skip the dispatch instead. + if (routerNameFlush === providerIdentifiers.mimo && isOffListMimoBaseUrl(apiConfiguration.mimoBaseUrl)) { + console.warn( + "[flushRouterModels] Skipped MiMo model flush: the stored base URL is not an allowed Xiaomi MiMo endpoint.", + ) + break + } + // Note: flushRouterModels is a generic flush without credentials // For providers that need credentials, use their specific handlers await flushModels( @@ -1368,10 +1395,7 @@ export const webviewMessageHandler = async ( // MimoHandler's constructor rejects off-list endpoints, and that throw // escapes the whole requestRouterModels case, skipping both the MiMo // failure response and the aggregate routerModels response. - const mimoBaseUrlRejected = - mimoBaseUrl !== undefined && - mimoBaseUrl !== "" && - !ALLOWED_BASE_URLS.has(stripTrailingSlashes(mimoBaseUrl)) + const mimoBaseUrlRejected = isOffListMimoBaseUrl(mimoBaseUrl) if (mimoApiKey) { if (mimoBaseUrlRejected) { diff --git a/src/extension/__tests__/api-configuration.spec.ts b/src/extension/__tests__/api-configuration.spec.ts index 6cc6c72c7a..e620163331 100644 --- a/src/extension/__tests__/api-configuration.spec.ts +++ b/src/extension/__tests__/api-configuration.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vitest" import type * as vscode from "vscode" -import { providerIdentifiers } from "@roo-code/types" +import { providerIdentifiers, type RooCodeSettings } from "@roo-code/types" import { API } from "../api" import type { ClineProvider } from "../../core/webview/ClineProvider" @@ -89,4 +89,90 @@ describe("API - configuration", () => { expect(configuration.mimoBaseUrl).toBe("https://token-plan-sgp.xiaomimimo.com/v1") expect(configuration.apiModelId).toBe("mimo-v2.6-pro") }) + + describe("mimoBaseUrl allowlist at the public API boundary", () => { + // setConfiguration writes straight to ContextProxy, which fails open on a + // schema rejection, so this was the one write path where an off-list + // mimoBaseUrl could newly enter storage. Both persistence calls are + // asserted: the proxy write and the saved profile. + const makeApi = () => { + const setValues = vi.fn().mockResolvedValue(undefined) + const saveConfig = vi.fn().mockResolvedValue("default-id") + const setModeConfig = vi.fn().mockResolvedValue(undefined) + const postStateToWebview = vi.fn().mockResolvedValue(undefined) + const provider = { + context: {}, + on: vi.fn(), + contextProxy: { setValues }, + providerSettingsManager: { saveConfig, setModeConfig }, + postStateToWebview, + } as unknown as ClineProvider + const outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel + + return { api: new API(outputChannel, provider, makeClineProviderFactory()), setValues, saveConfig } + } + + // The declared type is the four-literal union, so an off-list value needs + // the same widening the guard itself documents: callers of this public API + // are not the schema. + const offListConfiguration = (mimoBaseUrl: string): RooCodeSettings => + ({ currentApiConfigName: "default", mimoBaseUrl }) as unknown as RooCodeSettings + + it("drops an off-list mimoBaseUrl instead of persisting it", async () => { + const { api, setValues, saveConfig } = makeApi() + + await api.setConfiguration(offListConfiguration("https://attacker.example/v1")) + + expect(setValues).toHaveBeenCalledWith(expect.not.objectContaining({ mimoBaseUrl: expect.anything() })) + expect(saveConfig).toHaveBeenCalledWith( + "default", + expect.not.objectContaining({ mimoBaseUrl: expect.anything() }), + ) + expect(setValues.mock.calls[0][0]).not.toHaveProperty("mimoBaseUrl") + expect(saveConfig.mock.calls[0][1]).not.toHaveProperty("mimoBaseUrl") + }) + + it.each([ + "https://api.xiaomimimo.com/v1", + "https://token-plan-cn.xiaomimimo.com/v1", + "https://token-plan-sgp.xiaomimimo.com/v1", + "https://token-plan-ams.xiaomimimo.com/v1", + ])("persists the allowlisted endpoint %s", async (mimoBaseUrl) => { + const { api, setValues, saveConfig } = makeApi() + + await api.setConfiguration(offListConfiguration(mimoBaseUrl)) + + expect(setValues).toHaveBeenCalledWith(expect.objectContaining({ mimoBaseUrl })) + expect(saveConfig).toHaveBeenCalledWith("default", expect.objectContaining({ mimoBaseUrl })) + }) + + it("persists a trailing-slash variant of an allowlisted endpoint", async () => { + const { api, setValues } = makeApi() + + await api.setConfiguration(offListConfiguration("https://token-plan-sgp.xiaomimimo.com/v1/")) + + expect(setValues).toHaveBeenCalledWith( + expect.objectContaining({ mimoBaseUrl: "https://token-plan-sgp.xiaomimimo.com/v1/" }), + ) + }) + + it("keeps an empty mimoBaseUrl so 'unset' still means the default cluster", async () => { + const { api, setValues } = makeApi() + + await api.setConfiguration(offListConfiguration("")) + + expect(setValues).toHaveBeenCalledWith(expect.objectContaining({ mimoBaseUrl: "" })) + }) + + it("leaves configurations without mimoBaseUrl untouched", async () => { + const { api, setValues } = makeApi() + + await api.setConfiguration({ currentApiConfigName: "default", apiModelId: "mimo-v2.6-pro" }) + + expect(setValues).toHaveBeenCalledWith({ + currentApiConfigName: "default", + apiModelId: "mimo-v2.6-pro", + }) + }) + }) }) diff --git a/src/extension/api.ts b/src/extension/api.ts index 3a33510346..ae8aea977c 100644 --- a/src/extension/api.ts +++ b/src/extension/api.ts @@ -36,6 +36,7 @@ import { readTaskMessages } from "../core/task-persistence/taskMessages" import { Terminal } from "../integrations/terminal/Terminal" import { TerminalRegistry } from "../integrations/terminal/TerminalRegistry" import { getCommands } from "../services/command/commands" +import { ALLOWED_BASE_URLS, stripTrailingSlashes } from "../api/providers/fetchers/mimo" export class API extends EventEmitter implements RooCodeAPI { private readonly outputChannel: vscode.OutputChannel @@ -629,8 +630,21 @@ export class API extends EventEmitter implements RooCodeAPI { } public async setConfiguration(values: RooCodeSettings) { - await this.sidebarProvider.contextProxy.setValues(values) - await this.sidebarProvider.providerSettingsManager.saveConfig(values.currentApiConfigName || "default", values) + // The allowlist normally lives at the two boundaries that consume this + // value (the updateSettings persistence gate and MimoHandler's + // constructor), but this public API writes straight to ContextProxy and + // ContextProxy fails open on a schema rejection, so a caller could park + // an off-list mimoBaseUrl in storage and every consumer would then have to + // defend against it. Drop it here instead of persisting it. The value is + // removed rather than overwritten so "unset" keeps its existing meaning + // (fall back to the default cluster) instead of pinning a cluster. + const sanitizedValues = this.withoutOffListMimoBaseUrl(values) + + await this.sidebarProvider.contextProxy.setValues(sanitizedValues) + await this.sidebarProvider.providerSettingsManager.saveConfig( + sanitizedValues.currentApiConfigName || "default", + sanitizedValues, + ) if (values.modeApiConfigs) { await Promise.all( Object.entries(values.modeApiConfigs).map(([mode, configId]) => @@ -641,6 +655,33 @@ export class API extends EventEmitter implements RooCodeAPI { await this.sidebarProvider.postStateToWebview() } + /** + * Returns `values` unchanged unless it carries a non-empty mimoBaseUrl outside + * the four allowed Xiaomi endpoints, in which case that one key is omitted. + * Empty string means "unset" and passes through untouched. + */ + private withoutOffListMimoBaseUrl(values: RooCodeSettings): RooCodeSettings { + // Declared as the four-literal union, but this API is a public entry point + // for other extensions and automation: the runtime value is whatever the + // caller passed, and the schema is exactly what it never went through. + const mimoBaseUrl: string | undefined = values.mimoBaseUrl + + if ( + mimoBaseUrl === undefined || + mimoBaseUrl === "" || + ALLOWED_BASE_URLS.has(stripTrailingSlashes(mimoBaseUrl)) + ) { + return values + } + + console.warn( + "[API] Rejected mimoBaseUrl outside the allowed Xiaomi MiMo endpoints; the value was not persisted.", + ) + + const { mimoBaseUrl: _rejected, ...rest } = values + return rest + } + public setTerminalProfile(name: string | undefined): void { const previousProfile = Terminal.getTerminalProfile() Terminal.setTerminalProfile(name) From 274952d190ce0cce68d56509792924958ace9832 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Fri, 30 Oct 2026 13:29:46 +0900 Subject: [PATCH 36/46] docs(mimo): name the redirect rule in the allowlist comment What: the ALLOWED_BASE_URLS comment now states that the allowlist and the redirect:"error" policy together keep the bearer key on a Xiaomi origin, and points at both places that pin the policy. Why: the comment claimed the exact-match gate alone "guarantees the bearer key never leaves a Xiaomi origin on any path". That was false while the inference client followed 3xx, and it would go stale again if a future path added a client without a redirect policy. Impact: comment only, no behavior change. --- src/api/providers/fetchers/mimo.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/api/providers/fetchers/mimo.ts b/src/api/providers/fetchers/mimo.ts index 4c9fa8a53d..e11d389bdb 100644 --- a/src/api/providers/fetchers/mimo.ts +++ b/src/api/providers/fetchers/mimo.ts @@ -28,8 +28,14 @@ export const MIMO_DEFAULT_BASE_URL = "https://token-plan-sgp.xiaomimimo.com/v1" // against providerSettingsSchema so the two sources cannot silently drift. The // fetcher also receives unsaved webview values that bypass the schema entirely, // and MimoHandler (src/api/providers/mimo.ts) re-enforces this same set before -// it builds its OpenAI chat-completion client, so this exact-match gate is what -// guarantees the bearer key never leaves a Xiaomi origin on any path. +// it builds its OpenAI chat-completion client. +// +// The gate alone only pins the URL each path is BUILT with, so both paths also +// pin redirect:"error" on their requests: the fetcher below, and the SDK client +// in MimoHandler. Without that, an allowlisted origin answering a 3xx would +// re-scope the bearer key and the request body to an origin this set never +// vetted. Together the allowlist plus the redirect policy are what keep the key +// on a Xiaomi origin on every path. export const ALLOWED_BASE_URLS: ReadonlySet = new Set([ "https://api.xiaomimimo.com/v1", "https://token-plan-cn.xiaomimimo.com/v1", From 653bf2ea176a490b9e62fa6a1ba7e9f3587e6630 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sun, 1 Nov 2026 03:55:24 +0900 Subject: [PATCH 37/46] fix(config): purge surviving plaintext mimoApiKey on state reset What: resetAllState now also clears the plaintext globalState copy of mimoApiKey, and the reset spec asserts the extra update call plus a new regression covering reset-then-initialize. Why: mimoApiKey became a SECRET_STATE_KEY, so it is filtered out of GLOBAL_STATE_KEYS. The reset sweep therefore never touched a plaintext copy left by a pre-migration build, and the initialize() that reset runs immediately afterwards found it and migrated it into SecretStorage. Reset resurrected the credential it is supposed to clear. Impact: "Reset state" no longer re-stores a plaintext MiMo API key. The crash-safe retry guarantee in migratePlaintextMimoApiKey is untouched: the plaintext is still only cleared after the migration's checks and store resolve, so a failed migration still retries on next launch. --- src/core/config/ContextProxy.ts | 5 +++ .../config/__tests__/ContextProxy.spec.ts | 33 +++++++++++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/core/config/ContextProxy.ts b/src/core/config/ContextProxy.ts index 45ce1b231a..c327c202e9 100644 --- a/src/core/config/ContextProxy.ts +++ b/src/core/config/ContextProxy.ts @@ -634,6 +634,11 @@ export class ContextProxy { ...GLOBAL_STATE_KEYS.map((key) => this.originalContext.globalState.update(key, undefined)), ...SECRET_STATE_KEYS.map((key) => this.originalContext.secrets.delete(key)), ...GLOBAL_SECRET_KEYS.map((key) => this.originalContext.secrets.delete(key)), + // mimoApiKey is a SECRET_STATE_KEY, so it is absent from GLOBAL_STATE_KEYS + // and the sweep above leaves any plaintext copy behind. The initialize() + // below would then migrate that survivor into SecretStorage, resurrecting + // a credential this reset is meant to clear. + this.originalContext.globalState.update("mimoApiKey", undefined), ]) await this.initialize() diff --git a/src/core/config/__tests__/ContextProxy.spec.ts b/src/core/config/__tests__/ContextProxy.spec.ts index d33dcb2640..0fd4e60291 100644 --- a/src/core/config/__tests__/ContextProxy.spec.ts +++ b/src/core/config/__tests__/ContextProxy.spec.ts @@ -456,9 +456,38 @@ describe("ContextProxy", () => { expect(mockGlobalState.update).toHaveBeenCalledWith(key, undefined) } - // Total calls should include initial setup + reset operations - const expectedUpdateCalls = 2 + GLOBAL_STATE_KEYS.length + // Total calls should include initial setup + reset operations, plus the + // plaintext mimoApiKey purge (the key is a SECRET_STATE_KEY, so it is not + // covered by the GLOBAL_STATE_KEYS sweep). + const expectedUpdateCalls = 3 + GLOBAL_STATE_KEYS.length expect(mockGlobalState.update).toHaveBeenCalledTimes(expectedUpdateCalls) + expect(mockGlobalState.update).toHaveBeenCalledWith("mimoApiKey", undefined) + }) + + it("does not resurrect a surviving plaintext mimoApiKey into SecretStorage", async () => { + // Builds before the key joined SECRET_STATE_KEYS could leave a plaintext + // mimoApiKey in globalState. Because the key is a secret-state key it is + // absent from GLOBAL_STATE_KEYS, so the reset sweep never touched the + // plaintext copy; the initialize() that reset runs afterwards then found + // it and migrated it into SecretStorage. Reset therefore resurrected the + // credential it is supposed to clear. + clearAllMocks() + + let plaintextKey: string | undefined = "surviving-plaintext-mimo-key" + mockGlobalState.get.mockImplementation((key: string) => (key === "mimoApiKey" ? plaintextKey : undefined)) + mockGlobalState.update.mockImplementation((key: string, value: unknown) => { + if (key === "mimoApiKey" && value === undefined) { + plaintextKey = undefined + } + return Promise.resolve() + }) + mockSecrets.get.mockResolvedValue(undefined) + + const resetProxy = new ContextProxy(mockContext) + await resetProxy.resetAllState() + + expect(mockSecrets.store).not.toHaveBeenCalledWith("mimoApiKey", expect.anything()) + expect(resetProxy.getSecret("mimoApiKey")).toBeUndefined() }) it("should delete all secrets", async () => { From 079a7c3ca37dfeb05a0d06709c5c54b2fd3f6148 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sun, 1 Nov 2026 03:57:28 +0900 Subject: [PATCH 38/46] fix(webview): keep MiMo refresh status in one persistent live region What: the loading/success/error status divs in the MiMo provider settings panel now render inside a single wrapper with role="status" and aria-live="polite", and Mimo.spec.tsx pins that the wrapper stays mounted while its content changes. The spec's hand-wrapped QueryClientProvider also moves to renderWithExtensionState, which the webview lint rule requires. Why: each status div used to mount and unmount as the region itself. A live region only announces content that changes inside an element already present in the accessibility tree, so screen-reader users got no announcement for the refresh outcome. Impact: refresh progress and results are announced to assistive tech. DOM structure gains one wrapper div; the existing text queries are unchanged. --- .../components/settings/providers/Mimo.tsx | 34 ++++++----- .../providers/__tests__/Mimo.spec.tsx | 57 +++++++++++++++---- 2 files changed, 68 insertions(+), 23 deletions(-) diff --git a/webview-ui/src/components/settings/providers/Mimo.tsx b/webview-ui/src/components/settings/providers/Mimo.tsx index 7a277e4fea..04fb96dfe1 100644 --- a/webview-ui/src/components/settings/providers/Mimo.tsx +++ b/webview-ui/src/components/settings/providers/Mimo.tsx @@ -157,19 +157,27 @@ export const Mimo = ({ apiConfiguration, setApiConfigurationField }: MimoProps) {t("settings:providers.refreshModels.label")}
- {refreshStatus === RefreshStatus.Loading && ( -
- {t("settings:providers.refreshModels.loading")} -
- )} - {refreshStatus === RefreshStatus.Success && ( -
{t("settings:providers.refreshModels.success")}
- )} - {refreshStatus === RefreshStatus.Error && ( -
- {refreshError || t("settings:providers.refreshModels.error")} -
- )} + {/* One persistent live region: screen readers only announce content that + changes inside an element that is already in the accessibility tree, + so the conditional status divs mount into this wrapper instead of + appearing and disappearing as the region itself. */} +
+ {refreshStatus === RefreshStatus.Loading && ( +
+ {t("settings:providers.refreshModels.loading")} +
+ )} + {refreshStatus === RefreshStatus.Success && ( +
+ {t("settings:providers.refreshModels.success")} +
+ )} + {refreshStatus === RefreshStatus.Error && ( +
+ {refreshError || t("settings:providers.refreshModels.error")} +
+ )} +
) } diff --git a/webview-ui/src/components/settings/providers/__tests__/Mimo.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/Mimo.spec.tsx index 4857e516ec..7dc6899522 100644 --- a/webview-ui/src/components/settings/providers/__tests__/Mimo.spec.tsx +++ b/webview-ui/src/components/settings/providers/__tests__/Mimo.spec.tsx @@ -1,7 +1,14 @@ // npx vitest src/components/settings/providers/__tests__/Mimo.spec.tsx -import { render, screen, fireEvent, waitFor, act } from "@/utils/test-utils" -import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { + render, + screen, + fireEvent, + waitFor, + act, + renderWithExtensionState, + createTestQueryClient, +} from "@/utils/test-utils" import { allRouterModelsProvider, providerIdentifiers, type ProviderSettings } from "@roo-code/types" import { Mimo } from "../Mimo" @@ -168,16 +175,15 @@ describe("Mimo Component", () => { }) it("invalidates only the MiMo and shared router-model caches after a successful refresh", async () => { - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const queryClient = createTestQueryClient() const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") - render( - - - , + renderWithExtensionState( + , + { queryClient }, ) fireEvent.click(findRefreshButton()!) @@ -313,6 +319,37 @@ describe("Mimo Component", () => { ) }) + it("keeps one persistent polite live region mounted across status changes", async () => { + // Screen readers only announce content that changes inside an element already + // present in the accessibility tree. When each status div mounts and unmounts + // as the region itself, the announcement is lost, so the wrapper has to stay + // mounted and only its children change. + render( + , + ) + + const liveRegion = screen.getByRole("status") + expect(liveRegion).toHaveAttribute("aria-live", "polite") + expect(liveRegion).toBeEmptyDOMElement() + + fireEvent.click(findRefreshButton()!) + + expect(screen.getByRole("status")).toBe(liveRegion) + expect(liveRegion).toHaveTextContent("settings:providers.refreshModels.loading") + + act(() => { + window.dispatchEvent(new MessageEvent("message", { data: { type: "routerModels" } })) + }) + + await waitFor(() => { + expect(liveRegion).toHaveTextContent("settings:providers.refreshModels.success") + }) + expect(screen.getByRole("status")).toBe(liveRegion) + }) + it("shows 'Get MiMo API Key' link when no API key is set", () => { render( Date: Sun, 1 Nov 2026 04:00:22 +0900 Subject: [PATCH 39/46] fix(webview): preserve a router-only MiMo ID when the catalog settles empty What: the MiMo case of getSelectedModel now skips ID validation when the router catalog settled but listed no models, keeping the saved apiModelId with info undefined. The existing invalid-ID spec row is retargeted to a populated catalog that omits the ID, which is the case that still falls back to the default. Why: a settled-but-empty catalog reaches the ready path, where the ID was validated against the merged catalog. A router-only ID is absent from the static catalog, so it reset to the default, and that default leaked into ApiOptions' apiModelId sync effect and could be baked into saved settings on Save. The unsettled path already preserves the ID; this closes the settled-empty gap. Impact: a saved router-only MiMo model survives a refresh that returns no models. The preserve branch is prototype-guarded with Object.hasOwn, so IDs naming Object.prototype members still resolve to the static default, and an empty-string apiModelId still resolves to the default via the existing `||` convention. --- .../hooks/__tests__/useSelectedModel.spec.ts | 39 +++++++++++++++++++ .../components/ui/hooks/useSelectedModel.ts | 20 +++++++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts index 20037ebeeb..7c3addaea4 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts @@ -356,6 +356,32 @@ describe("useSelectedModel", () => { expect(result.current.info).toEqual(routerModelInfo) }) + // Regression: a settled-but-EMPTY router catalog (the provider returned no + // models) reaches the ready path, where the ID was validated against the merged + // catalog. A saved router-only ID is absent from the static catalog, so it was + // reset to the static default — the same overwrite the unsettled path already + // guards against, and it leaked into ApiOptions' apiModelId sync effect. + it("mimo: preserves a router-only configured ID when the router catalog settles empty", () => { + const routerOnlyModelId = "mimo-router-only-settled-empty" + + // Guard the premise: an ID in the static catalog would not exercise the + // preserve branch at all. + expect(mimoModels[routerOnlyModelId as keyof typeof mimoModels]).toBeUndefined() + + mockUseRouterModels.mockReturnValue(createRouterModelsResult({ [providerIdentifiers.mimo]: {} })) + mockUseOpenRouterModelProviders.mockReturnValue(createOpenRouterModelProvidersResult({})) + + const { result } = renderHook( + () => useSelectedModel({ apiProvider: providerIdentifiers.mimo, apiModelId: routerOnlyModelId }), + { wrapper: createWrapper() }, + ) + + // The saved ID survives; info stays undefined because no catalog describes it, + // mirroring the unsettled-preservation contract. + expect(result.current.id).toBe(routerOnlyModelId) + expect(result.current.info).toBeUndefined() + }) + // Regression: both the ID validation and the metadata lookup used plain bracket // reads, so an ID naming an Object.prototype member was "found" in the catalog and // surfaced as a model with no metadata instead of falling back to the static spec. @@ -2426,6 +2452,19 @@ describe("useSelectedModel", () => { }) it("should fallback to default when model ID is not in static or router models", () => { + // The router catalog must be POPULATED for this to be an invalid selection: + // a settled-but-empty catalog carries no information about which IDs exist, + // so the hook preserves the saved ID instead (see the router-only + // settled-empty regression above). Listing other models is what makes the + // configured ID provably absent. + mockUseRouterModels.mockReturnValue( + createRouterModelsResult({ + [providerIdentifiers.mimo]: { + "mimo-v2.6-flash": { contextWindow: 1_048_576, supportsPromptCache: true, maxTokens: 131_072 }, + }, + }), + ) + const apiConfiguration: ProviderSettings = { apiProvider: providerIdentifiers.mimo, apiModelId: "non-existent-model", diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index 7b7e5d48c1..1497fe6fbf 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -456,7 +456,25 @@ function getSelectedModel({ case providerIdentifiers.mimo: { const routerCatalog = routerModels[providerIdentifiers.mimo] const availableModels = routerCatalog ? { ...mimoModels, ...routerCatalog } : mimoModels - const id = getValidatedModelId(apiConfiguration.apiModelId, availableModels, defaultModelId) + const configuredId = apiConfiguration.apiModelId + // A settled-but-empty router catalog carries no information about which + // IDs exist: the provider answered, but listed nothing. Validating the + // saved ID against the static catalog alone would reset a router-only + // selection to the default and leak that default into ApiOptions' + // apiModelId sync effect, so preserve it here, mirroring the unsettled + // path. Prototype-safe on both sides: an ID naming an Object.prototype + // member has no own entry in either catalog, so it must still fall back + // to the static spec instead of being preserved as a phantom selection. + const settledEmptyCatalog = !!routerCatalog && Object.keys(routerCatalog).length === 0 + if ( + settledEmptyCatalog && + configuredId && + !Object.hasOwn(mimoModels, configuredId) && + !Object.hasOwn(Object.prototype, configuredId) + ) { + return { id: configuredId, info: undefined } + } + const id = getValidatedModelId(configuredId, availableModels, defaultModelId) // Prototype-safe on both sides: the router catalog carries IDs straight // from the provider's /models response, so `catalog["constructor"]` // would otherwise resolve to an inherited member and surface as a model From e0a73f17038bbaf1abb3067ab9d3457e026f6a28 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sun, 1 Nov 2026 04:03:43 +0900 Subject: [PATCH 40/46] fix(api): purge an off-list stored mimoBaseUrl in setConfiguration What: setConfiguration now reads the stored mimoBaseUrl through the provider accessor and clears it via contextProxy.updateGlobalState when it is non-empty and outside the allowlist after stripTrailingSlashes. The three existing spec doubles gain the accessor and updateGlobalState, and three cases cover purge, valid-cluster preservation, and the no-op path. Why: dropping the incoming value only sanitizes what the caller passes. A partial update omits mimoBaseUrl, so an off-list value already in storage (written by an older build or another extension) survived the call and kept steering requests to that endpoint. Impact: an off-list stored endpoint is removed instead of persisting indefinitely. A stored allowlisted endpoint is untouched, so a rejected input cannot take a valid cluster down with it, and an empty stored value still means "unset". --- .../__tests__/api-configuration.spec.ts | 58 +++++++++++++++++-- src/extension/api.ts | 24 ++++++++ 2 files changed, 77 insertions(+), 5 deletions(-) diff --git a/src/extension/__tests__/api-configuration.spec.ts b/src/extension/__tests__/api-configuration.spec.ts index e620163331..2be79d41f6 100644 --- a/src/extension/__tests__/api-configuration.spec.ts +++ b/src/extension/__tests__/api-configuration.spec.ts @@ -17,12 +17,14 @@ describe("API - configuration", () => { const saveConfig = vi.fn().mockResolvedValue("default-id") const setModeConfig = vi.fn().mockResolvedValue(undefined) const postStateToWebview = vi.fn().mockResolvedValue(undefined) + const updateGlobalState = vi.fn().mockResolvedValue(undefined) const provider = { context: {}, on: vi.fn(), - contextProxy: { setValues }, + contextProxy: { setValues, updateGlobalState }, providerSettingsManager: { saveConfig, setModeConfig }, postStateToWebview, + getValue: vi.fn(), } as unknown as ClineProvider const outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel const api = new API(outputChannel, provider, makeClineProviderFactory()) @@ -50,12 +52,14 @@ describe("API - configuration", () => { const saveConfig = vi.fn().mockResolvedValue("default-id") const setModeConfig = vi.fn().mockResolvedValue(undefined) const postStateToWebview = vi.fn().mockResolvedValue(undefined) + const updateGlobalState = vi.fn().mockResolvedValue(undefined) const provider = { context: {}, on: vi.fn(), - contextProxy: { setValues }, + contextProxy: { setValues, updateGlobalState }, providerSettingsManager: { saveConfig, setModeConfig }, postStateToWebview, + getValue: vi.fn(), } as unknown as ClineProvider const outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel const api = new API(outputChannel, provider, makeClineProviderFactory()) @@ -95,21 +99,30 @@ describe("API - configuration", () => { // schema rejection, so this was the one write path where an off-list // mimoBaseUrl could newly enter storage. Both persistence calls are // asserted: the proxy write and the saved profile. - const makeApi = () => { + const makeApi = (storedBaseUrl?: string) => { const setValues = vi.fn().mockResolvedValue(undefined) const saveConfig = vi.fn().mockResolvedValue("default-id") const setModeConfig = vi.fn().mockResolvedValue(undefined) const postStateToWebview = vi.fn().mockResolvedValue(undefined) + const updateGlobalState = vi.fn().mockResolvedValue(undefined) const provider = { context: {}, on: vi.fn(), - contextProxy: { setValues }, + contextProxy: { setValues, updateGlobalState }, providerSettingsManager: { saveConfig, setModeConfig }, postStateToWebview, + // The stored-value read goes through the provider-facing accessor, the + // same one every other consumer uses, so the double only needs that. + getValue: vi.fn((key: string) => (key === "mimoBaseUrl" ? storedBaseUrl : undefined)), } as unknown as ClineProvider const outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel - return { api: new API(outputChannel, provider, makeClineProviderFactory()), setValues, saveConfig } + return { + api: new API(outputChannel, provider, makeClineProviderFactory()), + setValues, + saveConfig, + updateGlobalState, + } } // The declared type is the four-literal union, so an off-list value needs @@ -174,5 +187,40 @@ describe("API - configuration", () => { apiModelId: "mimo-v2.6-pro", }) }) + + it("purges a stored off-list mimoBaseUrl that a partial update would otherwise leave behind", async () => { + // The stored value was written by an older build or another extension, so + // the sanitizer never saw it. A partial update that omits mimoBaseUrl does + // not overwrite it, so without an explicit purge the off-list endpoint + // survives the call and keeps steering requests. + const { api, setValues, updateGlobalState } = makeApi("https://attacker.example/v1") + + await api.setConfiguration({ currentApiConfigName: "default", apiModelId: "mimo-v2.6-pro" }) + + expect(updateGlobalState).toHaveBeenCalledWith("mimoBaseUrl", undefined) + expect(setValues).toHaveBeenCalledWith({ + currentApiConfigName: "default", + apiModelId: "mimo-v2.6-pro", + }) + }) + + it("preserves a valid stored mimoBaseUrl during a partial update with rejected input", async () => { + // The rejected input must not take the stored cluster down with it: only + // the incoming value is dropped, and the stored allowlisted endpoint stays. + const { api, setValues, updateGlobalState } = makeApi("https://token-plan-ams.xiaomimimo.com/v1") + + await api.setConfiguration(offListConfiguration("https://attacker.example/v1")) + + expect(updateGlobalState).not.toHaveBeenCalledWith("mimoBaseUrl", undefined) + expect(setValues).toHaveBeenCalledWith(expect.not.objectContaining({ mimoBaseUrl: expect.anything() })) + }) + + it("does not purge a stored allowlisted mimoBaseUrl on a partial update", async () => { + const { api, updateGlobalState } = makeApi("https://api.xiaomimimo.com/v1") + + await api.setConfiguration({ currentApiConfigName: "default", apiModelId: "mimo-v2.6-pro" }) + + expect(updateGlobalState).not.toHaveBeenCalledWith("mimoBaseUrl", undefined) + }) }) }) diff --git a/src/extension/api.ts b/src/extension/api.ts index ae8aea977c..a14ad2dbba 100644 --- a/src/extension/api.ts +++ b/src/extension/api.ts @@ -640,6 +640,15 @@ export class API extends EventEmitter implements RooCodeAPI { // (fall back to the default cluster) instead of pinning a cluster. const sanitizedValues = this.withoutOffListMimoBaseUrl(values) + // Dropping the incoming value is not enough on its own: a partial update + // omits mimoBaseUrl, so an off-list value already in storage (written by an + // older build or another extension) survives this call and keeps steering + // requests. Purge it explicitly. A stored allowlisted endpoint is left alone + // so a rejected input cannot take a valid cluster down with it. + if (this.isStoredMimoBaseUrlOffList()) { + await this.sidebarProvider.contextProxy.updateGlobalState("mimoBaseUrl", undefined) + } + await this.sidebarProvider.contextProxy.setValues(sanitizedValues) await this.sidebarProvider.providerSettingsManager.saveConfig( sanitizedValues.currentApiConfigName || "default", @@ -655,6 +664,21 @@ export class API extends EventEmitter implements RooCodeAPI { await this.sidebarProvider.postStateToWebview() } + /** + * Reports whether the mimoBaseUrl currently in storage is a non-empty value + * outside the four allowed Xiaomi endpoints. Empty string means "unset" and + * counts as on-list, matching the sanitizer's convention. + */ + private isStoredMimoBaseUrlOffList(): boolean { + const storedBaseUrl: string | undefined = this.sidebarProvider.getValue("mimoBaseUrl") + + if (storedBaseUrl === undefined || storedBaseUrl === "") { + return false + } + + return !ALLOWED_BASE_URLS.has(stripTrailingSlashes(storedBaseUrl)) + } + /** * Returns `values` unchanged unless it carries a non-empty mimoBaseUrl outside * the four allowed Xiaomi endpoints, in which case that one key is omitted. From c189c0e8a94b13a5c3799514d2b665cc54d93f19 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sun, 1 Nov 2026 05:09:38 +0900 Subject: [PATCH 41/46] fix(api): tolerate a corrupted stored mimoBaseUrl and truth the write-path comment What: isStoredMimoBaseUrlOffList now reads the stored value as `unknown` and treats any non-string as off-list (purge) instead of passing it to stripTrailingSlashes, which threw TypeError on `.endsWith` and rejected the whole setConfiguration write. Adds two spec rows: a stored allowlisted URL carrying a trailing slash survives a partial update, and non-string stored values (42, an object, true) resolve to a purge rather than a rejection. The setConfiguration leading comment no longer claims this is the only write path. Why: the helper exists precisely because storage can hold values no schema ever saw, so a corrupted value must not be a new failure mode of the fix. The trailing-slash row pins the stripTrailingSlashes guard: only the slash-less literal is on the allowlist, so dropping the strip would purge every such cluster and silently drop the user to the default SGP endpoint. The comment was stale after review confirmed ClineProvider.createTask persists a caller-supplied configuration through setValues with no allowlist, which makes the consumers' fail-closed checks load-bearing rather than redundant. Impact: a corrupted mimoBaseUrl in storage is cleared on the next setConfiguration instead of breaking it; behavior for valid, off-list, empty and unset stored values is unchanged. No production behavior change from the two spec rows or the comment. Co-Authored-By: Claude --- .../__tests__/api-configuration.spec.ts | 42 ++++++++++++++++++- src/extension/api.ts | 33 +++++++++++---- 2 files changed, 64 insertions(+), 11 deletions(-) diff --git a/src/extension/__tests__/api-configuration.spec.ts b/src/extension/__tests__/api-configuration.spec.ts index 2be79d41f6..cb3121b7cf 100644 --- a/src/extension/__tests__/api-configuration.spec.ts +++ b/src/extension/__tests__/api-configuration.spec.ts @@ -96,10 +96,12 @@ describe("API - configuration", () => { describe("mimoBaseUrl allowlist at the public API boundary", () => { // setConfiguration writes straight to ContextProxy, which fails open on a - // schema rejection, so this was the one write path where an off-list + // schema rejection, so this is one of the write paths where an off-list // mimoBaseUrl could newly enter storage. Both persistence calls are // asserted: the proxy write and the saved profile. - const makeApi = (storedBaseUrl?: string) => { + // `storedBaseUrl` is deliberately `unknown`: storage is not schema-validated + // on read, so the corrupted-value cases below need to inject a non-string. + const makeApi = (storedBaseUrl?: unknown) => { const setValues = vi.fn().mockResolvedValue(undefined) const saveConfig = vi.fn().mockResolvedValue("default-id") const setModeConfig = vi.fn().mockResolvedValue(undefined) @@ -222,5 +224,41 @@ describe("API - configuration", () => { expect(updateGlobalState).not.toHaveBeenCalledWith("mimoBaseUrl", undefined) }) + + it("does not purge a stored allowlisted mimoBaseUrl that carries a trailing slash", async () => { + // setConfiguration persists a trailing-slash variant verbatim (see the + // incoming-value case above), so this exact stored shape is reachable + // through this very API. Only the slash-less literal is on the allowlist, + // so a stored-value check that skipped stripTrailingSlashes would purge + // every such cluster and silently drop the user to the default SGP one. + const { api, updateGlobalState } = makeApi("https://token-plan-ams.xiaomimimo.com/v1/") + + await api.setConfiguration({ currentApiConfigName: "default", apiModelId: "mimo-v2.6-pro" }) + + expect(updateGlobalState).not.toHaveBeenCalledWith("mimoBaseUrl", undefined) + }) + + it.each([[42], [{ nested: "https://attacker.example/v1" }], [true]])( + "treats a non-string stored mimoBaseUrl (%s) as off-list instead of throwing", + async (storedBaseUrl) => { + // The stored read is typed `string | undefined`, but that is compile-time + // only: ContextProxy hands back whatever the cache holds, and this helper + // exists precisely because storage can carry values no schema ever saw. + // stripTrailingSlashes calls .endsWith, so without a typeof guard a + // corrupted value throws TypeError and rejects the whole configuration + // write - a new failure mode in the scenario the helper targets. + const { api, setValues, updateGlobalState } = makeApi(storedBaseUrl) + + await expect( + api.setConfiguration({ currentApiConfigName: "default", apiModelId: "mimo-v2.6-pro" }), + ).resolves.toBeUndefined() + + expect(updateGlobalState).toHaveBeenCalledWith("mimoBaseUrl", undefined) + expect(setValues).toHaveBeenCalledWith({ + currentApiConfigName: "default", + apiModelId: "mimo-v2.6-pro", + }) + }, + ) }) }) diff --git a/src/extension/api.ts b/src/extension/api.ts index a14ad2dbba..c30046f955 100644 --- a/src/extension/api.ts +++ b/src/extension/api.ts @@ -630,14 +630,20 @@ export class API extends EventEmitter implements RooCodeAPI { } public async setConfiguration(values: RooCodeSettings) { - // The allowlist normally lives at the two boundaries that consume this - // value (the updateSettings persistence gate and MimoHandler's - // constructor), but this public API writes straight to ContextProxy and - // ContextProxy fails open on a schema rejection, so a caller could park - // an off-list mimoBaseUrl in storage and every consumer would then have to - // defend against it. Drop it here instead of persisting it. The value is - // removed rather than overwritten so "unset" keeps its existing meaning - // (fall back to the default cluster) instead of pinning a cluster. + // The allowlist lives at the two boundaries that consume this value (the + // updateSettings persistence gate and MimoHandler's constructor), but this + // public API writes straight to ContextProxy and ContextProxy fails open on + // a schema rejection, so a caller could park an off-list mimoBaseUrl in + // storage. Drop it here instead of persisting it. The value is removed + // rather than overwritten so "unset" keeps its existing meaning (fall back + // to the default cluster) instead of pinning a cluster. + // + // This is not the only write path: ClineProvider.createTask persists a + // caller-supplied configuration through setValues without consulting the + // allowlist, so an off-list value can still reach storage that way. Every + // consumer therefore fails closed on its own (MimoHandler's constructor + // throws, the model fetcher and the router-models handlers refuse the + // value) rather than trusting that storage was sanitized on the way in. const sanitizedValues = this.withoutOffListMimoBaseUrl(values) // Dropping the incoming value is not enough on its own: a partial update @@ -670,12 +676,21 @@ export class API extends EventEmitter implements RooCodeAPI { * counts as on-list, matching the sanitizer's convention. */ private isStoredMimoBaseUrlOffList(): boolean { - const storedBaseUrl: string | undefined = this.sidebarProvider.getValue("mimoBaseUrl") + // Typed as the four-literal union, but the type is compile-time only: the + // accessor returns whatever the state cache holds, and this helper exists + // because storage can carry values no schema ever saw. A non-string is + // corrupted storage, not a usable endpoint, so it counts as off-list and + // gets purged instead of throwing out of stripTrailingSlashes. + const storedBaseUrl: unknown = this.sidebarProvider.getValue("mimoBaseUrl") if (storedBaseUrl === undefined || storedBaseUrl === "") { return false } + if (typeof storedBaseUrl !== "string") { + return true + } + return !ALLOWED_BASE_URLS.has(stripTrailingSlashes(storedBaseUrl)) } From cb94c392cd7f99109d2e73cd31b55b622b01b158 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sun, 1 Nov 2026 05:10:45 +0900 Subject: [PATCH 42/46] test(webview-ui): pin both skip clauses of the settled-empty MiMo preserve branch What: two rows in the settled-empty group. A static-catalog ID (mimo-v2.6-flash) under `{mimo:{}}` keeps its ID and its curated spec, and an empty-string apiModelId under the same settled catalog resolves to the default. The existing empty-string fixture runs the unsettled path, so it never reached this branch. Why: both clauses were behavior-load-bearing and mutation-undetected. Dropping `!Object.hasOwn(mimoModels, configuredId)` makes the branch return `{ id, info: undefined }` for a model that has a curated spec, which blanks capability UI such as the context-window display; the ID assertion alone cannot see that, so the metadata assertion is what pins it. Dropping `configuredId &&` preserves "" as a phantom selection. Verified by mutation: each clause removal fails exactly one test, and in both cases it is the row added here (184 passed / 1 failed with the other clause intact). Impact: test-only. No production behavior change. Co-Authored-By: Claude --- .../hooks/__tests__/useSelectedModel.spec.ts | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts index 7c3addaea4..c1ae91c8b3 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts @@ -382,6 +382,51 @@ describe("useSelectedModel", () => { expect(result.current.info).toBeUndefined() }) + // The preserve branch must not swallow IDs the static catalog already knows: + // those take the normal validation path and keep their curated spec. Without + // that clause the branch would return `{ id, info: undefined }` for a + // perfectly well-described model, blanking capability UI such as the + // context-window display. The ID assertion alone cannot see the difference, + // so the metadata assertion is the one that pins the clause. + it("mimo: keeps the curated spec for a static-catalog ID when the router catalog settles empty", () => { + const staticModelId = "mimo-v2.6-flash" + + // Guard the premise: an ID missing from the static catalog would exercise + // the preserve branch instead of the validation path under test. + expect(Object.hasOwn(mimoModels, staticModelId)).toBe(true) + + mockUseRouterModels.mockReturnValue(createRouterModelsResult({ [providerIdentifiers.mimo]: {} })) + mockUseOpenRouterModelProviders.mockReturnValue(createOpenRouterModelProvidersResult({})) + + const { result } = renderHook( + () => useSelectedModel({ apiProvider: providerIdentifiers.mimo, apiModelId: staticModelId }), + { wrapper: createWrapper() }, + ) + + expect(result.current.id).toBe(staticModelId) + expect(result.current.info).toEqual(mimoModels[staticModelId as keyof typeof mimoModels]) + expect(result.current.info?.contextWindow).toBe(1_048_576) + }) + + // The `configuredId &&` clause keeps the empty-string convention on this + // branch too: "" means "unset", so it resolves to the default instead of + // being preserved as a phantom selection. Unlike the unsettled fixture, this + // one reaches the ready path with a settled-but-empty catalog. + it("mimo: treats an empty configured ID as unset when the router catalog settles empty", () => { + mockUseRouterModels.mockReturnValue(createRouterModelsResult({ [providerIdentifiers.mimo]: {} })) + mockUseOpenRouterModelProviders.mockReturnValue(createOpenRouterModelProvidersResult({})) + + const { result } = renderHook( + () => useSelectedModel({ apiProvider: providerIdentifiers.mimo, apiModelId: "" }), + { + wrapper: createWrapper(), + }, + ) + + expect(result.current.id).toBe(mimoDefaultModelId) + expect(result.current.info).toEqual(mimoModels[mimoDefaultModelId]) + }) + // Regression: both the ID validation and the metadata lookup used plain bracket // reads, so an ID naming an Object.prototype member was "found" in the catalog and // surfaced as a model with no metadata instead of falling back to the static spec. From 9fe6b7136db2c885bf8a7b02b38c238414e26723 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sun, 1 Nov 2026 05:11:23 +0900 Subject: [PATCH 43/46] fix(i18n): add the missing refreshModels.missingConfig key to every locale What: adds `settings.providers.refreshModels.missingConfig` to all 18 locale settings.json files, placed after `error` inside the existing refreshModels block and carrying the en-US string as the placeholder value. Why: four providers (MiMo, Moonshot, LiteLLM, Poe) render t("settings:providers.refreshModels.missingConfig") when the refresh button is clicked without an API key, but the key existed in no locale. i18next fell back to printing the raw key, so users saw the dotted path instead of a sentence. `node scripts/find-missing-i18n-key.js` reported the key as missing for each of the four call sites; `node scripts/find-missing-translations.js` now passes for all locales. Impact: UI copy only. Non-English locales show the English sentence until translations land, which is the same fallback shape the repo already uses for untranslated keys. Co-Authored-By: Claude --- webview-ui/src/i18n/locales/ca/settings.json | 3 ++- webview-ui/src/i18n/locales/de/settings.json | 3 ++- webview-ui/src/i18n/locales/en/settings.json | 3 ++- webview-ui/src/i18n/locales/es/settings.json | 3 ++- webview-ui/src/i18n/locales/fr/settings.json | 3 ++- webview-ui/src/i18n/locales/hi/settings.json | 3 ++- webview-ui/src/i18n/locales/id/settings.json | 3 ++- webview-ui/src/i18n/locales/it/settings.json | 3 ++- webview-ui/src/i18n/locales/ja/settings.json | 3 ++- webview-ui/src/i18n/locales/ko/settings.json | 3 ++- webview-ui/src/i18n/locales/nl/settings.json | 3 ++- webview-ui/src/i18n/locales/pl/settings.json | 3 ++- webview-ui/src/i18n/locales/pt-BR/settings.json | 3 ++- webview-ui/src/i18n/locales/ru/settings.json | 3 ++- webview-ui/src/i18n/locales/tr/settings.json | 3 ++- webview-ui/src/i18n/locales/vi/settings.json | 3 ++- webview-ui/src/i18n/locales/zh-CN/settings.json | 3 ++- webview-ui/src/i18n/locales/zh-TW/settings.json | 3 ++- 18 files changed, 36 insertions(+), 18 deletions(-) diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index d39d8edd6a..77721c53f4 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -485,7 +485,8 @@ "hint": "Si us plau, torneu a obrir la configuració per veure els models més recents.", "loading": "Actualitzant la llista de models...", "success": "Llista de models actualitzada correctament!", - "error": "No s'ha pogut actualitzar la llista de models. Si us plau, torneu-ho a provar." + "error": "No s'ha pogut actualitzar la llista de models. Si us plau, torneu-ho a provar.", + "missingConfig": "Enter an API key first, then refresh the models list." }, "getRequestyApiKey": "Obtenir clau API de Requesty", "getRequestyBaseUrl": "URL base", diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 983db1df5d..598481f8b6 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -485,7 +485,8 @@ "hint": "Bitte öffne die Einstellungen erneut, um die neuesten Modelle zu sehen.", "loading": "Modellliste wird aktualisiert...", "success": "Modellliste erfolgreich aktualisiert!", - "error": "Fehler beim Aktualisieren der Modellliste. Bitte versuche es erneut." + "error": "Fehler beim Aktualisieren der Modellliste. Bitte versuche es erneut.", + "missingConfig": "Enter an API key first, then refresh the models list." }, "getRequestyApiKey": "Requesty API-Schlüssel erhalten", "getRequestyBaseUrl": "Basis-URL", diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index ea924ce79e..a7da4ce81f 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -565,7 +565,8 @@ "hint": "Please reopen the settings to see the latest models.", "loading": "Refreshing models list...", "success": "Models list refreshed successfully!", - "error": "Failed to refresh models list. Please try again." + "error": "Failed to refresh models list. Please try again.", + "missingConfig": "Enter an API key first, then refresh the models list." }, "getRequestyApiKey": "Create new Requesty API Key", "getRequestyBaseUrl": "Base URL", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 57612bf3e5..4385fcfb70 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -485,7 +485,8 @@ "hint": "Por favor, vuelve a abrir la configuración para ver los modelos más recientes.", "loading": "Actualizando lista de modelos...", "success": "¡Lista de modelos actualizada correctamente!", - "error": "Error al actualizar la lista de modelos. Por favor, inténtalo de nuevo." + "error": "Error al actualizar la lista de modelos. Por favor, inténtalo de nuevo.", + "missingConfig": "Enter an API key first, then refresh the models list." }, "getRequestyApiKey": "Obtener clave API de Requesty", "getRequestyBaseUrl": "URL base", diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index a91a5137d3..1ca2bdae82 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -485,7 +485,8 @@ "hint": "Veuillez rouvrir les paramètres pour voir les modèles les plus récents.", "loading": "Actualisation de la liste des modèles...", "success": "Liste des modèles actualisée avec succès !", - "error": "Échec de l'actualisation de la liste des modèles. Veuillez réessayer." + "error": "Échec de l'actualisation de la liste des modèles. Veuillez réessayer.", + "missingConfig": "Enter an API key first, then refresh the models list." }, "getRequestyApiKey": "Obtenir la clé API Requesty", "getRequestyBaseUrl": "URL de base", diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index bc6e973623..75da251144 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -485,7 +485,8 @@ "hint": "नवीनतम मॉडल देखने के लिए कृपया सेटिंग्स को फिर से खोलें।", "loading": "मॉडल सूची अपडेट हो रही है...", "success": "मॉडल सूची सफलतापूर्वक अपडेट की गई!", - "error": "मॉडल सूची अपडेट करने में विफल। कृपया पुनः प्रयास करें।" + "error": "मॉडल सूची अपडेट करने में विफल। कृपया पुनः प्रयास करें।", + "missingConfig": "Enter an API key first, then refresh the models list." }, "getRequestyApiKey": "Requesty API कुंजी प्राप्त करें", "getRequestyBaseUrl": "बेस URL", diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index 3ed57f373e..d0cea24473 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -485,7 +485,8 @@ "hint": "Silakan buka kembali pengaturan untuk melihat model terbaru.", "loading": "Merefresh daftar model...", "success": "Daftar model berhasil direfresh!", - "error": "Gagal merefresh daftar model. Silakan coba lagi." + "error": "Gagal merefresh daftar model. Silakan coba lagi.", + "missingConfig": "Enter an API key first, then refresh the models list." }, "getRequestyApiKey": "Dapatkan Requesty API Key", "getRequestyBaseUrl": "Base URL", diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index b708304e63..99f1a3a540 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -485,7 +485,8 @@ "hint": "Riapri le impostazioni per vedere i modelli più recenti.", "loading": "Aggiornamento dell'elenco dei modelli...", "success": "Elenco dei modelli aggiornato con successo!", - "error": "Impossibile aggiornare l'elenco dei modelli. Riprova." + "error": "Impossibile aggiornare l'elenco dei modelli. Riprova.", + "missingConfig": "Enter an API key first, then refresh the models list." }, "getRequestyApiKey": "Ottieni chiave API Requesty", "getRequestyBaseUrl": "URL base", diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index f6959b50dc..8b22bc3fba 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -485,7 +485,8 @@ "hint": "最新のモデルを表示するには設定を再度開いてください。", "loading": "モデルリストを更新中...", "success": "モデルリストが正常に更新されました!", - "error": "モデルリストの更新に失敗しました。もう一度お試しください。" + "error": "モデルリストの更新に失敗しました。もう一度お試しください。", + "missingConfig": "Enter an API key first, then refresh the models list." }, "getRequestyApiKey": "Requesty APIキーを取得", "getRequestyBaseUrl": "ベースURL", diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 6cadbd8a3b..43bd3a1953 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -485,7 +485,8 @@ "hint": "최신 모델을 보려면 설정을 다시 열어주세요.", "loading": "모델 목록 새로고침 중...", "success": "모델 목록이 성공적으로 새로고침되었습니다!", - "error": "모델 목록 새로고침에 실패했습니다. 다시 시도해 주세요." + "error": "모델 목록 새로고침에 실패했습니다. 다시 시도해 주세요.", + "missingConfig": "Enter an API key first, then refresh the models list." }, "getRequestyApiKey": "Requesty API 키 받기", "getRequestyBaseUrl": "기본 URL", diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 992e9c53e0..b528bbd23e 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -485,7 +485,8 @@ "hint": "Open de instellingen opnieuw om de nieuwste modellen te zien.", "loading": "Modellenlijst wordt vernieuwd...", "success": "Modellenlijst succesvol vernieuwd!", - "error": "Kan modellenlijst niet vernieuwen. Probeer het opnieuw." + "error": "Kan modellenlijst niet vernieuwen. Probeer het opnieuw.", + "missingConfig": "Enter an API key first, then refresh the models list." }, "getRequestyApiKey": "Requesty API-sleutel ophalen", "getRequestyBaseUrl": "Basis-URL", diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 459675b0e1..bf3f396d8c 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -485,7 +485,8 @@ "hint": "Proszę ponownie otworzyć ustawienia, aby zobaczyć najnowsze modele.", "loading": "Odświeżanie listy modeli...", "success": "Lista modeli została pomyślnie odświeżona!", - "error": "Nie udało się odświeżyć listy modeli. Spróbuj ponownie." + "error": "Nie udało się odświeżyć listy modeli. Spróbuj ponownie.", + "missingConfig": "Enter an API key first, then refresh the models list." }, "getRequestyApiKey": "Uzyskaj klucz API Requesty", "getRequestyBaseUrl": "Bazowy URL", diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 500babaaa8..23b99f2d45 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -485,7 +485,8 @@ "hint": "Por favor, reabra as configurações para ver os modelos mais recentes.", "loading": "Atualizando lista de modelos...", "success": "Lista de modelos atualizada com sucesso!", - "error": "Falha ao atualizar a lista de modelos. Por favor, tente novamente." + "error": "Falha ao atualizar a lista de modelos. Por favor, tente novamente.", + "missingConfig": "Enter an API key first, then refresh the models list." }, "getRequestyApiKey": "Obter chave de API Requesty", "getRequestyBaseUrl": "URL Base", diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index 053ff0684d..b1a44a0f6c 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -485,7 +485,8 @@ "hint": "Пожалуйста, откройте настройки заново, чтобы увидеть последние модели.", "loading": "Обновление списка моделей...", "success": "Список моделей успешно обновлен!", - "error": "Не удалось обновить список моделей. Пожалуйста, попробуйте снова." + "error": "Не удалось обновить список моделей. Пожалуйста, попробуйте снова.", + "missingConfig": "Enter an API key first, then refresh the models list." }, "getRequestyApiKey": "Получить Requesty API-ключ", "getRequestyBaseUrl": "Базовый URL", diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index b37f28d44d..2d77f8d71d 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -485,7 +485,8 @@ "hint": "En son modelleri görmek için lütfen ayarları yeniden açın.", "loading": "Model listesi yenileniyor...", "success": "Model listesi başarıyla yenilendi!", - "error": "Model listesi yenilenemedi. Lütfen tekrar deneyin." + "error": "Model listesi yenilenemedi. Lütfen tekrar deneyin.", + "missingConfig": "Enter an API key first, then refresh the models list." }, "getRequestyApiKey": "Requesty API Anahtarı Al", "getRequestyBaseUrl": "Temel URL", diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 94843217ae..e962814ced 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -485,7 +485,8 @@ "hint": "Vui lòng mở lại cài đặt để xem các mô hình mới nhất.", "loading": "Đang làm mới danh sách mô hình...", "success": "Danh sách mô hình đã được làm mới thành công!", - "error": "Không thể làm mới danh sách mô hình. Vui lòng thử lại." + "error": "Không thể làm mới danh sách mô hình. Vui lòng thử lại.", + "missingConfig": "Enter an API key first, then refresh the models list." }, "getRequestyApiKey": "Lấy khóa API Requesty", "getRequestyBaseUrl": "URL cơ sở", diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 8be0fe1532..2f8da4c3f5 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -485,7 +485,8 @@ "hint": "请重新打开设置以查看最新模型。", "loading": "正在刷新模型列表...", "success": "模型列表刷新成功!", - "error": "刷新模型列表失败。请重试。" + "error": "刷新模型列表失败。请重试。", + "missingConfig": "Enter an API key first, then refresh the models list." }, "getRequestyApiKey": "获取 Requesty API 密钥", "getRequestyBaseUrl": "基础 URL", diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 65eb5c67a5..a035660460 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -512,7 +512,8 @@ "hint": "請重新開啟設定以查看最新模型。", "loading": "正在重新整理模型列表...", "success": "模型列表重新整理成功!", - "error": "重新整理模型列表失敗。請再試一次。" + "error": "重新整理模型列表失敗。請再試一次。", + "missingConfig": "Enter an API key first, then refresh the models list." }, "getRequestyApiKey": "取得 Requesty API 金鑰", "getRequestyBaseUrl": "基礎 URL", From d4679fff0137cf370c8493bfe7e636b2617726d4 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sun, 1 Nov 2026 05:19:22 +0900 Subject: [PATCH 44/46] fix(api): drop a non-string incoming mimoBaseUrl instead of throwing What: `withoutOffListMimoBaseUrl` reads `values.mimoBaseUrl` as `unknown` and only consults the allowlist when it is a string, so a number, object or boolean is dropped from the write exactly like an off-list string. Three spec cases pin the behavior (42 / object / true). Why: this is the incoming-side twin of the stored-side guard in c189c0e8a. `setConfiguration` is a public entry point for other extensions and automation, so the declared `string | undefined` type is not enforced at runtime. `stripTrailingSlashes` calls `.endsWith`, so a non-string threw `TypeError: result.endsWith is not a function` out of the sanitizer and rejected the whole `setConfiguration` call, losing every other setting the caller sent. Red on HEAD: 3 failed / 18 passed with that exact stack (`fetchers/mimo.ts:64` <- `withoutOffListMimoBaseUrl` <- `setConfiguration`). After: 21/21 passed. Impact: a mistyped or hostile caller can no longer abort a configuration write with a bad mimoBaseUrl; the value is rejected and the rest of the configuration persists. String behavior is unchanged, including the empty string and trailing-slash cases. Verified: check-types exit 0, eslint exit 0 on both files without `--prune-suppressions`, `src/eslint-suppressions.json` unchanged. Co-Authored-By: Claude --- .../__tests__/api-configuration.spec.ts | 22 +++++++++++++++++-- src/extension/api.ts | 17 ++++++++------ 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/src/extension/__tests__/api-configuration.spec.ts b/src/extension/__tests__/api-configuration.spec.ts index cb3121b7cf..6935351763 100644 --- a/src/extension/__tests__/api-configuration.spec.ts +++ b/src/extension/__tests__/api-configuration.spec.ts @@ -129,8 +129,9 @@ describe("API - configuration", () => { // The declared type is the four-literal union, so an off-list value needs // the same widening the guard itself documents: callers of this public API - // are not the schema. - const offListConfiguration = (mimoBaseUrl: string): RooCodeSettings => + // are not the schema. `unknown` rather than `string` because the runtime + // value is whatever the caller passed, including non-strings. + const offListConfiguration = (mimoBaseUrl: unknown): RooCodeSettings => ({ currentApiConfigName: "default", mimoBaseUrl }) as unknown as RooCodeSettings it("drops an off-list mimoBaseUrl instead of persisting it", async () => { @@ -190,6 +191,23 @@ describe("API - configuration", () => { }) }) + it.each([[42], [{ nested: "https://attacker.example/v1" }], [true]])( + "drops a non-string incoming mimoBaseUrl (%s) instead of throwing", + async (mimoBaseUrl) => { + // Same hazard as the stored-value read below, on the incoming side: the + // parameter is typed `string | undefined`, but this is a public entry + // point and stripTrailingSlashes calls .endsWith. Without a typeof guard + // the sanitizer throws TypeError, which rejects the whole configuration + // write, so a mistyped caller loses every other setting it sent. + const { api, setValues, saveConfig } = makeApi() + + await expect(api.setConfiguration(offListConfiguration(mimoBaseUrl))).resolves.toBeUndefined() + + expect(setValues).toHaveBeenCalledWith({ currentApiConfigName: "default" }) + expect(saveConfig).toHaveBeenCalledWith("default", { currentApiConfigName: "default" }) + }, + ) + it("purges a stored off-list mimoBaseUrl that a partial update would otherwise leave behind", async () => { // The stored value was written by an older build or another extension, so // the sanitizer never saw it. A partial update that omits mimoBaseUrl does diff --git a/src/extension/api.ts b/src/extension/api.ts index c30046f955..69d4d14c8f 100644 --- a/src/extension/api.ts +++ b/src/extension/api.ts @@ -697,19 +697,22 @@ export class API extends EventEmitter implements RooCodeAPI { /** * Returns `values` unchanged unless it carries a non-empty mimoBaseUrl outside * the four allowed Xiaomi endpoints, in which case that one key is omitted. - * Empty string means "unset" and passes through untouched. + * Empty string means "unset" and passes through untouched. A non-string value + * cannot be an allowed endpoint, so it is dropped like any other off-list value. */ private withoutOffListMimoBaseUrl(values: RooCodeSettings): RooCodeSettings { // Declared as the four-literal union, but this API is a public entry point // for other extensions and automation: the runtime value is whatever the // caller passed, and the schema is exactly what it never went through. - const mimoBaseUrl: string | undefined = values.mimoBaseUrl + // Typed `unknown` for that reason: a caller that sends a number or an object + // must be rejected, not allowed to throw out of stripTrailingSlashes. + const mimoBaseUrl: unknown = values.mimoBaseUrl - if ( - mimoBaseUrl === undefined || - mimoBaseUrl === "" || - ALLOWED_BASE_URLS.has(stripTrailingSlashes(mimoBaseUrl)) - ) { + if (mimoBaseUrl === undefined || mimoBaseUrl === "") { + return values + } + + if (typeof mimoBaseUrl === "string" && ALLOWED_BASE_URLS.has(stripTrailingSlashes(mimoBaseUrl))) { return values } From 54b8723412e047ded61dc42100350f032bb795bd Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sun, 1 Nov 2026 05:35:02 +0900 Subject: [PATCH 45/46] test(webview): refresh MiMo settings visual baselines from CI container render What: replace the two mimo-settings-*-dark.png baselines with the Linux-container-rendered actuals from run 41238511101 (block height 448x224->448x236 unconfigured, 448x196->448x208 configured). Why: the persistent role=status live region added by 079a7c3ca grows the MiMo block by ~12px; baselines must come from the pinned CI container, not host rendering (repo policy). Impact: webview-visual check passes at this head; no source change. --- .../mimo-settings-configured-dark.png | Bin 11883 -> 11952 bytes .../mimo-settings-unconfigured-dark.png | Bin 14381 -> 14454 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/webview-ui/src/components/settings/providers/__tests__/__screenshots__/mimo-settings-configured-dark.png b/webview-ui/src/components/settings/providers/__tests__/__screenshots__/mimo-settings-configured-dark.png index acba1c578b008319d6e23ce3d2df77e05320975d..ce2a24393b97f7b4e3ee84c7c963dfa1be72956d 100644 GIT binary patch delta 86 zcmaDIvmthZIM)RZCI$uu`Dbd}8x^G$7=1U}DzNfR{vgOU`M#hy6bowptOqLk|KIz= iNpB#7&C|s(gqflF`0tLBt_P)nA`G6celF{r5}E+dF&wu5 delta 70 zcmdlG`#NTVIM)#lCI$wE_JwI}8x^G$7&$iEDzNft{;UTI{{Nq#bXXS1VDofw3}I#n XUH7}dPHOEvpa_GftDnm{r-UW|2C5a1 diff --git a/webview-ui/src/components/settings/providers/__tests__/__screenshots__/mimo-settings-unconfigured-dark.png b/webview-ui/src/components/settings/providers/__tests__/__screenshots__/mimo-settings-unconfigured-dark.png index 68ac268ddc86c232f36b706f1d85fd16704dc1ea..6c80703d76f1947194cc891fa68e660a83262a4e 100644 GIT binary patch literal 14454 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 O2pI{0c#WuG(0>69VvuqG literal 14381 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 Date: Sun, 1 Nov 2026 07:37:05 +0900 Subject: [PATCH 46/46] Remove retired MiMo V2.5 catalog entries What: delete the mimo-v2.5-pro and mimo-v2.5 entries from the MiMo catalog (packages/types/src/providers/mimo.ts) and rewrite the header comment that promised they would stay "until the EOL date passes". Migrate the test pins that used the retired IDs as their fixture model, and add saved-user coverage for the runtime and webview fallback paths. Why: the EOL date (2026-10-21 10:00 GMT+8) has passed and Xiaomi's model names stopped working, so the header's own contract expired. Keeping the entries left dead IDs selectable in the picker; a deprecated banner would only decorate a selection whose name no longer resolves. Impact: MimoModelId narrows to the three V2.6 entries. A saved configuration that still names a retired ID is unaffected at the type level (apiModelId is z.string().optional()) and keeps working: getModel() falls back to mimoDefaultModelInfo for pricing and limits while sending the stored ID unchanged, and the webview preserves the ID with undefined info until the router catalog settles. Both paths are spec-pinned. --- packages/types/src/providers/lite-llm.ts | 3 +- packages/types/src/providers/mimo.ts | 36 ++-------- src/api/providers/__tests__/mimo.spec.ts | 68 ++++++++++++------- .../providers/fetchers/__tests__/mimo.spec.ts | 9 +-- src/api/providers/mimo.ts | 2 +- .../hooks/__tests__/useSelectedModel.spec.ts | 56 +++++++++++++++ 6 files changed, 116 insertions(+), 58 deletions(-) diff --git a/packages/types/src/providers/lite-llm.ts b/packages/types/src/providers/lite-llm.ts index 5303772586..3a59332f64 100644 --- a/packages/types/src/providers/lite-llm.ts +++ b/packages/types/src/providers/lite-llm.ts @@ -37,7 +37,8 @@ export const LITELLM_PRESERVE_REASONING_MODEL_IDS = [ "deepseek-v4-pro", "deepseek-reasoner", - // mimo.ts, opencode-go.ts + // opencode-go.ts (MiMo V2.5 is retired from mimo.ts; the gateway can still + // front opencode-go's V2.5 routes) "mimo-v2.5", "mimo-v2.5-pro", diff --git a/packages/types/src/providers/mimo.ts b/packages/types/src/providers/mimo.ts index 7801ed85c2..f65e5644f2 100644 --- a/packages/types/src/providers/mimo.ts +++ b/packages/types/src/providers/mimo.ts @@ -13,9 +13,13 @@ import { // https://mimo.mi.com/docs/en-US/quick-start/usage-guide/text-generation/deep-thinking // https://mimo.mi.com/docs/en-US/price/pay-as-you-go // -// mimo-v2.5-pro and mimo-v2.5 are deprecated on 2026-10-21 10:00 (GMT+8) and -// their model names will stop working. They stay in the catalog as a fallback -// until the EOL date passes; mimo-v2.6-* entries carry the V2.6 series. +// mimo-v2.5-pro and mimo-v2.5 were retired on 2026-10-21 10:00 (GMT+8) and their +// model names stopped working, so they are gone from this catalog: keeping them +// would leave dead IDs selectable. The V2.6 series below replaces them. A saved +// selection that still names a retired ID keeps working through the documented +// runtime fallback -- getMimoModelInfo(id) ?? mimoDefaultModelInfo in +// src/api/providers/mimo.ts -- which prices and limits the request as the +// default model while sending the stored ID through unchanged. // // NOTE: mimo-v2-flash is not included here. Its thinking mode defaults to // disabled and it doesn't reliably handle reasoning_content passthrough @@ -67,32 +71,6 @@ export const mimoModels = { description: "MiMo V2.6 Pro Ultraspeed - V2.6-Pro performance at up to 20x speed for real-time, latency-sensitive workloads.", }, - "mimo-v2.5-pro": { - maxTokens: 131_072, - contextWindow: 1_048_576, - supportsImages: false, // Pro series is text-only - supportsPromptCache: false, - preserveReasoning: true, - inputPrice: 0.435, // $0.435/1M tokens (cache miss) - outputPrice: 0.87, // $0.87/1M tokens - cacheReadsPrice: 0.0036, // $0.0036/1M tokens (cache hit) - cacheWritesPrice: 0, // Free for limited time - description: - "MiMo V2.5 Pro - Deprecated on 2026-10-21. Xiaomi's flagship reasoning model with 1M context, deep thinking, tool calling, and structured output.", - }, - "mimo-v2.5": { - maxTokens: 131_072, - contextWindow: 1_048_576, - supportsImages: true, // Full-modal: text, image, audio, video input - supportsPromptCache: false, - preserveReasoning: true, - inputPrice: 0.14, // $0.14/1M tokens (cache miss) - outputPrice: 0.28, // $0.28/1M tokens - cacheReadsPrice: 0.0028, // $0.0028/1M tokens (cache hit) - cacheWritesPrice: 0, // Free for limited time - description: - "MiMo V2.5 - Deprecated on 2026-10-21. Full-modal understanding model (text, image, audio, video) with 1M context, deep thinking, tool calling, and structured output.", - }, } as const satisfies Record export const mimoDefaultModelInfo: ModelInfo = mimoModels[mimoDefaultModelId] diff --git a/src/api/providers/__tests__/mimo.spec.ts b/src/api/providers/__tests__/mimo.spec.ts index 3b9b726d09..8355263255 100644 --- a/src/api/providers/__tests__/mimo.spec.ts +++ b/src/api/providers/__tests__/mimo.spec.ts @@ -34,7 +34,7 @@ vi.mock("openai", () => { import type { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" -import { mimoDefaultModelId, mimoModels } from "@roo-code/types" +import { mimoDefaultModelId, mimoDefaultModelInfo, mimoModels } from "@roo-code/types" import type { ApiHandlerOptions } from "../../../shared/api" import { MimoHandler } from "../mimo" import { ALLOWED_BASE_URLS } from "../fetchers/mimo" @@ -48,7 +48,7 @@ describe("MimoHandler", () => { beforeEach(() => { mockOptions = { mimoApiKey: "test-api-key", - apiModelId: "mimo-v2.5-pro", + apiModelId: "mimo-v2.6-pro", mimoBaseUrl: "https://token-plan-sgp.xiaomimimo.com/v1", } handler = new MimoHandler(mockOptions) @@ -58,7 +58,7 @@ describe("MimoHandler", () => { describe("constructor", () => { it("should initialize with provided options", () => { expect(handler).toBeInstanceOf(MimoHandler) - expect(handler.getModel().id).toBe("mimo-v2.5-pro") + expect(handler.getModel().id).toBe("mimo-v2.6-pro") }) it("should use default model ID if not provided", () => { @@ -139,22 +139,24 @@ describe("MimoHandler", () => { }) describe("getModel", () => { - it("should return correct model info for mimo-v2.5-pro", () => { - const model = handler.getModel() - expect(model.id).toBe("mimo-v2.5-pro") - expect(model.info.contextWindow).toBe(1_048_576) - expect(model.info.maxTokens).toBe(131_072) - expect(model.info.inputPrice).toBe(0.435) - expect(model.info.outputPrice).toBe(0.87) - }) - - it("should return correct model info for mimo-v2.5", () => { - const h = new MimoHandler({ ...mockOptions, apiModelId: "mimo-v2.5" }) - const model = h.getModel() - expect(model.id).toBe("mimo-v2.5") - expect(model.info.inputPrice).toBe(0.14) - expect(model.info.outputPrice).toBe(0.28) - }) + // Saved users: a configuration persisted before the V2.5 retirement still carries + // a retired ID, and `apiModelId` is `z.string().optional()`, so nothing rejects it. + // The handler must keep constructing and must price the request as the family + // default -- `getMimoModelInfo` returns undefined for a retired ID, and the + // `?? mimoDefaultModelInfo` fallback supplies the spec. The ID itself is passed + // through unchanged, which is what makes the provider error visible instead of + // silently switching the user's model. + it.each(["mimo-v2.5-pro", "mimo-v2.5"])( + "should fall back to the default spec for the retired model ID %s", + (retiredModelId) => { + const h = new MimoHandler({ ...mockOptions, apiModelId: retiredModelId }) + const model = h.getModel() + expect(model.id).toBe(retiredModelId) + expect(model.info).toBe(mimoDefaultModelInfo) + expect(model.info).toBe(mimoModels[mimoDefaultModelId]) + expect(model.maxTokens).toBe(mimoDefaultModelInfo.maxTokens) + }, + ) it("should return correct model info for mimo-v2.6-pro", () => { const h = new MimoHandler({ ...mockOptions, apiModelId: "mimo-v2.6-pro" }) @@ -991,7 +993,7 @@ describe("MimoHandler", () => { await handler.completePrompt("What is 2+2?") const params = mockCreate.mock.calls[0][0] - expect(params.model).toBe("mimo-v2.5-pro") + expect(params.model).toBe("mimo-v2.6-pro") expect(params.messages).toHaveLength(1) expect(params.messages[0].role).toBe("user") expect(params.messages[0].content).toBe("What is 2+2?") @@ -1031,8 +1033,28 @@ describe("MimoHandler", () => { await expect(handler.completePrompt("Test prompt")).rejects.toThrow("OpenAI completion error:") }) - it("should use correct model ID for mimo-v2.5 variant", async () => { - const v25Handler = new MimoHandler({ + it("should use correct model ID for mimo-v2.6-flash variant", async () => { + const flashHandler = new MimoHandler({ + ...mockOptions, + apiModelId: "mimo-v2.6-flash", + }) + + mockCreate.mockResolvedValueOnce({ + choices: [{ message: { content: "Response" } }], + }) + + await flashHandler.completePrompt("Test") + + const params = mockCreate.mock.calls[0][0] + expect(params.model).toBe("mimo-v2.6-flash") + }) + + // Saved users: a retired ID is not rewritten on the wire. The spec falls back to + // the family default (see getModel), but the request still names the ID the user + // has stored, so the provider's own error reaches the user instead of the + // extension silently answering as a model they did not pick. + it("should pass a retired model ID through unchanged on the wire", async () => { + const retiredHandler = new MimoHandler({ ...mockOptions, apiModelId: "mimo-v2.5", }) @@ -1041,7 +1063,7 @@ describe("MimoHandler", () => { choices: [{ message: { content: "Response" } }], }) - await v25Handler.completePrompt("Test") + await retiredHandler.completePrompt("Test") const params = mockCreate.mock.calls[0][0] expect(params.model).toBe("mimo-v2.5") diff --git a/src/api/providers/fetchers/__tests__/mimo.spec.ts b/src/api/providers/fetchers/__tests__/mimo.spec.ts index 4de3ee9a87..dfbe57fc32 100644 --- a/src/api/providers/fetchers/__tests__/mimo.spec.ts +++ b/src/api/providers/fetchers/__tests__/mimo.spec.ts @@ -41,10 +41,11 @@ describe("getMimoModels", () => { description: "MiMo model: mimo-v3-future", }) - // mimo-v2.5-pro is still served today, but this mock response omits it - // (post-EOL world): a model absent from the API response must stay absent - // from the result, guarding against a wholesale-static-map regression. - expect(models["mimo-v2.5-pro"]).toBeUndefined() + // A model that IS in the static catalog but absent from the API response must + // stay absent from the result: a mutation that returned the static map + // wholesale would surface it here. (mimo-v2.6-pro-ultraspeed is the current + // catalog entry this response omits.) + expect(models["mimo-v2.6-pro-ultraspeed"]).toBeUndefined() }) it("provides MiMo-family defaults for unknown model IDs without pricing", async () => { diff --git a/src/api/providers/mimo.ts b/src/api/providers/mimo.ts index 16cec491d4..c9c6585f75 100644 --- a/src/api/providers/mimo.ts +++ b/src/api/providers/mimo.ts @@ -135,7 +135,7 @@ export class MimoHandler extends OpenAiHandler { const tools = metadata?.tools // Build request per MiMo's OpenAI-compatible API - // https://developer.puter.com/ai/xiaomi/mimo-v2.5-pro/ + // https://mimo.mi.com/docs/en-US/quick-start/usage-guide/text-generation/deep-thinking // Note: temperature is omitted because MiMo forces it to 1.0 when thinking mode // is enabled, regardless of what is passed (see model-hyperparameters docs). const params: Record = { diff --git a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts index c1ae91c8b3..cfeb2f56a1 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts @@ -2521,5 +2521,61 @@ describe("useSelectedModel", () => { expect(result.current.id).toBe(mimoDefaultModelId) expect(result.current.info).toEqual(mimoModels[mimoDefaultModelId]) }) + + // Saved users: a configuration persisted before the V2.5 retirement still names + // a retired ID, and the static catalog no longer describes it. While the router + // catalog is unsettled, the selection is PRESERVED with `info: undefined` -- the + // documented fallback contract of getStaticCatalogSelection. Capability UI falls + // back to its own defaults for that turn instead of showing the default model's + // numbers, and the saved ID is never rewritten behind the user's back. Once the + // router catalog settles, validation takes over (see the populated-catalog test + // below), which is why the ID is not silently migrated here. + it("mimo: preserves a retired V2.5 ID with undefined info while the router catalog is unsettled", () => { + const retiredModelId = "mimo-v2.5" + + // Guard the premise: the ID must be absent from the static catalog, which is + // what routes it through the undefined-info path under test. + expect(Object.hasOwn(mimoModels, retiredModelId)).toBe(false) + + mockUseRouterModels.mockReturnValue(createRouterModelsResult(undefined, { isLoading: true })) + mockUseOpenRouterModelProviders.mockReturnValue(createOpenRouterModelProvidersResult({})) + + const { result } = renderHook( + () => useSelectedModel({ apiProvider: providerIdentifiers.mimo, apiModelId: retiredModelId }), + { wrapper: createWrapper() }, + ) + + expect(result.current.id).toBe(retiredModelId) + expect(result.current.info).toBeUndefined() + }) + + // The settled half of the same saved-user path: once the router catalog answers + // with other IDs, the retired ID is provably not served, so validation resets the + // selection to the family default and capability UI renders the default's real + // numbers. This is what the user sees in the picker and the context-window + // display; the stored ID is only rewritten by ApiOptions' sync effect, and the + // handler still falls back to the same default spec for requests. + it("mimo: resets a retired V2.5 ID to the default once the router catalog lists other models", () => { + const retiredModelId = "mimo-v2.5-pro" + + expect(Object.hasOwn(mimoModels, retiredModelId)).toBe(false) + + mockUseRouterModels.mockReturnValue( + createRouterModelsResult({ + [providerIdentifiers.mimo]: { + "mimo-v2.6-flash": { contextWindow: 1_048_576, supportsPromptCache: false, maxTokens: 131_072 }, + }, + }), + ) + mockUseOpenRouterModelProviders.mockReturnValue(createOpenRouterModelProvidersResult({})) + + const { result } = renderHook( + () => useSelectedModel({ apiProvider: providerIdentifiers.mimo, apiModelId: retiredModelId }), + { wrapper: createWrapper() }, + ) + + expect(result.current.id).toBe(mimoDefaultModelId) + expect(result.current.info).toEqual(mimoModels[mimoDefaultModelId]) + }) }) })