diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e66a5bd13b..66458c23402 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ Docs: https://docs.openclaw.ai - Realtime transcription: report malformed provider websocket JSON frames with owned parser errors instead of leaking raw `SyntaxError` objects. - Microsoft Foundry: report malformed Azure CLI token JSON with owned auth errors instead of leaking raw parser failures. - Gateway/model pricing: report malformed external pricing catalog JSON with source-owned errors instead of leaking raw parser failures. +- QA Lab: report malformed model-catalog subprocess JSON with an owned error and ignore invalid catalog rows. - Models config/auth: stop inferring provider env-var markers from broad `^[A-Z_][A-Z0-9_]*$` strings, and resolve config-backed provider `apiKey` values only through structured env SecretRefs (`secrets.providers[id]` / `secrets.defaults`), so unrelated env vars cannot accidentally become provider credentials. Thanks @sallyom. - Media fetch: skip allocating and buffering the response body for bodyless media responses (HEAD probes and 204-style empty bodies), avoiding wasted heap on streams that carry no payload. Thanks @shakkernerd. - CLI/onboarding: forward provider-specific auth flags (e.g. `--openai-api-key`) through the onboarding wizard so they reach provider auth methods via `ctx.opts`, letting `--openai-api-key "$OPENAI_API_KEY"` skip the redundant "use existing env var?" prompt in non-interactive harnesses. (#81669) Thanks @sjf. diff --git a/extensions/qa-lab/src/model-catalog.runtime.test.ts b/extensions/qa-lab/src/model-catalog.runtime.test.ts index 5e8b19b01aa..67fb2b34343 100644 --- a/extensions/qa-lab/src/model-catalog.runtime.test.ts +++ b/extensions/qa-lab/src/model-catalog.runtime.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { selectQaRunnerModelOptions } from "./model-catalog.runtime.js"; +import { + parseQaRunnerModelOptionsOutput, + selectQaRunnerModelOptions, +} from "./model-catalog.runtime.js"; describe("qa runner model catalog", () => { it("filters to available rows and prefers gpt-5.5 first", () => { @@ -29,4 +32,29 @@ describe("qa runner model catalog", () => { ]).map((entry) => entry.key), ).toEqual(["openai/gpt-5.5", "anthropic/claude-sonnet-4-6"]); }); + + it("reports malformed catalog JSON with an owned error", () => { + expect(() => parseQaRunnerModelOptionsOutput("{not json")).toThrow( + "qa model catalog returned malformed JSON", + ); + }); + + it("ignores invalid catalog rows without failing the model picker", () => { + expect( + parseQaRunnerModelOptionsOutput( + JSON.stringify({ + models: [ + null, + { + key: "openai/gpt-5.5", + name: "gpt-5.5", + input: "text,image", + available: true, + missing: false, + }, + ], + }), + ).map((entry) => entry.key), + ).toEqual(["openai/gpt-5.5"]); + }); }); diff --git a/extensions/qa-lab/src/model-catalog.runtime.ts b/extensions/qa-lab/src/model-catalog.runtime.ts index c0990e9154c..af471937690 100644 --- a/extensions/qa-lab/src/model-catalog.runtime.ts +++ b/extensions/qa-lab/src/model-catalog.runtime.ts @@ -68,6 +68,34 @@ export function selectQaRunnerModelOptions(rows: ModelRow[]): QaRunnerModelOptio }); } +function isModelRow(value: unknown): value is ModelRow { + if (!value || typeof value !== "object") { + return false; + } + const row = value as Partial; + return ( + typeof row.key === "string" && + typeof row.name === "string" && + typeof row.input === "string" && + (row.available === true || row.available === false || row.available === null) && + typeof row.missing === "boolean" + ); +} + +export function parseQaRunnerModelOptionsOutput(stdout: string): QaRunnerModelOption[] { + let payload: unknown; + try { + payload = JSON.parse(stdout) as unknown; + } catch { + throw new Error("qa model catalog returned malformed JSON"); + } + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + throw new Error("qa model catalog returned invalid JSON payload"); + } + const rows = (payload as { models?: unknown }).models; + return selectQaRunnerModelOptions(Array.isArray(rows) ? rows.filter(isModelRow) : []); +} + const CATALOG_ABORT_ERROR_MESSAGE = "qa model catalog aborted"; function createCatalogAbortError() { @@ -199,8 +227,7 @@ export async function loadQaRunnerModelOptions(params: { repoRoot: string; signa }); }); - const payload = JSON.parse(Buffer.concat(stdout).toString("utf8")) as { models?: ModelRow[] }; - return selectQaRunnerModelOptions(payload.models ?? []); + return parseQaRunnerModelOptionsOutput(Buffer.concat(stdout).toString("utf8")); } finally { await fs.rm(tempRoot, { recursive: true, force: true }); }