From 9cb91dee74621bc3e82d2461648499da5d37be54 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 23 Aug 2026 20:32:42 -0300 Subject: [PATCH 1/2] fix(security): close round-3 advisories (v7g9, x7vm, j7j4, jcm5) + exposure warning (wmgv) (#11261) Validated on a 2-PR combined board: routeGuard 36/36 (within the 68/68 focused-file total), a2a-task-owner-idor 7/7, a2a-tasks-auth, search-baseurl-ssrf-guard, cli-serve-hostname, spawn-capable-prefixes-client-safe all green, typecheck:core + dashboard-typecheck clean, gates within baseline. Five real High-severity advisories fixed with TDD (each failing-then-passing): settings export/import-json ALWAYS_PROTECTED completion, MITM route LOCAL_ONLY+SPAWN_CAPABLE gating, search baseUrl SSRF/IMDS guard, A2A REST task auth+ownership (previously none at all), and the loud boot exposure warning. GHSA-cjv9 confirmed already closed on this base (verified). Round 3 of the advisory sweep. --- bin/cli/commands/serve.mjs | 11 +- bin/cli/utils/serverHost.mjs | 31 ++++ open-sse/handlers/search.ts | 19 ++- src/app/a2a/route.ts | 49 ++----- src/app/api/a2a/_auth.ts | 51 +++++++ src/app/api/a2a/tasks/[id]/cancel/route.ts | 15 +- src/app/api/a2a/tasks/[id]/route.ts | 21 ++- src/app/api/a2a/tasks/route.ts | 27 +++- src/lib/a2a/authenticate.ts | 53 +++++++ src/lib/a2a/taskManager.ts | 40 +++++- src/server/authz/routeGuard.ts | 8 ++ src/shared/constants/spawnCapablePrefixes.ts | 2 + tests/unit/a2a-task-owner-idor.test.ts | 136 ++++++++++++++++++ tests/unit/a2a-tasks-auth.test.ts | 12 +- tests/unit/authz/routeGuard.test.ts | 29 ++++ ...spawn-capable-prefixes-client-safe.test.ts | 4 +- tests/unit/cli-serve-hostname.test.ts | 25 +++- tests/unit/search-baseurl-ssrf-guard.test.ts | 81 +++++++++++ 18 files changed, 550 insertions(+), 64 deletions(-) create mode 100644 src/app/api/a2a/_auth.ts create mode 100644 src/lib/a2a/authenticate.ts create mode 100644 tests/unit/a2a-task-owner-idor.test.ts create mode 100644 tests/unit/search-baseurl-ssrf-guard.test.ts diff --git a/bin/cli/commands/serve.mjs b/bin/cli/commands/serve.mjs index 456e2e6f77..b58271e833 100644 --- a/bin/cli/commands/serve.mjs +++ b/bin/cli/commands/serve.mjs @@ -12,7 +12,7 @@ import { isFatalInstrumentationHookFailure, formatAndroidInstrumentationFailureHint, } from "../utils/ensureAndroidCacheDir.mjs"; -import { resolveServerHost } from "../utils/serverHost.mjs"; +import { resolveServerHost, resolveExposureWarning } from "../utils/serverHost.mjs"; import { resolveMaxOldSpaceMb, calibrateHeapFallbackMb, @@ -162,6 +162,15 @@ export async function runServe(opts = {}) { `); } + // GHSA-wmgv-ph3p-rv57: the default posture (all interfaces + no API key) is a + // deliberate local-first choice, but it must be loud at startup — an operator + // on an untrusted network learns the two escape hatches here, not after a + // surprise quota bill. + const exposureWarning = resolveExposureWarning(); + if (exposureWarning) { + console.warn(`\x1b[33m ⚠ ${exposureWarning}\x1b[0m\n`); + } + const serverWsJs = join(APP_DIR, "server-ws.mjs"); const serverJs = existsSync(serverWsJs) ? serverWsJs : join(APP_DIR, "server.js"); diff --git a/bin/cli/utils/serverHost.mjs b/bin/cli/utils/serverHost.mjs index a64a88d2a6..a13082612f 100644 --- a/bin/cli/utils/serverHost.mjs +++ b/bin/cli/utils/serverHost.mjs @@ -24,3 +24,34 @@ export function resolveServerHost( } return "0.0.0.0"; } + +const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]); + +/** + * Boot-time exposure warning (GHSA-wmgv-ph3p-rv57): the shipped default binds + * all interfaces while the inference plane requires no credentials, so any + * LAN peer can spend the operator's quota. That local-first posture is a + * deliberate, documented default — but it must be LOUD at startup so an + * operator who never read the docs still learns the two escape hatches. + * + * Returns the warning text when the server will listen on a non-loopback + * interface with no API-key requirement, or null when the exposure is closed. + * + * @param {NodeJS.ProcessEnv} [env] + * @param {string} [host] + * @returns {string | null} + */ +export function resolveExposureWarning(env = process.env, host = resolveServerHost(env)) { + if (LOOPBACK_HOSTS.has(host)) return null; + const requireKey = String(env.REQUIRE_API_KEY || "") + .trim() + .toLowerCase(); + if (requireKey === "true" || requireKey === "1" || requireKey === "yes") return null; + return ( + `SECURITY: listening on ${host} with NO API-key requirement — the inference ` + + `plane (/v1/*) is reachable by ANY device that can route to this host, and ` + + `requests are billed to your configured providers. This local-first default ` + + `is intentional, but on an untrusted network either set REQUIRE_API_KEY=true ` + + `or bind loopback with OMNIROUTE_SERVER_HOST=127.0.0.1.` + ); +} diff --git a/open-sse/handlers/search.ts b/open-sse/handlers/search.ts index d5cbc5fa38..858ea6f68a 100644 --- a/open-sse/handlers/search.ts +++ b/open-sse/handlers/search.ts @@ -31,6 +31,7 @@ import * as xSearch from "./search/xSearch.ts"; import { freeWebSearch } from "../services/freeWebSearch.ts"; import { saveCallLog } from "@/lib/usageDb"; import { safeOutboundFetch } from "@/shared/network/safeOutboundFetch"; +import { parseAndValidateNonMetadataUrl } from "@/shared/network/outboundUrlGuard"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { z } from "zod"; @@ -313,9 +314,23 @@ function getProviderSettingString( return undefined; } -function resolveSearchBaseUrl(config: SearchProviderConfig, params: SearchRequestParams): string { +export function resolveSearchBaseUrl( + config: SearchProviderConfig, + params: SearchRequestParams +): string { const override = getProviderSettingString(params, "baseUrl"); - return (override || config.baseUrl).replace(/\/+$/, ""); + if (override) { + // GHSA-j7j4-g9qc-q69c: the override is client-controlled (provider_options / + // providerSpecificData) and flows into a plain fetch() sink — validate it + // before any builder uses it as the server-side fetch target. Mode is + // block-metadata (NOT public-only): the primary searxng use case is a + // self-hosted instance on loopback/LAN, so private hosts keep working, + // while cloud-metadata endpoints (IMDS credential theft) are rejected. + // The catalog's own config.baseUrl is operator config and stays untouched. + parseAndValidateNonMetadataUrl(override); + return override.replace(/\/+$/, ""); + } + return config.baseUrl.replace(/\/+$/, ""); } function toSearchPageNumber(offset: number | undefined, maxResults: number): number | undefined { diff --git a/src/app/a2a/route.ts b/src/app/a2a/route.ts index af7d93a2e5..dfe3fc73a7 100644 --- a/src/app/a2a/route.ts +++ b/src/app/a2a/route.ts @@ -10,15 +10,13 @@ * Auth: Bearer token via Authorization header */ -import { timingSafeEqual } from "node:crypto"; import { NextRequest, NextResponse } from "next/server"; import { getTaskManager } from "@/lib/a2a/taskManager"; import { logRoutingDecision } from "@/lib/a2a/routingLogger"; import { createA2AStream, SSE_HEADERS } from "@/lib/a2a/streaming"; import { A2A_SKILL_HANDLERS, executeA2ATaskWithState } from "@/lib/a2a/taskExecution"; import { getSettings } from "@/lib/db/settings"; -import { isRequireApiKeyEnabled } from "@/shared/utils/featureFlags"; -import { extractApiKey, isValidApiKey } from "@/sse/services/auth"; +import { authenticateA2ARequest, resolveA2AOwner } from "@/lib/a2a/authenticate"; // ============ A2A v1.0 ↔ v0.3 compatibility layer ============ // A2A 1.0 renamed the JSON-RPC methods (message/send → SendMessage, @@ -55,7 +53,7 @@ function buildV1Task( ? result.artifacts .map((a) => a && typeof a === "object" && typeof (a as { content?: unknown }).content === "string" - ? ((a as { content: string }).content) + ? (a as { content: string }).content : "" ) .filter((s) => s.length > 0) @@ -124,39 +122,13 @@ function toMessageArray(raw: unknown): A2AMessage[] | null { // ============ Auth ============ -/** - * Constant-time comparison of the presented bearer token against the configured - * key. A plain `===` short-circuits on the first differing byte, leaking the - * length of the shared prefix through response timing; `timingSafeEqual` does - * not. It requires equal-length buffers, so mismatched lengths are rejected up - * front (the length itself is not secret). - */ -function tokensMatch(provided: string, expected: string): boolean { - const a = Buffer.from(provided); - const b = Buffer.from(expected); - if (a.length !== b.length) return false; - return timingSafeEqual(a, b); -} - async function authenticate(req: NextRequest): Promise { // /a2a is outside the authz proxy matcher, so the REQUIRE_API_KEY posture the // pipeline enforces for /v1 never ran here — the route accepted every caller // whenever OMNIROUTE_API_KEY was unset, which is the shipped default - // (GHSA-v54m-6rm3-p565). Apply the same posture directly: when a client key is - // required, demand a valid OmniRoute key; otherwise honor the legacy explicit - // A2A key; otherwise stay keyless (the same local-first default as /v1). - const apiKey = extractApiKey(req); - if (isRequireApiKeyEnabled()) { - return apiKey ? await isValidApiKey(apiKey) : false; - } - - const configuredKey = process.env.OMNIROUTE_API_KEY; - if (configuredKey) { - return apiKey ? tokensMatch(apiKey, configuredKey) : false; - } - - // No API key required and none configured — allow (keyless local-first). - return true; + // (GHSA-v54m-6rm3-p565). The shared helper applies the same posture on both + // the JSON-RPC and the REST task surfaces (GHSA-jcm5-6wpp-wjj8). + return authenticateA2ARequest(req); } // ============ JSON-RPC Helpers ============ @@ -213,6 +185,9 @@ export async function POST(req: NextRequest) { if (disabledResponse) return disabledResponse; const tm = getTaskManager(); + // GHSA-jcm5-6wpp-wjj8: scope every task read/mutation below to the caller's + // owner id (hashed API key; undefined under the keyless local-first posture). + const callerOwner = resolveA2AOwner(req); // A2A 1.0 method-name compatibility (SendMessage → message/send, etc.) const isV1Method = method in V1_METHOD_ALIASES; @@ -236,7 +211,7 @@ export async function POST(req: NextRequest) { return jsonRpcError(id, -32601, `Unknown skill: ${skill}`); } - const task = tm.createTask({ skill, messages, metadata: params?.metadata }); + const task = tm.createTask({ skill, messages, metadata: params?.metadata }, callerOwner); try { tm.updateTask(task.id, "working"); const result = await handler(task); @@ -302,7 +277,7 @@ export async function POST(req: NextRequest) { return jsonRpcError(id, -32601, `Unknown skill: ${skill}`); } - const task = tm.createTask({ skill, messages, metadata: params?.metadata }); + const task = tm.createTask({ skill, messages, metadata: params?.metadata }, callerOwner); tm.updateTask(task.id, "working"); const stream = createA2AStream( @@ -323,7 +298,7 @@ export async function POST(req: NextRequest) { const taskId = params?.taskId || params?.id; if (!taskId) return jsonRpcError(id, -32602, "Invalid params: taskId required"); - const task = tm.getTask(taskId); + const task = tm.getTask(taskId, callerOwner); if (!task) return jsonRpcError(id, -32601, `Task not found: ${taskId}`); return jsonRpcResult(id, { task }); @@ -335,7 +310,7 @@ export async function POST(req: NextRequest) { if (!taskId) return jsonRpcError(id, -32602, "Invalid params: taskId required"); try { - const task = tm.cancelTask(taskId); + const task = tm.cancelTask(taskId, callerOwner); return jsonRpcResult(id, { task: { id: task.id, state: task.state } }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); diff --git a/src/app/api/a2a/_auth.ts b/src/app/api/a2a/_auth.ts new file mode 100644 index 0000000000..2ec286db91 --- /dev/null +++ b/src/app/api/a2a/_auth.ts @@ -0,0 +1,51 @@ +/** + * Shared authorization for the REST A2A task routes (GHSA-jcm5-6wpp-wjj8). + * + * Dual audience: the dashboard calls these routes with a management session, + * A2A clients with an inference API key. Posture matrix: + * + * - REQUIRE_API_KEY=true: a valid OmniRoute key is mandatory (the same + * posture the /v1 inference plane enforces); a management session also + * passes (dashboard), via alwaysRequireAuth so requireLogin=false cannot + * bypass it. + * - otherwise + requireLogin=true: management session, or a valid key. + * - otherwise + requireLogin=false (local-first default): open, by design. + * + * Callers authenticated by key are owner-scoped — another principal's tasks + * answer as if they did not exist. Management/operator view sees all tasks. + */ + +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { extractApiKey, isValidApiKey } from "@/sse/services/auth"; +import { isRequireApiKeyEnabled } from "@/shared/utils/featureFlags"; +import { resolveA2AOwner } from "@/lib/a2a/authenticate"; + +export interface A2ARestAuth { + /** Owner scope for task reads/mutations; undefined = operator view (all tasks). */ + owner: string | undefined; +} + +/** + * NOTE: the failure branch is whatever requireManagementAuth returns — today a + * plain `Response` from createErrorResponse(), NOT a NextResponse. Callers must + * test with `instanceof Response` (NextResponse extends Response), never + * `instanceof NextResponse`, or the 401 silently falls through to the handler. + */ +export async function authorizeA2ATaskRoute(request: Request): Promise { + const apiKey = extractApiKey(request); + + if (isRequireApiKeyEnabled()) { + if (apiKey && (await isValidApiKey(apiKey))) return { owner: resolveA2AOwner(request) }; + const managementError = await requireManagementAuth(request, { + invalidApiKeyStatus: 401, + alwaysRequireAuth: true, + }); + if (managementError === null) return { owner: undefined }; + return managementError; + } + + const managementError = await requireManagementAuth(request, { invalidApiKeyStatus: 401 }); + if (managementError === null) return { owner: undefined }; + if (apiKey && (await isValidApiKey(apiKey))) return { owner: resolveA2AOwner(request) }; + return managementError; +} diff --git a/src/app/api/a2a/tasks/[id]/cancel/route.ts b/src/app/api/a2a/tasks/[id]/cancel/route.ts index 9919626f39..bc3558b06e 100644 --- a/src/app/api/a2a/tasks/[id]/cancel/route.ts +++ b/src/app/api/a2a/tasks/[id]/cancel/route.ts @@ -1,14 +1,23 @@ import { NextResponse } from "next/server"; import { getTaskManager } from "@/lib/a2a/taskManager"; +import { authorizeA2ATaskRoute } from "@/app/api/a2a/_auth"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; -export async function POST(_request: Request, { params }: { params: Promise<{ id: string }> }) { +export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) { + // GHSA-jcm5-6wpp-wjj8: this route had no auth call at all. The owner check + // happens inside cancelTask: another principal's task throws the same + // "not found" a missing one would (no existence oracle). + const auth = await authorizeA2ATaskRoute(request); + if (auth instanceof Response) return auth; try { const { id } = await params; const tm = getTaskManager(); - const task = tm.cancelTask(id); + const task = tm.cancelTask(id, auth.owner); return NextResponse.json({ task: { id: task.id, state: task.state } }); } catch (error) { - const message = error instanceof Error ? error.message : "Failed to cancel A2A task"; + const message = sanitizeErrorMessage( + error instanceof Error ? error.message : "Failed to cancel A2A task" + ); const status = message.includes("not found") ? 404 : 400; return NextResponse.json({ error: message }, { status }); } diff --git a/src/app/api/a2a/tasks/[id]/route.ts b/src/app/api/a2a/tasks/[id]/route.ts index ae3906171e..2d5c1bf0c3 100644 --- a/src/app/api/a2a/tasks/[id]/route.ts +++ b/src/app/api/a2a/tasks/[id]/route.ts @@ -1,17 +1,30 @@ import { NextResponse } from "next/server"; import { getTaskManager } from "@/lib/a2a/taskManager"; +import { authorizeA2ATaskRoute } from "@/app/api/a2a/_auth"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; -export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) { +export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { + // GHSA-jcm5-6wpp-wjj8: this route had no auth call at all — open regardless + // of configuration. Another principal's task answers 404, same as a missing + // one, so an IDOR probe cannot tell the two apart. + const auth = await authorizeA2ATaskRoute(request); + if (auth instanceof Response) return auth; try { const { id } = await params; const tm = getTaskManager(); - const task = tm.getTask(id); + const task = tm.getTask(id, auth.owner); if (!task) { return NextResponse.json({ error: `Task not found: ${id}` }, { status: 404 }); } return NextResponse.json({ task }); } catch (error) { - const message = error instanceof Error ? error.message : "Failed to load A2A task"; - return NextResponse.json({ error: message }, { status: 500 }); + return NextResponse.json( + { + error: sanitizeErrorMessage( + error instanceof Error ? error.message : "Failed to load A2A task" + ), + }, + { status: 500 } + ); } } diff --git a/src/app/api/a2a/tasks/route.ts b/src/app/api/a2a/tasks/route.ts index ddd1ad60f1..18353dfa7d 100644 --- a/src/app/api/a2a/tasks/route.ts +++ b/src/app/api/a2a/tasks/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { getTaskManager, type TaskState } from "@/lib/a2a/taskManager"; +import { authorizeA2ATaskRoute } from "@/app/api/a2a/_auth"; import { createConductorTask } from "@/lib/conductor/hubProxy"; import { getSettings } from "@/lib/db/settings"; @@ -22,6 +23,11 @@ function parseIntParam(value: string | null, fallback: number): number { } export async function GET(request: Request) { + // GHSA-jcm5-6wpp-wjj8: the list route had no auth call at all. Management + // (or the keyless posture) sees every task; a bare API key must be valid + // and is owner-scoped. + const auth = await authorizeA2ATaskRoute(request); + if (auth instanceof Response) return auth; try { const { searchParams } = new URL(request.url); const stateParam = searchParams.get("state"); @@ -36,7 +42,7 @@ export async function GET(request: Request) { const tm = getTaskManager(); const total = tm.countTasks({ state, skill }); - const tasks = tm.listTasks({ state, skill, limit, offset }); + const tasks = tm.listTasks({ state, skill, limit, offset }, auth.owner); return NextResponse.json({ tasks, @@ -104,7 +110,10 @@ export function authenticateA2A(request: Request): boolean { */ export async function POST(request: Request) { if (!authenticateA2A(request)) { - return NextResponse.json({ error: "Unauthorized: missing or invalid API key" }, { status: 401 }); + return NextResponse.json( + { error: "Unauthorized: missing or invalid API key" }, + { status: 401 } + ); } const settings = await getSettings(); if (settings.a2aEnabled !== true) { @@ -122,12 +131,18 @@ export async function POST(request: Request) { } const parsed = delegationSchema.safeParse(raw); if (!parsed.success) { - return NextResponse.json({ error: "Invalid A2A task: provide messages[] (and metadata.conductor)" }, { status: 400 }); + return NextResponse.json( + { error: "Invalid A2A task: provide messages[] (and metadata.conductor)" }, + { status: 400 } + ); } const { skill, messages, metadata } = parsed.data; if (skill !== "conductor" && !skill.startsWith("conductor-cli-")) { return NextResponse.json( - { error: "Only Conductor fleet skills are delegable here (conductor / conductor-cli-)" }, + { + error: + "Only Conductor fleet skills are delegable here (conductor / conductor-cli-)", + }, { status: 400 } ); } @@ -138,7 +153,9 @@ export async function POST(request: Request) { { status: 400 } ); } - const prompt = [...messages].reverse().find((m) => m.role === "user")?.content ?? messages[messages.length - 1].content; + const prompt = + [...messages].reverse().find((m) => m.role === "user")?.content ?? + messages[messages.length - 1].content; const created = await createConductorTask({ repoUrl: conductor.repo.url, diff --git a/src/lib/a2a/authenticate.ts b/src/lib/a2a/authenticate.ts new file mode 100644 index 0000000000..b57d4082cc --- /dev/null +++ b/src/lib/a2a/authenticate.ts @@ -0,0 +1,53 @@ +/** + * Shared A2A authentication + caller-owner resolution (GHSA-jcm5-6wpp-wjj8). + * + * The JSON-RPC router (/a2a) grew its own authenticate() for GHSA-v54m, but + * the REST task routes under /api/a2a/tasks/ had no auth call at all. Both + * surfaces now share this single implementation so they cannot drift again: + * same REQUIRE_API_KEY posture as /v1, same keyless local-first default, and + * a stable owner id (hashed API key) used to scope task visibility. + */ + +import { createHash, timingSafeEqual } from "crypto"; +import type { NextRequest } from "next/server"; +import { extractApiKey, isValidApiKey } from "@/sse/services/auth"; +import { isRequireApiKeyEnabled } from "@/shared/utils/featureFlags"; + +function tokensMatch(provided: string, expected: string): boolean { + const a = Buffer.from(provided); + const b = Buffer.from(expected); + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); +} + +/** + * Whether the request may use the A2A surface at all. Mirrors the JSON-RPC + * posture: when a client key is required, demand a valid OmniRoute key; + * otherwise honor the legacy explicit A2A key; otherwise stay keyless (the + * same local-first default as /v1). + */ +export async function authenticateA2ARequest(req: NextRequest | Request): Promise { + const apiKey = extractApiKey(req as NextRequest); + if (isRequireApiKeyEnabled()) { + return apiKey ? await isValidApiKey(apiKey) : false; + } + + const configuredKey = process.env.OMNIROUTE_API_KEY; + if (configuredKey) { + return apiKey ? tokensMatch(apiKey, configuredKey) : false; + } + + // No API key required and none configured — allow (keyless local-first). + return true; +} + +/** + * Owner id for task scoping (GHSA-jcm5-6wpp-wjj8): a stable hash of the + * caller's API key, or `undefined` when the call carries no key (keyless + * posture — ownerless tasks stay visible to everyone, by design). + */ +export function resolveA2AOwner(req: NextRequest | Request): string | undefined { + const apiKey = extractApiKey(req as NextRequest); + if (!apiKey) return undefined; + return createHash("sha256").update(apiKey).digest("hex").slice(0, 32); +} diff --git a/src/lib/a2a/taskManager.ts b/src/lib/a2a/taskManager.ts index 390bcda03d..a21ac57207 100644 --- a/src/lib/a2a/taskManager.ts +++ b/src/lib/a2a/taskManager.ts @@ -45,6 +45,13 @@ export interface A2ATask { createdAt: string; updatedAt: string; expiresAt: string; + /** + * GHSA-jcm5-6wpp-wjj8: principal that created the task (hashed API key). + * `undefined` = created under the keyless local-first posture — such tasks + * stay visible to every caller, matching the pre-owner behavior. Tasks WITH + * an owner are only returned/cancelled/listed for the same owner. + */ + owner?: string; } export interface TaskListFilter { @@ -91,7 +98,7 @@ export class A2ATaskManager { } } - createTask(input: TaskInput): A2ATask { + createTask(input: TaskInput, owner?: string): A2ATask { const now = new Date(); const task: A2ATask = { id: randomUUID(), @@ -104,19 +111,31 @@ export class A2ATaskManager { createdAt: now.toISOString(), updatedAt: now.toISOString(), expiresAt: new Date(now.getTime() + this.ttlMs).toISOString(), + ...(owner !== undefined ? { owner } : {}), }; this.tasks.set(task.id, task); return task; } - getTask(taskId: string): A2ATask | undefined { + /** + * Owner scoping (GHSA-jcm5-6wpp-wjj8): a task carrying an owner is visible + * only to that owner. Ownerless tasks (keyless posture, or created before + * this field existed) stay visible to everyone — no behavior change there. + */ + private isVisibleTo(task: A2ATask, owner?: string): boolean { + return task.owner === undefined || task.owner === owner; + } + + getTask(taskId: string, owner?: string): A2ATask | undefined { const task = this.tasks.get(taskId); if (task && new Date(task.expiresAt) < new Date()) { if (task.state === "submitted" || task.state === "working") { this.updateTask(taskId, "failed", undefined, "Task expired"); } } - return this.tasks.get(taskId); + const current = this.tasks.get(taskId); + if (!current || !this.isVisibleTo(current, owner)) return undefined; + return current; } updateTask( @@ -142,7 +161,15 @@ export class A2ATaskManager { return task; } - cancelTask(taskId: string): A2ATask { + cancelTask(taskId: string, owner?: string): A2ATask { + // Owner check BEFORE the mutation (GHSA-jcm5-6wpp-wjj8): a caller must not + // cancel another principal's task by id. Uses the same not-found error as + // a missing task so an IDOR probe cannot distinguish "exists but not + // yours" from "does not exist". + const task = this.tasks.get(taskId); + if (!task || !this.isVisibleTo(task, owner)) { + throw new Error(`Task ${taskId} not found`); + } return this.updateTask(taskId, "cancelled", undefined, "Cancelled by client"); } @@ -153,8 +180,11 @@ export class A2ATaskManager { return tasks.length; } - listTasks(filter?: TaskListFilter): A2ATask[] { + listTasks(filter?: TaskListFilter, owner?: string): A2ATask[] { let tasks = [...this.tasks.values()]; + // GHSA-jcm5-6wpp-wjj8: when an owner scope is supplied, owned tasks of + // other principals are hidden; ownerless tasks remain visible (posture). + if (owner !== undefined) tasks = tasks.filter((t) => this.isVisibleTo(t, owner)); if (filter?.state) tasks = tasks.filter((t) => t.state === filter.state); if (filter?.skill) tasks = tasks.filter((t) => t.skill === filter.skill); tasks.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); diff --git a/src/server/authz/routeGuard.ts b/src/server/authz/routeGuard.ts index 7c61d24545..c5cbe01501 100644 --- a/src/server/authz/routeGuard.ts +++ b/src/server/authz/routeGuard.ts @@ -43,6 +43,8 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray = [ "/dashboard/providers/services/", // T-07: reverse proxy to embedded service UIs "/api/copilot/", // unauthenticated LLM driver — CLI-only by default; admins can opt-in to remote access via manage-scope bypass "/api/tools/agent-bridge/", // AgentBridge: spawns MITM server + DNS edits (Hard Rules #15 + #17) + "/api/settings/mitm", // "Enable MITM" flow: installs a system-wide trusted root CA (security add-trusted-cert / certutil / update-ca-certificates) and writes /etc/hosts DNS overrides via src/mitm/* — host-level TLS interception. Was MANAGEMENT-only, so requireLogin=false left it remotely reachable (GHSA-x7vm-hp44-9p79, Hard Rules #15 + #17). Same tier as /api/tools/agent-bridge/. + "/api/cli-tools/antigravity-mitm", // Antigravity MITM enable flow: same privileged CA-trust + DNS surface as /api/settings/mitm (GHSA-x7vm-hp44-9p79, Hard Rules #15 + #17). Covers the /alias child route by prefix. "/api/tools/traffic-inspector/", // Traffic Inspector: http-proxy listener + system proxy (Hard Rules #15 + #17) "/api/issue-agent/", // Issue Agent: recorded/local triage executor surface; keep loopback/LAN until sandbox + audit hardening is complete "/api/plugins/", // plugins: load/execute via worker_threads + child_process (Hard Rules #15 + #17) @@ -126,6 +128,12 @@ export const ALWAYS_PROTECTED_API_PATHS: ReadonlyArray = [ // /api/settings/database already does. isAlwaysProtectedPath matches on a path // boundary, so this covers export, exportAll and import. (GHSA-mghq-58h3-qcqj) "/api/db-backups", + // Legacy siblings of /api/db-backups left out of the mghq fix: export-json + // dumps every stored credential and import-json irreversibly replaces + // settings/connections, and both handlers only gate on isAuthRequired() — + // which is false under requireLogin=false. (GHSA-v7g9-7f55-5g46) + "/api/settings/export-json", + "/api/settings/import-json", ]; export function isLoopbackHost(hostHeader: string | null): boolean { diff --git a/src/shared/constants/spawnCapablePrefixes.ts b/src/shared/constants/spawnCapablePrefixes.ts index 20787d7d2f..3a1a05a881 100644 --- a/src/shared/constants/spawnCapablePrefixes.ts +++ b/src/shared/constants/spawnCapablePrefixes.ts @@ -28,6 +28,8 @@ export const SPAWN_CAPABLE_PREFIXES: ReadonlyArray = [ "/api/cli-tools/qwen-settings", // GET probes the Qwen Code binary; the route also mutates local ~/.qwen files "/api/services/", // T-10: can run npm install + spawn node processes "/api/tools/agent-bridge/", // start/stop MITM server + DNS edits (Hard Rules #15 + #17) + "/api/settings/mitm", // installs a system trusted root CA + /etc/hosts DNS overrides via src/mitm/* — must never be whitelistable via manage-scope bypass (GHSA-x7vm-hp44-9p79, Hard Rules #15 + #17) + "/api/cli-tools/antigravity-mitm", // same privileged CA-trust + DNS surface as /api/settings/mitm (GHSA-x7vm-hp44-9p79, Hard Rules #15 + #17) "/api/tools/traffic-inspector/", // http-proxy listener + system proxy (Hard Rules #15 + #17) "/api/plugins/", // plugins: load/execute via worker_threads + child_process (Hard Rules #15 + #17) "/api/local/", // T-12: 1-click local service launchers (Redis today) — must never be whitelistable via manage-scope bypass (Hard Rules #15 + #17) diff --git a/tests/unit/a2a-task-owner-idor.test.ts b/tests/unit/a2a-task-owner-idor.test.ts new file mode 100644 index 0000000000..365744ed33 --- /dev/null +++ b/tests/unit/a2a-task-owner-idor.test.ts @@ -0,0 +1,136 @@ +/** + * GHSA-jcm5-6wpp-wjj8 — A2A task IDOR + unauthenticated REST task routes. + * + * Two gaps closed here: + * 1. The REST routes /api/a2a/tasks/[id] and /api/a2a/tasks/[id]/cancel had + * NO auth call at all — open regardless of configuration. They now share + * the JSON-RPC surface's authentication (REQUIRE_API_KEY posture). + * 2. Tasks lived in an owner-less Map: any caller could read/cancel any + * task by id. Tasks now bind to an owner (hashed API key) at creation and + * reads/cancels/lists are owner-scoped. Ownerless tasks (keyless + * local-first posture) stay visible to everyone — by design. + * + * Run with: + * node --import tsx/esm --test tests/unit/a2a-task-owner-idor.test.ts + */ + +import { describe, it, after } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omni-a2a-idor-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "a2a-idor-test-secret"; +process.env.OMNIROUTE_DISABLE_REDIS_AUTH_CACHE = "1"; + +const core = await import("../../src/lib/db/core.ts"); +const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); +const { A2ATaskManager, getTaskManager } = await import("../../src/lib/a2a/taskManager.ts"); +const { resolveA2AOwner } = await import("../../src/lib/a2a/authenticate.ts"); +const restGet = await import("../../src/app/api/a2a/tasks/[id]/route.ts"); + +const ORIGINAL_REQUIRE = process.env.REQUIRE_API_KEY; + +after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + if (ORIGINAL_REQUIRE === undefined) delete process.env.REQUIRE_API_KEY; + else process.env.REQUIRE_API_KEY = ORIGINAL_REQUIRE; +}); + +function makeManager() { + const tm = new A2ATaskManager(5); + // Prevent the per-instance cleanup interval from keeping the process alive. + clearInterval((tm as unknown as { cleanupInterval: NodeJS.Timeout }).cleanupInterval); + return tm; +} + +describe("A2ATaskManager — owner scoping (GHSA-jcm5)", () => { + it("another principal cannot READ an owned task (same undefined as missing)", () => { + const tm = makeManager(); + const task = tm.createTask({ skill: "smart-routing", messages: [] }, "owner-a"); + assert.equal(tm.getTask(task.id, "owner-a")?.id, task.id, "the owner still reads it"); + assert.equal(tm.getTask(task.id, "owner-b"), undefined, "another owner gets undefined"); + }); + + it("another principal cannot CANCEL an owned task (not-found error, no existence oracle)", () => { + const tm = makeManager(); + const task = tm.createTask({ skill: "smart-routing", messages: [] }, "owner-a"); + assert.throws(() => tm.cancelTask(task.id, "owner-b"), /not found/); + assert.equal(tm.getTask(task.id, "owner-a")?.state, "submitted", "task untouched"); + assert.equal(tm.cancelTask(task.id, "owner-a").state, "cancelled", "the owner can cancel"); + }); + + it("owner-scoped listTasks hides other principals' owned tasks", () => { + const tm = makeManager(); + tm.createTask({ skill: "s1", messages: [] }, "owner-a"); + const mine = tm.createTask({ skill: "s1", messages: [] }, "owner-b"); + const listed = tm.listTasks(undefined, "owner-b"); + assert.deepEqual( + listed.map((t) => t.id), + [mine.id] + ); + // No owner scope (management/dashboard path) still sees everything. + assert.equal(tm.listTasks(undefined).length, 2); + }); + + it("ownerless tasks stay visible to everyone (keyless local-first posture)", () => { + const tm = makeManager(); + const task = tm.createTask({ skill: "smart-routing", messages: [] }); + assert.equal(tm.getTask(task.id, "anyone")?.id, task.id); + assert.equal(tm.getTask(task.id)?.id, task.id); + assert.equal(tm.cancelTask(task.id, "anyone").state, "cancelled"); + }); +}); + +describe("REST /api/a2a/tasks/[id] — authentication (GHSA-jcm5)", () => { + it("rejects an unkeyed call when REQUIRE_API_KEY=true (was: no auth at all)", async () => { + process.env.REQUIRE_API_KEY = "true"; + delete process.env.OMNIROUTE_API_KEY; + const res = await restGet.GET(new Request("http://localhost/api/a2a/tasks/abc") as never, { + params: Promise.resolve({ id: "abc" }), + }); + assert.equal(res.status, 401); + }); + + it("serves a keyed call under REQUIRE_API_KEY=true", async () => { + process.env.REQUIRE_API_KEY = "true"; + const key = await apiKeysDb.createApiKey("a2a-rest-client", "machine-rest", []); + const res = await restGet.GET( + new Request("http://localhost/api/a2a/tasks/definitely-missing", { + headers: { authorization: `Bearer ${key.key}` }, + }) as never, + { params: Promise.resolve({ id: "definitely-missing" }) } + ); + // Authenticated — the 404 now comes from the task lookup, not the auth gate. + assert.equal(res.status, 404); + }); + + it("keyed caller gets 404 for another principal's task (route-level IDOR, GHSA-jcm5)", async () => { + process.env.REQUIRE_API_KEY = "true"; + const tm = getTaskManager(); + // A task owned by a DIFFERENT principal than the caller's key hash. + const foreign = tm.createTask({ skill: "smart-routing", messages: [] }, "some-other-owner"); + const key = await apiKeysDb.createApiKey("a2a-rest-idor", "machine-idor", []); + const req = new Request(`http://localhost/api/a2a/tasks/${foreign.id}`, { + headers: { authorization: `Bearer ${key.key}` }, + }); + const res = await restGet.GET(req as never, { params: Promise.resolve({ id: foreign.id }) }); + assert.equal(res.status, 404, "another principal's task is invisible"); + + // And the same task IS visible to its owner (owner hash derived from the key). + const owned = tm.createTask( + { skill: "smart-routing", messages: [] }, + resolveA2AOwner(req as never) + ); + const res2 = await restGet.GET( + new Request(`http://localhost/api/a2a/tasks/${owned.id}`, { + headers: { authorization: `Bearer ${key.key}` }, + }) as never, + { params: Promise.resolve({ id: owned.id }) } + ); + assert.equal(res2.status, 200, "the owner reads its own task"); + }); +}); diff --git a/tests/unit/a2a-tasks-auth.test.ts b/tests/unit/a2a-tasks-auth.test.ts index 5569905d66..53d3a975c3 100644 --- a/tests/unit/a2a-tasks-auth.test.ts +++ b/tests/unit/a2a-tasks-auth.test.ts @@ -8,7 +8,9 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const TASKS_ROUTE = path.resolve(__dirname, "../../src/app/api/a2a/tasks/route.ts"); -const A2A_ROUTE = path.resolve(__dirname, "../../src/app/a2a/route.ts"); +// GHSA-jcm5-6wpp-wjj8: the constant-time token comparison moved out of +// src/app/a2a/route.ts into the shared helper both surfaces now use. +const A2A_AUTH_HELPER = path.resolve(__dirname, "../../src/lib/a2a/authenticate.ts"); const source = fs.readFileSync(TASKS_ROUTE, "utf-8"); @@ -21,11 +23,11 @@ function hasImport(src: string, name: string, from: string): boolean { return pattern.test(src); } -test("tasks route uses the same constant-time contract as src/app/a2a/route.ts", () => { - const a2aSource = fs.readFileSync(A2A_ROUTE, "utf-8"); +test("tasks route uses the same constant-time contract as the shared A2A auth helper", () => { + const a2aSource = fs.readFileSync(A2A_AUTH_HELPER, "utf-8"); assert.ok( - hasImport(a2aSource, "timingSafeEqual", "node:crypto"), - "reference route imports timingSafeEqual" + hasImport(a2aSource, "timingSafeEqual", "crypto"), + "shared auth helper imports timingSafeEqual" ); assert.ok( diff --git a/tests/unit/authz/routeGuard.test.ts b/tests/unit/authz/routeGuard.test.ts index 163f5bce41..cae8ef4a8c 100644 --- a/tests/unit/authz/routeGuard.test.ts +++ b/tests/unit/authz/routeGuard.test.ts @@ -22,6 +22,22 @@ test("isLocalOnlyPath: /api/cli-tools/runtime/ is local-only", () => { assert.equal(isLocalOnlyPath("/api/cli-tools/runtime/claude"), true); }); +test("isLocalOnlyPath: MITM management routes are local-only (GHSA-x7vm-hp44-9p79)", () => { + // The "Enable MITM" flow installs a system-wide trusted root CA and writes + // /etc/hosts DNS overrides (src/mitm/*) — host-level TLS interception. Both + // routes were MANAGEMENT-classified only, so requireLogin=false left them + // remotely reachable. They belong to the same loopback tier as + // /api/tools/agent-bridge/ (also MITM + DNS). + assert.equal(isLocalOnlyPath("/api/settings/mitm"), true); + assert.equal(isLocalOnlyPath("/api/cli-tools/antigravity-mitm"), true); + assert.equal(isLocalOnlyPath("/api/cli-tools/antigravity-mitm/alias"), true); +}); + +test("isLocalOnlyBypassableByManageScope: MITM routes are NOT bypassable (GHSA-x7vm-hp44-9p79)", () => { + assert.equal(isLocalOnlyBypassableByManageScope("/api/settings/mitm"), false); + assert.equal(isLocalOnlyBypassableByManageScope("/api/cli-tools/antigravity-mitm"), false); +}); + test("isLocalOnlyPath: regular management routes are not local-only", () => { assert.equal(isLocalOnlyPath("/api/settings"), false); assert.equal(isLocalOnlyPath("/api/providers"), false); @@ -89,6 +105,19 @@ test("isAlwaysProtectedPath: /api/db-backups is always protected (GHSA-mghq-58h3 assert.equal(isAlwaysProtectedPath("/api/db-backups/import"), true); }); +test("isAlwaysProtectedPath: legacy settings export/import-json are always protected (GHSA-v7g9-7f55-5g46)", () => { + // The mghq fix covered /api/db-backups but left the legacy sibling routes out: + // export-json dumps every credential and import-json irreversibly replaces + // settings/connections. Both handlers only check isAuthRequired(), which + // returns false under requireLogin=false — so they must sit in Tier 2 like + // /api/settings/database and /api/db-backups. + assert.equal(isAlwaysProtectedPath("/api/settings/export-json"), true); + assert.equal(isAlwaysProtectedPath("/api/settings/import-json"), true); + // The matcher is a plain startsWith (fail-closed: covers more, never less), + // so a hypothetical export-json2 sibling would also be protected — fine. + assert.equal(isAlwaysProtectedPath("/api/settings/proxy"), false); +}); + test("isAlwaysProtectedPath: ordinary settings routes are not always protected", () => { assert.equal(isAlwaysProtectedPath("/api/settings"), false); assert.equal(isAlwaysProtectedPath("/api/settings/proxy"), false); diff --git a/tests/unit/authz/spawn-capable-prefixes-client-safe.test.ts b/tests/unit/authz/spawn-capable-prefixes-client-safe.test.ts index 788777596d..8a2c6b9e17 100644 --- a/tests/unit/authz/spawn-capable-prefixes-client-safe.test.ts +++ b/tests/unit/authz/spawn-capable-prefixes-client-safe.test.ts @@ -82,11 +82,13 @@ test("SPAWN_CAPABLE_PREFIXES is defined in the server-free constants leaf with t "/api/headroom/stop", "/api/vnc-session", "/api/modality-bridge/video/", + "/api/settings/mitm", + "/api/cli-tools/antigravity-mitm", ]) { assert.ok( SPAWN_CAPABLE_PREFIXES.includes(prefix), `SPAWN_CAPABLE_PREFIXES lost the spawn-capable prefix "${prefix}" during extraction` ); } - assert.equal(SPAWN_CAPABLE_PREFIXES.length, 12); + assert.equal(SPAWN_CAPABLE_PREFIXES.length, 14); }); diff --git a/tests/unit/cli-serve-hostname.test.ts b/tests/unit/cli-serve-hostname.test.ts index 377e7eed38..9b801e2baa 100644 --- a/tests/unit/cli-serve-hostname.test.ts +++ b/tests/unit/cli-serve-hostname.test.ts @@ -1,6 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { resolveServerHost } from "../../bin/cli/utils/serverHost.mjs"; +import { resolveServerHost, resolveExposureWarning } from "../../bin/cli/utils/serverHost.mjs"; test("serve hostname: Linux honors OMNIROUTE_SERVER_HOST when HOSTNAME is set", () => { assert.equal( @@ -55,3 +55,26 @@ test("serve hostname: Windows preserves an explicit legacy HOSTNAME", () => { test("serve hostname: Windows ignores an auto-set HOSTNAME matching the machine", () => { assert.equal(resolveServerHost({ HOSTNAME: "windows-pc" }, "win32", "windows-pc"), "0.0.0.0"); }); + +test("exposure warning: fires when bound to all interfaces with no API-key requirement (GHSA-wmgv-ph3p-rv57)", () => { + const warning = resolveExposureWarning({}, "0.0.0.0"); + assert.ok(warning, "a warning must be returned for the shipped default posture"); + assert.match(warning, /REQUIRE_API_KEY/); + assert.match(warning, /OMNIROUTE_SERVER_HOST/); +}); + +test("exposure warning: silent when REQUIRE_API_KEY is enabled", () => { + assert.equal(resolveExposureWarning({ REQUIRE_API_KEY: "true" }, "0.0.0.0"), null); + assert.equal(resolveExposureWarning({ REQUIRE_API_KEY: "1" }, "0.0.0.0"), null); +}); + +test("exposure warning: silent on loopback binds", () => { + assert.equal(resolveExposureWarning({}, "127.0.0.1"), null); + assert.equal(resolveExposureWarning({}, "localhost"), null); + assert.equal(resolveExposureWarning({}, "::1"), null); +}); + +test("exposure warning: fires for a LAN bind too (any non-loopback interface)", () => { + assert.ok(resolveExposureWarning({}, "192.168.0.17")); + assert.ok(resolveExposureWarning({}, "::")); +}); diff --git a/tests/unit/search-baseurl-ssrf-guard.test.ts b/tests/unit/search-baseurl-ssrf-guard.test.ts new file mode 100644 index 0000000000..f42335edd3 --- /dev/null +++ b/tests/unit/search-baseurl-ssrf-guard.test.ts @@ -0,0 +1,81 @@ +/** + * SSRF guard coverage for /v1/search's shared base-url resolution (GHSA-j7j4-g9qc-q69c). + * + * `provider_options.baseUrl` (and legacy `providerSpecificData.baseUrl`) is + * client-controlled and flowed verbatim through `resolveSearchBaseUrl()` into + * every search builder's server-side fetch target (searxng, ollama, …), with + * no SSRF validation — while the sink (`searchProxy.ts`) is a plain `fetch()`. + * The Firecrawl sibling was fixed in #10738; this shared resolver was missed. + * + * Guard mode is `block-metadata` (NOT public-only): the catalog's primary + * searxng use case is a self-hosted instance on loopback/LAN, so private + * hosts must keep working, while cloud-metadata endpoints (IMDS credential + * theft — the worst pivot) are rejected. + * + * Run with: + * node --import tsx/esm --test tests/unit/search-baseurl-ssrf-guard.test.ts + */ + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { resolveSearchBaseUrl } from "../../open-sse/handlers/search.ts"; +import type { SearchProviderConfig } from "../../open-sse/config/searchRegistry.ts"; + +const config: SearchProviderConfig = { + id: "searxng-search", + name: "SearXNG", + baseUrl: "http://127.0.0.1:8888", + method: "GET", + authType: "none", + costPerQuery: 0, +} as SearchProviderConfig; + +const base = { + query: "test", + searchType: "web", + maxResults: 5, +}; + +const METADATA_URLS = [ + "http://169.254.169.254/latest/meta-data/iam/security-credentials/", + "http://169.254.169.254/latest/meta-data/?x=/search", // reporter's suffix-bypass shape + "http://metadata.google.internal/computeMetadata/v1/", +]; + +describe("resolveSearchBaseUrl — SSRF guard on client-controlled baseUrl (GHSA-j7j4)", () => { + for (const malicious of METADATA_URLS) { + it(`rejects providerOptions.baseUrl pointing at cloud metadata (${malicious})`, () => { + assert.throws(() => { + resolveSearchBaseUrl(config, { ...base, providerOptions: { baseUrl: malicious } }); + }); + }); + + it(`rejects providerSpecificData.baseUrl pointing at cloud metadata (${malicious})`, () => { + assert.throws(() => { + resolveSearchBaseUrl(config, { ...base, providerSpecificData: { baseUrl: malicious } }); + }); + }); + } + + it("still allows a self-hosted loopback/LAN override (block-metadata, not public-only)", () => { + assert.equal( + resolveSearchBaseUrl(config, { + ...base, + providerOptions: { baseUrl: "http://127.0.0.1:9999" }, + }), + "http://127.0.0.1:9999" + ); + assert.equal( + resolveSearchBaseUrl(config, { + ...base, + providerOptions: { baseUrl: "http://10.0.0.5:8080" }, + }), + "http://10.0.0.5:8080" + ); + }); + + it("leaves the catalog baseUrl untouched when no override is supplied", () => { + assert.equal(resolveSearchBaseUrl(config, base), "http://127.0.0.1:8888"); + }); +}); From 67fba53190514541d92e0cccc6cd9e5349a9a57f Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 23 Aug 2026 20:32:46 -0300 Subject: [PATCH 2/2] fix(tests): drain base-red cluster from 2026-08-23 merges (#9985) (#11280) Validated on a 2-PR combined board: 158/158 across the drain's 17 focused test files (incl. integration qdrant-routes), typecheck:core + dashboard-typecheck clean, env-doc-sync, mutation-test-coverage, cli-i18n, file-size, changelog-integrity all green. Every red discriminated per-item as stale-test (contract intentionally moved, citing the origin PR) or real bug (fixed, never masked) across 3 base-advance waves (#9985): stryker registration, i18n gaps (health.* namespace mismatch + pt-BR parity), combo-token-window pins (872K), CLI catalog counts (prime-agent), the isComboStep single-target reasoning-transport-fallback regression, container-guard hermeticity, and 26 unsuppressed ESLint errors from the wave (typed qdrant adapter, import trim, justified disable with precedent). Drains the accumulated base-red cluster. --- .env.example | 6 ++ config/quality/dependency-allowlist.json | 6 ++ docs/reference/CLI-TOOLS.md | 6 +- docs/reference/ENVIRONMENT.md | 1 + open-sse/handlers/chatCore.ts | 7 +- .../components/modals/EditConnectionModal.tsx | 5 ++ src/i18n/messages/en.json | 14 ++-- src/i18n/messages/pt-BR.json | 26 +++++++ src/i18n/messages/vi.json | 16 ++-- src/shared/schemas/cliCatalog.ts | 3 +- stryker.conf.json | 2 +- tests/integration/qdrant-routes.test.ts | 76 +++++++++++-------- .../8134-github-t5-fallback-filter.test.ts | 57 ++++++++------ .../check-db-rules-classification.test.ts | 5 ++ tests/unit/cli-catalog-counts.test.ts | 7 +- tests/unit/cli-setup-opencode.test.ts | 16 +++- .../cli-tools-apply-opencode-jsonc.test.ts | 7 ++ tests/unit/cli-tools-schema.test.ts | 2 + tests/unit/cli-tools.test.ts | 4 +- tests/unit/cli/setup-qwen.test.ts | 5 ++ tests/unit/db-core-init.test.ts | 16 +++- .../effort-tiers-loop-catalog-e2e.test.ts | 2 +- .../models-catalog-combo-metadata.test.ts | 6 +- .../unit/provider-models-route-codex.test.ts | 5 +- tests/unit/search-route.test.ts | 42 +++++----- tests/unit/usage-service-hardening.test.ts | 10 ++- tests/unit/vscode-token-routes-gpt56.test.ts | 6 +- tests/unit/vscode-token-routes.test.ts | 11 ++- 28 files changed, 251 insertions(+), 118 deletions(-) diff --git a/.env.example b/.env.example index 7d92b7cad5..a2156902a6 100644 --- a/.env.example +++ b/.env.example @@ -65,6 +65,12 @@ INITIAL_PASSWORD=CHANGEME # OMNIROUTE_RELEASE_REF=origin/main # OMNIROUTE_ALLOW_CANARY_BUILD=1 +# Build-phase signal (#10060). Set to 1 by scripts/build/build-next-isolated.mjs and +# inherited by every spawned build worker so the DB layer returns a no-op stub instead +# of loading the native better-sqlite3 addon (which aborts the worker on exit). +# Never set this for the running server. Used by: src/lib/buildPhase.ts, src/lib/db/core.ts +# OMNIROUTE_BUILDING=1 + # Encryption key for SQLite database encryption at rest. # Used by: src/lib/db/encryption.ts — encrypts the entire SQLite database. # Generate: openssl rand -hex 32 | Leave empty to disable DB encryption. diff --git a/config/quality/dependency-allowlist.json b/config/quality/dependency-allowlist.json index c4476f95d1..92e6ef7e8d 100644 --- a/config/quality/dependency-allowlist.json +++ b/config/quality/dependency-allowlist.json @@ -1,5 +1,9 @@ { "_comment": "Allowlist anti-slopsquatting (check-deps.mjs). Toda dep nova exige adicao EXPLICITA aqui apos verificar que e legitima.", + "_justifications": { + "@testing-library/dom": "Peer dep obrigatoria de @testing-library/react v16 (adicionada no PR #11224); Refs #9985.", + "@testing-library/user-event": "Utilitario oficial do ecossistema testing-library para testes de UI (adicionada no PR #11224); Refs #9985." + }, "allowed": [ "@atjsh/llmlingua-2", "@aws-sdk/client-bedrock-runtime", @@ -20,8 +24,10 @@ "@stryker-mutator/tap-runner", "@swc/helpers", "@tailwindcss/postcss", + "@testing-library/dom", "@testing-library/jest-dom", "@testing-library/react", + "@testing-library/user-event", "@toon-format/toon", "@types/better-sqlite3", "@types/bun", diff --git a/docs/reference/CLI-TOOLS.md b/docs/reference/CLI-TOOLS.md index c32b433fbd..79d4a7d5e3 100644 --- a/docs/reference/CLI-TOOLS.md +++ b/docs/reference/CLI-TOOLS.md @@ -1,19 +1,19 @@ --- title: "CLI Tools — OmniRoute" version: 3.8.50 -lastUpdated: 2026-08-18 +lastUpdated: 2026-08-23 --- # CLI Tools — OmniRoute -Last updated: 2026-08-18 +Last updated: 2026-08-23 OmniRoute integrates with three categories of CLI tools spread across three dedicated dashboard pages: | Page | Route | Concept | Count | | -------------- | ----------------------- | ------------------------------------------------------------------------- | ------------ | | **CLI Code's** | `/dashboard/cli-code` | Coding tools you point at OmniRoute (Client → CLI → OmniRoute → Provider) | 26 | -| **CLI Agents** | `/dashboard/cli-agents` | Autonomous agents you point at OmniRoute (same flow, broader scope) | 8 | +| **CLI Agents** | `/dashboard/cli-agents` | Autonomous agents you point at OmniRoute (same flow, broader scope) | 9 | | **ACP Agents** | `/dashboard/acp-agents` | CLIs that OmniRoute spawns as backend via stdio/ACP (reverse flow) | see registry | Legacy routes redirect via 308: `/dashboard/cli-tools` → `/dashboard/cli-code`, `/dashboard/agents` → `/dashboard/acp-agents`. diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 2681ba5478..e9ef0df9c2 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -88,6 +88,7 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari | `OMNIROUTE_RELEASE_REF` | `origin/main` | `scripts/build/buildProvenance.ts` | Ref the pack-artifact provenance gate checks the build SHA against (#10427). | | `OMNIROUTE_ALLOW_CANARY_BUILD` | _(unset)_ | `scripts/build/buildProvenance.ts` | Set to `1` to allow packing a build whose SHA is not on the release line, recording it as a deliberate canary instead of failing the gate (#10427). | | `OMNIROUTE_SMOKE_API_KEY` | _(unset)_ | `scripts/ops/deploy-canary.mjs` | API key for the canary-deploy smoke probe, sent as `Authorization: Bearer` on `/v1/chat/completions`. Only used by the deploy script (#10429), never by the server. Not related to the `OMNIROUTE_SMOKE_*` variables of the opt-in CLI smoke harness (`RUN_CLI_SMOKE=1`, `OMNIROUTE_SMOKE_BASE_URL/MODEL/API_KEY_ENV/TARGETS/TIMEOUT_MS` in `tests/integration/upstream-cli-smoke.int.test.ts`) — see [CLI Integrations → Real smoke sweep](../guides/CLI-INTEGRATIONS.md). | +| `OMNIROUTE_BUILDING` | _(unset)_ | `src/lib/buildPhase.ts` | Build-phase signal (#10060): set to `1` by `scripts/build/build-next-isolated.mjs` and inherited by every spawned build worker so the DB layer returns a no-op stub instead of loading the native better-sqlite3 addon (which aborts the worker on exit). Never set for the running server. | | `OMNIROUTE_DATA_DIR` | _(unset)_ | `open-sse/executors/promptql/threadSticky.ts` | **Fallback alias** for `DATA_DIR`, checked only when `DATA_DIR` is unset. Used to locate the PromptQL executor's on-disk thread-sticky session cache (`/promptql-thread-sessions.json`); if neither var is set, the cache stays in-memory only (not persisted across restarts). | | `STORAGE_ENCRYPTION_KEY` | _(empty = disabled)_ | `src/lib/db/encryption.ts` | AES key for full SQLite database encryption at rest. Generate with `openssl rand -hex 32`. | | `STORAGE_ENCRYPTION_KEY_VERSION` | `v1` | `scripts/build/bootstrap-env.mjs`, `electron/main.js` | Version label for the encryption key. Increment when performing key rotation to support decryption of old backups. | diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 10e6c32ae6..07e3a25857 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -1218,7 +1218,12 @@ export async function handleChatCore({ credentials?.providerSpecificData?.preserveEncryptedReasoning === true, onIncompatibleReasoning: resolveIncompatibleReasoningAction({ reasoningTransportFallback, - isComboStep: Boolean(comboStepId || comboExecutionKey), + // #11178 regressed combo steps whose combo record carries no explicit + // stepId/executionKey (plain model-list combos): their explicit + // `reasoningTransportFallback: "skip"` config was silently degraded to + // "drop". `isCombo` is the combo marker; step ids are optional + // finer-grained metadata that plain combos never set. + isComboStep: Boolean(isCombo) || Boolean(comboStepId || comboExecutionKey), headers: clientRawRequest?.headers ?? null, }), } diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx index e2eb3288e2..ed4649d89e 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx @@ -290,6 +290,11 @@ export default function EditConnectionModal({ connection.providerSpecificData?.quotaPerUnit != null ? String(connection.providerSpecificData.quotaPerUnit) : ""; + // Modal-open form initialization from the loaded connection (sync with an + // external system on `isOpen`); remounting the 30+ field form per + // connection id is a behavior-risking restructure out of scope here + // (#11251 follow-up, #9985). + // eslint-disable-next-line react-hooks/set-state-in-effect setFormData({ name: connection.name || "", priority: connection.priority || 1, diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index e45ebbd04f..f3cab6d48c 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1221,12 +1221,6 @@ "consoleLogsSubtitle": "Console output", "logsActivitySubtitle": "User activity log", "healthSubtitle": "System health check", - "healthVerdictReady": "OmniRoute is ready", - "healthVerdictActionRequired": "Action required to restore full operation", - "healthVerdictCoolingDown": "Cooling down after recent changes", - "advancedDiagnosticsTitle": "Advanced diagnostics", - "hide": "Hide", - "show": "Show", "costsPricingSubtitle": "Per-model pricing rules", "costsBudgetSubtitle": "Budget limits", "costsQuotaShareSubtitle": "Share provider quotas across keys", @@ -2928,6 +2922,7 @@ "interpreter": "Open Interpreter autonomous coding agent CLI", "omp": "Oh My Pi terminal coding agent", "letta": "Letta CLI agent with persistent memory and tool use", + "prime-agent": "Prime Agent — self-improving RLM coding harness with OpenAI-compatible provider support", "warp": "Warp AI terminal with custom provider support", "agent-deck": "Agent Deck multi-agent orchestrator" }, @@ -4637,6 +4632,13 @@ "retry": "Retry", "allOperational": "All systems operational", "issuesDetected": "System issues detected", + "healthVerdictReady": "OmniRoute is ready", + "healthVerdictActionRequired": "Action required to restore full operation", + "healthVerdictCoolingDown": "Cooling down after recent changes", + "healthSubtitle": "System health check", + "advancedDiagnosticsTitle": "Advanced diagnostics", + "hide": "Hide", + "show": "Show", "updatedAt": "Updated {time}", "latency": "Latency", "latencyP50": "p50", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index 7fbe30f278..1c5beedc69 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -970,6 +970,13 @@ "batchTimelineCancelled": "Cancelado", "batchTokenUsage": "Uso de Token", "batchMetadata": "Metadados", + "batchHeaderSubtitle": "Execute muitas requisições como um único job", + "batchStep1": "1 · Enviar JSONL", + "batchStep1Desc": "Adicionar requisições", + "batchStep2": "2 · Criar lote", + "batchStep2Desc": "Executar job", + "batchStep3": "3 · Obter resultados", + "batchStep3Desc": "Baixar saída", "batchFileContents": "Conteúdo do Arquivo", "batchFileUsedByCount": "Usado por {count, plural, one {# lote} other {# lotes}}", "batchFilePreview": "Prévia", @@ -2905,6 +2912,7 @@ "interpreter": "CLI do agente de codificação autônomo Open Interpreter", "omp": "Agente de codificação de terminal Oh My Pi", "letta": "Agente CLI Letta com memória persistente e uso de ferramentas", + "prime-agent": "Prime Agent — harness de codificação RLM autoevolutivo com suporte a API compatível com OpenAI", "warp": "Terminal de IA Warp com suporte a provedor personalizado", "agent-deck": "Orquestrador multi-agente Agent Deck" }, @@ -3831,6 +3839,9 @@ }, "endpoint": { "title": "Endpoint da API", + "subtitle": "Use o endpoint compatível com OpenAI na maioria dos SDKs e ferramentas.", + "testEndpoint": "Testar endpoint →", + "advancedProtocols": "Protocolos avançados", "available": "Endpoints Disponíveis", "cloudProxy": "Proxy na Nuvem", "disableConfirm": "Tem certeza que deseja desativar o proxy na nuvem?", @@ -4611,6 +4622,13 @@ "retry": "Tentar Novamente", "allOperational": "Todos os sistemas operacionais", "issuesDetected": "Problemas detectados no sistema", + "healthVerdictReady": "O OmniRoute está pronto", + "healthVerdictActionRequired": "Ação necessária para restaurar a operação plena", + "healthVerdictCoolingDown": "Em resfriamento após mudanças recentes", + "healthSubtitle": "Verificação de saúde do sistema", + "advancedDiagnosticsTitle": "Diagnósticos avançados", + "hide": "Ocultar", + "show": "Mostrar", "updatedAt": "Atualizado {time}", "latency": "Latência", "latencyP50": "p50", @@ -12035,6 +12053,7 @@ "acp": { "title": "ACP Agents", "phrase": "CLIs que o OmniRoute spawna como backend de execução (fluxo reverso)", + "warning": "A maioria dos usuários pode ignorar isto — use apenas quando uma integração exigir.", "flow": "Cliente → OmniRoute → spawn CLI (stdio/ACP) → resposta", "seeOther": "Ver →" } @@ -13342,6 +13361,13 @@ }, "resilienceConnections": { "title": "Resiliência de Conexão", + "reassuranceTitle": "Suas conexões se recuperam automaticamente", + "reassuranceDetail": "Normalmente nenhuma ação é necessária. O OmniRoute dá uma pausa temporária em uma conexão após falhas e depois a tenta novamente com segurança.", + "plainStates": { + "healthy": "Requisições podem ser enviadas", + "coolingDown": "Tentando novamente em breve", + "lockedOut": "Precisa da sua atenção" + }, "table": { "status": "Status", "provider": "Provedor", diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index 787e6dd7d9..9d5ed6c524 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -1300,13 +1300,7 @@ "open": "mở", "close": "đóng" }, - "noResults": "Không có kết quả", - "healthVerdictReady": "OmniRoute đã sẵn sàng", - "healthVerdictActionRequired": "Cần hành động để khôi phục hoạt động đầy đủ", - "healthVerdictCoolingDown": "Đang nguội sau các thay đổi gần đây", - "advancedDiagnosticsTitle": "Chẩn đoán nâng cao", - "hide": "Ẩn", - "show": "Hiện" + "noResults": "Không có kết quả" }, "webhooks": { "title": "Webhook", @@ -2918,6 +2912,7 @@ "interpreter": "Tác nhân lập trình tự trị Open Interpreter CLI", "omp": "Tác nhân lập trình Oh My Pi trên terminal", "letta": "Tác nhân Letta CLI có bộ nhớ lâu dài và khả năng dùng công cụ", + "prime-agent": "Prime Agent — bộ khung lập trình RLM tự cải tiến hỗ trợ API tương thích OpenAI", "warp": "Terminal Warp AI hỗ trợ nhà cung cấp tùy chỉnh", "agent-deck": "Trình điều phối đa tác nhân Agent Deck" }, @@ -4627,6 +4622,13 @@ "retry": "Thử lại", "allOperational": "Tất cả hệ thống đang hoạt động bình thường", "issuesDetected": "Phát hiện sự cố hệ thống", + "healthVerdictReady": "OmniRoute đã sẵn sàng", + "healthVerdictActionRequired": "Cần hành động để khôi phục hoạt động đầy đủ", + "healthVerdictCoolingDown": "Đang nguội sau các thay đổi gần đây", + "healthSubtitle": "Kiểm tra tình trạng hệ thống", + "advancedDiagnosticsTitle": "Chẩn đoán nâng cao", + "hide": "Ẩn", + "show": "Hiện", "updatedAt": "Đã cập nhật {time}", "latency": "Độ trễ", "latencyP50": "p50", diff --git a/src/shared/schemas/cliCatalog.ts b/src/shared/schemas/cliCatalog.ts index 27ab19e3c9..bcfefe1700 100644 --- a/src/shared/schemas/cliCatalog.ts +++ b/src/shared/schemas/cliCatalog.ts @@ -67,4 +67,5 @@ export const EXPECTED_CODE_COUNT = 21; // +2 (#6318): "omp" (Oh My Pi) and "letta" (Letta CLI) added as agent entries. // Note: #6318 originally also shipped duplicate "pi"/"jcode"/"codewhale" entries — // those tools were already delivered by a separate PR, so only omp+letta landed here. -export const EXPECTED_AGENT_COUNT = 8; +// +1 (#11166): "prime-agent" (PrimeIntellect-ai/prime-agent) added as an agent entry. +export const EXPECTED_AGENT_COUNT = 9; diff --git a/stryker.conf.json b/stryker.conf.json index f9317d95a0..f039eeaca8 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -89,7 +89,6 @@ "tests/unit/auth-terminal-status.test.ts", "tests/unit/authz/discovery-routes-local-only.test.ts", "tests/unit/authz/oauth-autoimport-local-only.test.ts", - "tests/unit/quota-exhaustion-cutoff-opencode.test.ts", "tests/unit/authz/route-guard-local-prefix.test.ts", "tests/unit/authz/route-guard-skills-collect.test.ts", "tests/unit/authz/route-guard-version-get-exemption.test.ts", @@ -308,6 +307,7 @@ "tests/unit/public-client-ids-3493.test.ts", "tests/unit/publicCreds.test.ts", "tests/unit/qoder-oauth-config.test.ts", + "tests/unit/quota-exhaustion-cutoff-opencode.test.ts", "tests/unit/quota-groups-route.test.ts", "tests/unit/quota-key-models-route.test.ts", "tests/unit/quota-policy-generalization.test.ts", diff --git a/tests/integration/qdrant-routes.test.ts b/tests/integration/qdrant-routes.test.ts index 252c6e505f..34444154d5 100644 --- a/tests/integration/qdrant-routes.test.ts +++ b/tests/integration/qdrant-routes.test.ts @@ -39,6 +39,10 @@ const qdrantEmbeddingModelsRoute = // ── Helpers ── +// Route handlers are typed against NextRequest; the management-session helper +// returns the Fetch API Request, which is structurally sufficient at runtime. +const asNextRequest = (req: Request) => req as unknown as import("next/server").NextRequest; + async function resetStorage() { core.resetDbInstance(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); @@ -91,7 +95,7 @@ test.after(async () => { test("GET /api/settings/qdrant — returns settings with masked API key shape", async () => { const req = await makeAuthRequest("GET", "http://localhost/api/settings/qdrant"); - const res = await qdrantSettingsRoute.GET(req as any); + const res = await qdrantSettingsRoute.GET(asNextRequest(req)); assert.strictEqual(res.status, 200); const body = await res.json(); @@ -110,7 +114,7 @@ test("GET /api/settings/qdrant — returns settings with masked API key shape", test("GET /api/settings/qdrant — 401 without auth", async () => { await setRequireLogin(true); const req = makeUnauthRequest("GET", "http://localhost/api/settings/qdrant"); - const res = await qdrantSettingsRoute.GET(req as any); + const res = await qdrantSettingsRoute.GET(asNextRequest(req)); assert.strictEqual(res.status, 401); await setRequireLogin(false); }); @@ -126,7 +130,7 @@ test("PUT /api/settings/qdrant — updates settings and returns new masked shape embeddingModel: "openai/text-embedding-3-small", }); - const res = await qdrantSettingsRoute.PUT(req as any); + const res = await qdrantSettingsRoute.PUT(asNextRequest(req)); assert.strictEqual(res.status, 200); const body = await res.json(); @@ -148,7 +152,7 @@ test("PUT enabled=true also activates Qdrant as the engine (memoryVectorStore=qd host: "qdrant-server", collection: "c", }); - const res = await qdrantSettingsRoute.PUT(req as any); + const res = await qdrantSettingsRoute.PUT(asNextRequest(req)); assert.strictEqual(res.status, 200); const s = (await localDb.getSettings()) as Record; @@ -161,16 +165,20 @@ test("PUT enabled=true also activates Qdrant as the engine (memoryVectorStore=qd test("PUT enabled=false resets the engine back to auto (sqlite-vec)", async () => { await qdrantSettingsRoute.PUT( - (await makeAuthRequest("PUT", "http://localhost/api/settings/qdrant", { - enabled: true, - host: "qdrant-server", - collection: "c", - })) as any + asNextRequest( + await makeAuthRequest("PUT", "http://localhost/api/settings/qdrant", { + enabled: true, + host: "qdrant-server", + collection: "c", + }) + ) ); await qdrantSettingsRoute.PUT( - (await makeAuthRequest("PUT", "http://localhost/api/settings/qdrant", { - enabled: false, - })) as any + asNextRequest( + await makeAuthRequest("PUT", "http://localhost/api/settings/qdrant", { + enabled: false, + }) + ) ); const s = (await localDb.getSettings()) as Record; @@ -185,9 +193,11 @@ test("PUT without the enabled field must not change memoryVectorStore", async () // User already on qdrant; editing only the collection must not reset the engine. await localDb.updateSettings({ memoryVectorStore: "qdrant", qdrantEnabled: true }); await qdrantSettingsRoute.PUT( - (await makeAuthRequest("PUT", "http://localhost/api/settings/qdrant", { - collection: "renamed", - })) as any + asNextRequest( + await makeAuthRequest("PUT", "http://localhost/api/settings/qdrant", { + collection: "renamed", + }) + ) ); const s = (await localDb.getSettings()) as Record; @@ -211,11 +221,13 @@ test("PUT enabled=true invalidates the memory-settings cache (retrieval sees qdr ); const res = await qdrantSettingsRoute.PUT( - (await makeAuthRequest("PUT", "http://localhost/api/settings/qdrant", { - enabled: true, - host: "qdrant-server", - collection: "c", - })) as any + asNextRequest( + await makeAuthRequest("PUT", "http://localhost/api/settings/qdrant", { + enabled: true, + host: "qdrant-server", + collection: "c", + }) + ) ); assert.strictEqual(res.status, 200); @@ -234,7 +246,7 @@ test("PUT /api/settings/qdrant — 400 invalid settings (invalid port type in st port: "not-a-number", }); - const res = await qdrantSettingsRoute.PUT(req as any); + const res = await qdrantSettingsRoute.PUT(asNextRequest(req)); assert.strictEqual(res.status, 400); const body = await res.json(); assert.ok(body.message || body.error, "should return error"); @@ -243,7 +255,7 @@ test("PUT /api/settings/qdrant — 400 invalid settings (invalid port type in st test("PUT /api/settings/qdrant — 401 without auth", async () => { await setRequireLogin(true); const req = makeUnauthRequest("PUT", "http://localhost/api/settings/qdrant", { enabled: true }); - const res = await qdrantSettingsRoute.PUT(req as any); + const res = await qdrantSettingsRoute.PUT(asNextRequest(req)); assert.strictEqual(res.status, 401); await setRequireLogin(false); }); @@ -257,7 +269,7 @@ test("GET /api/settings/qdrant/health — returns health result shape (qdrant di headers: Object.fromEntries(headers.entries()), }); - const res = await qdrantHealthRoute.GET(req as any); + const res = await qdrantHealthRoute.GET(asNextRequest(req)); assert.strictEqual(res.status, 200); const body = await res.json(); @@ -292,7 +304,7 @@ test("GET /api/settings/qdrant/health — reports named collection vector metada try { const req = await makeAuthRequest("GET", "http://localhost/api/settings/qdrant/health"); - const res = await qdrantHealthRoute.GET(req as any); + const res = await qdrantHealthRoute.GET(asNextRequest(req)); const body = await res.json(); assert.strictEqual(res.status, 200); @@ -309,7 +321,7 @@ test("GET /api/settings/qdrant/health — reports named collection vector metada test("GET /api/settings/qdrant/health — 401 without auth", async () => { await setRequireLogin(true); const req = makeUnauthRequest("GET", "http://localhost/api/settings/qdrant/health"); - const res = await qdrantHealthRoute.GET(req as any); + const res = await qdrantHealthRoute.GET(asNextRequest(req)); assert.strictEqual(res.status, 401); await setRequireLogin(false); }); @@ -322,7 +334,7 @@ test("POST /api/settings/qdrant/search — returns ok + results array", async () topK: 5, }); - const res = await qdrantSearchRoute.POST(req as any); + const res = await qdrantSearchRoute.POST(asNextRequest(req)); assert.strictEqual(res.status, 200); const body = await res.json(); @@ -336,7 +348,7 @@ test("POST /api/settings/qdrant/search — 400 invalid body (empty query)", asyn topK: 5, }); - const res = await qdrantSearchRoute.POST(req as any); + const res = await qdrantSearchRoute.POST(asNextRequest(req)); assert.strictEqual(res.status, 400); const body = await res.json(); assert.ok(body.message || body.error, "should return error"); @@ -346,7 +358,7 @@ test("POST /api/settings/qdrant/search — 400 invalid body (empty query)", asyn test("POST /api/settings/qdrant/cleanup — returns ok + deletedCount + retentionDays", async () => { const req = await makeAuthRequest("POST", "http://localhost/api/settings/qdrant/cleanup"); - const res = await qdrantCleanupRoute.POST(req as any); + const res = await qdrantCleanupRoute.POST(asNextRequest(req)); assert.strictEqual(res.status, 200); const body = await res.json(); @@ -365,7 +377,7 @@ test("GET /api/settings/qdrant/embedding-models — returns models array", async headers: Object.fromEntries(headers.entries()), }); - const res = await qdrantEmbeddingModelsRoute.GET(req as any); + const res = await qdrantEmbeddingModelsRoute.GET(asNextRequest(req)); // 200 expected; verify shape assert.strictEqual(res.status, 200); const body = await res.json(); @@ -387,7 +399,7 @@ test("GET /api/settings/qdrant/embedding-models — lists only configured provid headers: Object.fromEntries(headers.entries()), }); - const res = await qdrantEmbeddingModelsRoute.GET(req as any); + const res = await qdrantEmbeddingModelsRoute.GET(asNextRequest(req)); assert.strictEqual(res.status, 200); const body = await res.json(); assert.ok(body.models.length > 0, "should list models for configured provider"); @@ -400,7 +412,7 @@ test("GET /api/settings/qdrant/embedding-models — lists only configured provid test("GET /api/settings/qdrant/embedding-models — 401 without auth", async () => { await setRequireLogin(true); const req = makeUnauthRequest("GET", "http://localhost/api/settings/qdrant/embedding-models"); - const res = await qdrantEmbeddingModelsRoute.GET(req as any); + const res = await qdrantEmbeddingModelsRoute.GET(asNextRequest(req)); assert.strictEqual(res.status, 401); await setRequireLogin(false); }); @@ -416,7 +428,7 @@ test("Qdrant routes — error response has no stack trace in body", async () => body: "not-valid-json{{{", }); - const res = await qdrantSettingsRoute.PUT(req as any); + const res = await qdrantSettingsRoute.PUT(asNextRequest(req)); assert.ok(res.status >= 400, "should return error status"); const body = await res.json(); diff --git a/tests/unit/8134-github-t5-fallback-filter.test.ts b/tests/unit/8134-github-t5-fallback-filter.test.ts index 6de8f13416..7f54ce4e34 100644 --- a/tests/unit/8134-github-t5-fallback-filter.test.ts +++ b/tests/unit/8134-github-t5-fallback-filter.test.ts @@ -6,8 +6,8 @@ import { getRegistryEntry } from "../../open-sse/config/providerRegistry.ts"; const { getNextFamilyFallback } = await import("../../open-sse/services/modelFamilyFallback.ts"); // Regression for #8134 — GitHub Copilot ("github", alias "gh") T5 family fallback -// returned "claude-opus-4-6" verbatim even though the github registry catalog -// (Opus 4.8 / 4.8-fast / 4.7 / 4.5) has NO 4.6 tier under any dot/hyphen +// returned "claude-opus-4-6" verbatim even though the github registry catalog at +// the time (Opus 4.8 / 4.8-fast / 4.7 / 4.5) had NO 4.6 tier under any dot/hyphen // notation. getNextFamilyFallback() resolved `supportedIds` from the provider's // registry but only used it to try notation variants of a candidate, never to // filter out a candidate that is provably absent from the catalog — so the @@ -18,35 +18,46 @@ const { getNextFamilyFallback } = await import("../../open-sse/services/modelFam // skips (continue) any family candidate that has no match in supportedIds // under ANY notation (hyphen, dot, or a dated-snapshot id with the date // suffix stripped) instead of returning it unfiltered. +// +// Fixture note: #10952 later added claude-opus-4.6 to the github registry, so +// the provably-absent tier used by the fixture moved to claude-opus-4-6-thinking +// (the ladder's first candidate after 4.6 — still absent from the catalog). -test("#8134: github claude-opus-4.8 fallback chain never returns an unsupported tier (claude-opus-4-6)", () => { +test("#8134: github claude-opus fallback chain never returns an unsupported tier (claude-opus-4-6-thinking)", () => { const github = getRegistryEntry("github"); assert.ok(github, "expected the github registry entry to resolve"); const githubIds = new Set(github.models.map((m) => m.id)); + // Fixture assumption: #10952 added claude-opus-4.6 to the github registry, so + // the original absent-tier role moved to the 4.6-thinking variant, which the + // catalog still does NOT carry under any notation. assert.ok( - !githubIds.has("claude-opus-4-6") && !githubIds.has("claude-opus-4.6"), - "fixture assumption broken: github registry now has a 4.6 tier" + !githubIds.has("claude-opus-4-6-thinking") && !githubIds.has("claude-opus-4.6-thinking"), + "fixture assumption broken: github registry now has a 4.6-thinking tier" ); + // Ladder reality: 4.8 -> 4.7 -> 4.6 -> [4-6-thinking (absent), 4-5-20251101, + // sonnet-5]. The absent 4-6-thinking must be SKIPPED — the third hop resolves + // to the dated 4.5 snapshot's undated catalog entry, never to 4-6-thinking. const tried = new Set(["github/claude-opus-4.8"]); - const first = getNextFamilyFallback("github/claude-opus-4.8", tried); - assert.ok(first, "expected a first fallback candidate"); - const firstBareId = first.replace(/^github\//, ""); - assert.ok( - githubIds.has(firstBareId), - `first fallback "${first}" is not in github's registered model catalog: ${[...githubIds].join(", ")}` - ); - - tried.add(first); - const second = getNextFamilyFallback(first, tried); - assert.ok(second, "expected a second fallback candidate (family must not be silently exhausted)"); - const secondBareId = second.replace(/^github\//, ""); - assert.ok( - githubIds.has(secondBareId), - `second fallback "${second}" is not in github's registered model catalog: ${[...githubIds].join(", ")}` - ); - assert.notEqual(secondBareId, "claude-opus-4-6"); - assert.notEqual(secondBareId, "claude-opus-4.6"); + const hops: string[] = []; + let current = "github/claude-opus-4.8"; + for (let hop = 0; hop < 3; hop++) { + const next = getNextFamilyFallback(current, tried); + assert.ok(next, `hop ${hop + 1}: family must not be silently exhausted`); + const bareId = next!.replace(/^github\//, ""); + assert.ok( + githubIds.has(bareId), + `hop ${hop + 1}: "${next}" is not in github's registered model catalog: ${[...githubIds].join(", ")}` + ); + assert.notEqual(bareId, "claude-opus-4-6-thinking"); + assert.notEqual(bareId, "claude-opus-4.6-thinking"); + tried.add(next!); + hops.push(next!); + current = next!; + } + // The skip specifically fired: the 4.6 -> next hop jumped past the absent + // 4-6-thinking tier straight to a catalogued model. + assert.equal(hops[2].replace(/^github\//, ""), "claude-opus-4.5"); }); test("#8134: getNextFamilyFallback never returns a candidate absent from the resolved provider's catalog", () => { diff --git a/tests/unit/check-db-rules-classification.test.ts b/tests/unit/check-db-rules-classification.test.ts index 5707878b8a..82ca183f0f 100644 --- a/tests/unit/check-db-rules-classification.test.ts +++ b/tests/unit/check-db-rules-classification.test.ts @@ -61,6 +61,11 @@ function hasImporter(mod: string, roots: string[]): boolean { new RegExp(`(?:import|require)\\s*\\(\\s*['""][^'"]+/db/${escaped}['"]`), // dynamic template: import(`…/db/.ts`) — bin/cli/runtime.mjs uses template literals new RegExp(`import\\s*\\(\`[^'"\`]+/db/${escaped}\\.ts\`\\)`), + // dynamic via file:// URL helper: import(projectFileUrl("…/db/.ts")) — + // bin/cli/runtime.mjs since #11238 (Windows-safe file:// dynamic imports). + new RegExp( + `import\\s*\\(\\s*projectFileUrl\\(\\s*['""][^'"]+/db/${escaped}\\.ts['"]\\s*\\)\\s*\\)` + ), // relative import within db/: from "./" or from "./" new RegExp(`from\\s+['"]\\.\\.?/${escaped}['"]`), ]; diff --git a/tests/unit/cli-catalog-counts.test.ts b/tests/unit/cli-catalog-counts.test.ts index 186a951329..36853fdf99 100644 --- a/tests/unit/cli-catalog-counts.test.ts +++ b/tests/unit/cli-catalog-counts.test.ts @@ -41,8 +41,8 @@ test("CLI_TOOLS total code entries (including none) equals 26 (21 visible + 5 no assert.equal(codeAll.length, 26, `Expected 26 total code entries, got ${codeAll.length}`); }); -test("CLI_TOOLS total (code + agent) = 34", () => { - assert.equal(all.length, 34, `Expected 34 total entries, got ${all.length}`); +test("CLI_TOOLS total (code + agent) = 35", () => { + assert.equal(all.length, 35, `Expected 35 total entries, got ${all.length}`); }); test("All code-none entries have configType mitm OR are legacy excluded entries", () => { @@ -99,7 +99,7 @@ test("The 21 visible code entries include Qwen Code's rebuilt integration", () = } }); -test("The 8 agent entries match D15 list exactly (+ omp + letta, #6318)", () => { +test("The 9 agent entries match D15 list exactly (+ omp + letta #6318, + prime-agent #11166)", () => { const d15Agents = new Set([ "hermes-agent", "openclaw", @@ -109,6 +109,7 @@ test("The 8 agent entries match D15 list exactly (+ omp + letta, #6318)", () => "agent-deck", "omp", "letta", + "prime-agent", ]); const agentIds = new Set(agentAll.map((t) => t.id)); for (const id of d15Agents) { diff --git a/tests/unit/cli-setup-opencode.test.ts b/tests/unit/cli-setup-opencode.test.ts index ca5425e9d5..ac99ced134 100644 --- a/tests/unit/cli-setup-opencode.test.ts +++ b/tests/unit/cli-setup-opencode.test.ts @@ -74,6 +74,9 @@ describe("omniroute setup opencode", () => { // Commander turns `--base-url` into `baseUrl` — the runner must accept it. baseUrl: "http://10.0.0.5:20128", nonInteractive: true, + // These tests exercise the plugin install/merge path, not the container + // guard (#10057) — keep them hermetic on container devboxes/CI. + allowContainerWrite: true, }); assert.equal(r.exitCode, 0); @@ -99,6 +102,7 @@ describe("omniroute setup opencode", () => { configDir: CONFIG_DIR, baseUrl: "http://10.0.0.9:20128", nonInteractive: true, + allowContainerWrite: true, }); assert.equal(r.exitCode, 0); @@ -127,7 +131,11 @@ describe("omniroute setup opencode", () => { }) ); - const r = await runSetupOpenCodeCommand({ configDir: CONFIG_DIR, nonInteractive: true }); + const r = await runSetupOpenCodeCommand({ + configDir: CONFIG_DIR, + nonInteractive: true, + allowContainerWrite: true, + }); assert.equal(r.exitCode, 0); const cfg = readConfig(); @@ -140,7 +148,11 @@ describe("omniroute setup opencode", () => { it("fails with a clear error (exit 1) when the bundled plugin dist is missing", async () => { fs.rmSync(path.join(FAKE_PLUGIN_DIR, "dist"), { recursive: true, force: true }); try { - const r = await runSetupOpenCodeCommand({ configDir: CONFIG_DIR, nonInteractive: true }); + const r = await runSetupOpenCodeCommand({ + configDir: CONFIG_DIR, + nonInteractive: true, + allowContainerWrite: true, + }); assert.equal(r.exitCode, 1); } finally { makeFakePluginDist(); diff --git a/tests/unit/cli-tools-apply-opencode-jsonc.test.ts b/tests/unit/cli-tools-apply-opencode-jsonc.test.ts index f530607bc6..3abdb422b4 100644 --- a/tests/unit/cli-tools-apply-opencode-jsonc.test.ts +++ b/tests/unit/cli-tools-apply-opencode-jsonc.test.ts @@ -15,6 +15,10 @@ const originalFetch = globalThis.fetch; const originalJwtSecret = process.env.JWT_SECRET; const originalApiKeySecret = process.env.API_KEY_SECRET; const originalXdg = process.env.XDG_CONFIG_HOME; +const originalAllowContainerWrite = process.env.OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE; +// This test exercises the apply/merge path, not the container guard (#10057) — +// keep it hermetic on container devboxes/CI. +process.env.OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE = "1"; const testRoots = new Set(); async function createAuthCookie(): Promise { @@ -72,6 +76,9 @@ test.afterEach(async () => { else process.env.API_KEY_SECRET = originalApiKeySecret; if (originalXdg === undefined) delete process.env.XDG_CONFIG_HOME; else process.env.XDG_CONFIG_HOME = originalXdg; + if (originalAllowContainerWrite === undefined) + delete process.env.OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE; + else process.env.OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE = originalAllowContainerWrite; for (const root of testRoots) await fs.rm(root, { recursive: true, force: true }); testRoots.clear(); }); diff --git a/tests/unit/cli-tools-schema.test.ts b/tests/unit/cli-tools-schema.test.ts index ee2354986b..3f01c16de5 100644 --- a/tests/unit/cli-tools-schema.test.ts +++ b/tests/unit/cli-tools-schema.test.ts @@ -11,6 +11,7 @@ test("CLI_TOOLS registry contains all expected tools including rebuilt Qwen Code // (CodeWhale is the actively-maintained successor to DeepSeek TUI). // omp + letta added by #6318 (agent-category CLI integrations). // grok-build added — xAI Grok Build TUI coding agent (ported from upstream decolua/9router#2571). + // prime-agent added by #11166 (PrimeIntellect-ai/prime-agent, agent category). const expected = [ "claude", "codex", @@ -46,6 +47,7 @@ test("CLI_TOOLS registry contains all expected tools including rebuilt Qwen Code "grok-build", "qwen", "zcode", + "prime-agent", ]; for (const id of expected) { assert.ok(id in CLI_TOOLS, `Missing tool: ${id}`); diff --git a/tests/unit/cli-tools.test.ts b/tests/unit/cli-tools.test.ts index 123c98e600..4523da96de 100644 --- a/tests/unit/cli-tools.test.ts +++ b/tests/unit/cli-tools.test.ts @@ -106,7 +106,9 @@ test("CLI fingerprint preserves Codex executor User-Agent and maps legacy Copilo { model: "gpt-4o", messages: [] } ); - assert.equal(copilot.headers["User-Agent"], "GitHubCopilotChat/0.54.0"); + // #10952 bumped GITHUB_COPILOT_CLI_VERSION 0.54.0 -> 1.0.81-6; the fingerprint + // pin tracks the advertised upstream CLI version. + assert.equal(copilot.headers["User-Agent"], "GitHubCopilotChat/1.0.81-6"); }); test("CLI fingerprint keeps legacy Copilot settings functional without exposing duplicate UI toggles", () => { diff --git a/tests/unit/cli/setup-qwen.test.ts b/tests/unit/cli/setup-qwen.test.ts index e8086961de..b6140c76d4 100644 --- a/tests/unit/cli/setup-qwen.test.ts +++ b/tests/unit/cli/setup-qwen.test.ts @@ -42,6 +42,9 @@ test("setup-qwen writes current V4 settings and only its dedicated env key", asy configPath: settingsPath, envPath, yes: true, + // These tests exercise the merge/write logic, not the container guard + // (#10057) — keep them hermetic on container devboxes/CI. + allowContainerWrite: true, }); assert.equal(code, 0); @@ -76,6 +79,8 @@ test("setup-qwen does not overwrite an invalid settings file", async () => { model: "model-id", configPath: settingsPath, yes: true, + // See above — hermetic regardless of container detection (#10057). + allowContainerWrite: true, }); assert.equal(code, 1); assert.equal(await fs.readFile(settingsPath, "utf8"), "{ invalid JSON"); diff --git a/tests/unit/db-core-init.test.ts b/tests/unit/db-core-init.test.ts index bb0cd512e5..15ab1b576b 100644 --- a/tests/unit/db-core-init.test.ts +++ b/tests/unit/db-core-init.test.ts @@ -497,7 +497,13 @@ test( } ); -test("build phase uses an in-memory database without creating sqlite files", serial, async () => { +test("build phase returns the no-op stub without creating sqlite files", serial, async () => { + // Contract changed by #10060 (via #10952): the build phase no longer opens a + // real in-memory SQLite with migrations — loading the native better-sqlite3 + // addon aborts the Next.js build worker on exit (node:: + // RemoveEnvironmentCleanupHook). getDbInstance() now returns a no-op stub + // (pinned by tests/unit/build/10060-build-sqlite-stub.test.ts); queries are + // harmless no-ops and no file is touched. const dataDir = makeTempDir("omniroute-db-build-"); try { @@ -510,13 +516,15 @@ test("build phase uses an in-memory database without creating sqlite files", ser const core = await importFresh("src/lib/db/core.ts"); const db = core.getDbInstance(); - assert.ok( + assert.notEqual(db.driver, "better-sqlite3"); + assert.equal( db .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?") - .get("provider_connections") + .get("provider_connections"), + undefined, + "the build stub must answer queries with no-ops, never a real table scan" ); assert.equal(fs.existsSync(path.join(dataDir, "storage.sqlite")), false); - assert.equal(db.pragma("journal_mode", { simple: true }), "memory"); core.resetDbInstance(); } diff --git a/tests/unit/effort-tiers-loop-catalog-e2e.test.ts b/tests/unit/effort-tiers-loop-catalog-e2e.test.ts index f2fe6f89c6..4e6b5c2663 100644 --- a/tests/unit/effort-tiers-loop-catalog-e2e.test.ts +++ b/tests/unit/effort-tiers-loop-catalog-e2e.test.ts @@ -4,7 +4,7 @@ * response (/api/v1/models), including the learned-only variant entry. * Never "fix" this test by injecting the same string on both sides. */ -import { test, after, beforeEach } from "node:test"; +import { test } from "node:test"; import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; diff --git a/tests/unit/models-catalog-combo-metadata.test.ts b/tests/unit/models-catalog-combo-metadata.test.ts index b4fc629bad..c80253c314 100644 --- a/tests/unit/models-catalog-combo-metadata.test.ts +++ b/tests/unit/models-catalog-combo-metadata.test.ts @@ -98,7 +98,11 @@ test("single-target Codex combo advertises a larger model context override", asy assert.equal(response.status, 200); assert.equal(direct?.context_length, contextWindow); assert.equal(combo?.context_length, contextWindow); - assert.equal(combo?.max_input_tokens, 272000); + // #11179 raised the static codex catalog cap to maxInputTokens=872000 (the real + // usable window; the old 272000 was just the first pricing tier). The input cap + // can never exceed the total window, so with the 500K override it clamps to it: + // min(872000, 500000) = 500000. + assert.equal(combo?.max_input_tokens, 500000); } finally { contextOverrides.removeModelContextOverride("codex", modelId); } diff --git a/tests/unit/provider-models-route-codex.test.ts b/tests/unit/provider-models-route-codex.test.ts index 0a3ad6f757..0d4587218d 100644 --- a/tests/unit/provider-models-route-codex.test.ts +++ b/tests/unit/provider-models-route-codex.test.ts @@ -181,10 +181,11 @@ test("provider models route merges live Codex models with the local catalog then // merge conservatively — the smaller of live vs. pinned wins, never the // larger, so a stale/inflated live number can never make OmniRoute promise // more context than the account can actually serve (#7012). Here the pinned - // GPT-5.6 Codex contract (272000/128000, see GPT_5_6_CODEX_CAPABILITIES) + // GPT-5.6 Codex contract (872000/128000, see GPT_5_6_CODEX_CAPABILITIES — raised + // from the old 272K pricing tier to the real usable window by #11179) // is smaller than the live payload's 999999/999999, so the pinned value wins. assert.equal(liveModel?.name, "GPT 5.6 Sol Live"); - assert.equal(liveModel?.inputTokenLimit, 272000); + assert.equal(liveModel?.inputTokenLimit, 872000); assert.equal(liveModel?.outputTokenLimit, 128000); assert.equal(liveModel?.apiFormat, "responses"); assert.deepEqual(liveModel?.supportedEndpoints, ["responses"]); diff --git a/tests/unit/search-route.test.ts b/tests/unit/search-route.test.ts index 9f3c67eca1..bdcfde1892 100644 --- a/tests/unit/search-route.test.ts +++ b/tests/unit/search-route.test.ts @@ -420,25 +420,24 @@ test("v1 search POST preserves stored SearXNG baseUrl for authless providers", a } }); -test("v1 search POST returns 400 when auto-select finds no configured provider (searxng-search is now fallbackOnly)", async () => { +test("v1 search POST falls back to duckduckgo-free when no provider is configured (#11097)", async () => { + // Contract changed by PR #11097 ("fix(search): fall back to duckduckgo-free when + // no search provider is configured"): zero-credential /v1/search no longer returns + // 400 — it promotes the fallback-only duckduckgo-free provider so out-of-the-box + // search works. This test pins the NEW contract. const originalFetch = globalThis.fetch; let capturedUrl = ""; + // DuckDuckGo lite HTML shape: result link + snippet cell (see + // open-sse/services/freeWebSearch.ts parseDuckDuckGoLite). + const liteHtml = ` + Auto-selected DuckDuckGo result + Fallback free search snippet + `; + globalThis.fetch = async (url) => { capturedUrl = String(url); - return new Response( - JSON.stringify({ - results: [ - { - title: "Auto-selected SearXNG result", - url: "https://searx.example/auto", - content: "Auto-selected self-hosted response", - engines: ["duckduckgo"], - }, - ], - }), - { status: 200, headers: { "content-type": "application/json" } } - ); + return new Response(liteHtml, { status: 200, headers: { "content-type": "text/html" } }); }; try { @@ -454,14 +453,15 @@ test("v1 search POST returns 400 when auto-select finds no configured provider ( ); const body = (await response.json()) as any; - assert.equal(response.status, 400); - assert.equal(capturedUrl, "", "fallback-only SearXNG must not receive an upstream request"); - assert.ok(body.error?.message || body.error); - assert.match( - String(body.error?.message ?? body.error), - /provider|configured/i, - "the response must explain that no provider was selected" + assert.equal(response.status, 200); + assert.equal( + capturedUrl, + "https://lite.duckduckgo.com/lite/", + "the fallback must call the DuckDuckGo lite endpoint" ); + assert.equal(body.provider, "duckduckgo-free"); + assert.equal(body.results[0].title, "Auto-selected DuckDuckGo result"); + assert.equal(body.results[0].url, "https://example.com/auto-result"); } finally { globalThis.fetch = originalFetch; } diff --git a/tests/unit/usage-service-hardening.test.ts b/tests/unit/usage-service-hardening.test.ts index d503cfef3e..6f9cbb6589 100644 --- a/tests/unit/usage-service-hardening.test.ts +++ b/tests/unit/usage-service-hardening.test.ts @@ -72,10 +72,12 @@ test("usage service covers GitHub free-plan parsing, auth denial and unsupported assert.equal(freeUsage.quotas.completions.used, 0); assert.equal(freeUsage.quotas.completions.remainingPercentage, 100); assert.equal(calls[0].headers.Authorization, "token gho-free"); - assert.equal(calls[0].headers["User-Agent"], "GitHubCopilotChat/0.54.0"); - assert.equal(calls[0].headers["Editor-Version"], "vscode/1.126.0"); - assert.equal(calls[0].headers["Editor-Plugin-Version"], "copilot-chat/0.54.0"); - assert.equal(calls[0].headers["X-GitHub-Api-Version"], "2026-06-01"); + // #10952 re-based the Copilot wire identity on the live-captured CLI 1.0.81-6 + // (copilot-developer-cli integration id; API version 2026-08-01). + assert.equal(calls[0].headers["User-Agent"], "GitHubCopilotChat/1.0.81-6"); + assert.equal(calls[0].headers["Editor-Version"], "copilot/1.0.81-6"); + assert.equal(calls[0].headers["Editor-Plugin-Version"], "copilot-chat/1.0.81-6"); + assert.equal(calls[0].headers["X-GitHub-Api-Version"], "2026-08-01"); globalThis.fetch = async () => new Response("forbidden", { status: 403 }); const forbidden: any = await usageService.getUsageForProvider({ diff --git a/tests/unit/vscode-token-routes-gpt56.test.ts b/tests/unit/vscode-token-routes-gpt56.test.ts index 64daae58c4..c68a85a6b7 100644 --- a/tests/unit/vscode-token-routes-gpt56.test.ts +++ b/tests/unit/vscode-token-routes-gpt56.test.ts @@ -128,9 +128,11 @@ test("vscode raw models route exposes native GPT-5.6 IDs and effort tiers", asyn assert.equal(typeof defaultModel.created, "number"); assert.equal(defaultModel.owned_by, "codex"); assert.equal(defaultModel.name, "Codex GPT 5.6 Sol"); - assert.equal(defaultModel.context_length, 272000); + // #11179: codex static catalog advertises the usable 872K window (max_context_window), + // not the old 272K pricing tier. + assert.equal(defaultModel.context_length, 872000); assert.equal(defaultModel.max_output_tokens, 128000); - assert.equal(defaultModel.max_input_tokens, 272000); + assert.equal(defaultModel.max_input_tokens, 872000); assert.deepEqual(defaultModel.capabilities, { vision: true, tool_calling: true, diff --git a/tests/unit/vscode-token-routes.test.ts b/tests/unit/vscode-token-routes.test.ts index 5a292b4600..d66118b3f2 100644 --- a/tests/unit/vscode-token-routes.test.ts +++ b/tests/unit/vscode-token-routes.test.ts @@ -255,7 +255,9 @@ test("vscode combos route resolves combo names through Ollama api/show", async ( assert.equal(body.model, "show-combo"); assert.equal(body.modelfile, "FROM show-combo"); assert.equal(body.details.family, "show-combo"); - assert.equal(body.model_info.context_length, 272000); + // #11179: codex static catalog advertises the usable 872K window (max_context_window), + // not the old 272K pricing tier. + assert.equal(body.model_info.context_length, 872000); assert.deepEqual(body.supportsReasoningEffort, ["none", "low", "medium", "high", "xhigh"]); assert.equal(body.model_info.capabilities.reasoning, true); }); @@ -290,7 +292,8 @@ test("vscode tokenized combos root route exposes importable combo metadata", asy assert.equal(response.status, 200); assert.ok(combo, "expected balanced-load in combo root response"); assert.equal(combo.url.includes("/responses#models.ai.azure.com"), true); - assert.equal(combo.maxInputTokens, 272000); + // #11179: codex static catalog maxInputTokens is now the usable 872K window. + assert.equal(combo.maxInputTokens, 872000); assert.equal(combo.toolCalling, true); assert.deepEqual(combo.supportsReasoningEffort, ["none", "low", "medium", "high", "xhigh"]); }); @@ -1073,7 +1076,9 @@ test("vscode tokenized api/show route exposes explicit reasoning effort metadata assert.equal(body.configurationSchema?.properties?.reasoningEffort?.default, "low"); assert.equal(body.model_info["general.basename"], "Codex GPT 5.6 Sol (Default)"); assert.equal(body.model_info["general.architecture"], "codex"); - assert.equal(body.model_info["codex.context_length"], 272000); + // #11179: codex static catalog advertises the usable 872K window (max_context_window), + // not the old 272K pricing tier. + assert.equal(body.model_info["codex.context_length"], 872000); assert.deepEqual(body.model_info.supports_reasoning_effort, [ "low", "medium",