diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e315fbf72..d1a227cde7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -692,7 +692,17 @@ jobs: # vouchear). Sem este passo o par build+validate deste job e estruturalmente # incompativel e falha 100% das vezes. - name: Stamp dist/BUILD_SHA for the provenance guard (#10427) - run: node scripts/build/write-build-sha.mjs + # O SHA TEM de vir do head da PR, nao de `git rev-parse HEAD`. Este workflow + # roda em `pull_request`, entao o checkout e o MERGE COMMIT efemero que o + # GitHub cria — um commit que nao existe em branch nenhuma e portanto nunca e + # ancestral da release. O guard de proveniencia (#10427) rejeita exatamente + # isso, e com razao: um artefato carimbado com o merge commit nao pode ser + # rastreado ate codigo que passou pelos gates. + env: + OMNIROUTE_BUILD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + export OMNIROUTE_BUILD_SHA="${OMNIROUTE_BUILD_SHA:0:7}" + node scripts/build/write-build-sha.mjs - run: npm run check:pack-artifact # WS1.2 (#7065 class): pack the real tarball, install it into a clean prefix and # BOOT it to a healthy /api/monitoring/health — the gate that structure checks diff --git a/electron/package.json b/electron/package.json index 63a97d527c..2e3cde1e5b 100644 --- a/electron/package.json +++ b/electron/package.json @@ -78,6 +78,14 @@ { "from": "../.build/electron-standalone", "to": "app", + "filter": [ + "**/*", + "node_modules/**/*" + ] + }, + { + "from": "../.build/electron-standalone/node_modules", + "to": "app/node_modules", "filter": [ "**/*" ] diff --git a/tests/e2e/group-b-quota-plans-config.spec.ts b/tests/e2e/group-b-quota-plans-config.spec.ts index 409e22eb9b..fa1b96499b 100644 --- a/tests/e2e/group-b-quota-plans-config.spec.ts +++ b/tests/e2e/group-b-quota-plans-config.spec.ts @@ -14,7 +14,20 @@ import { test, expect } from "@playwright/test"; import { gotoDashboardRoute } from "./helpers/dashboardAuth"; test.describe("Group B — Quota Plans Config", () => { + // Client-side exception capture. Without it a page that falls into the error + // boundary only shows up as "Internal Server Error" in the HTML, with no stack + // trace anywhere in the CI log — which is exactly how this spec's failure went + // undiagnosed for two CI rounds. + const pageErrors: string[] = []; + test.beforeEach(async ({ page }) => { + pageErrors.length = 0; + page.on("pageerror", (err) => { + pageErrors.push(`[pageerror] ${err.message}\n${err.stack ?? ""}`); + }); + page.on("console", (msg) => { + if (msg.type() === "error") pageErrors.push(`[console.error] ${msg.text()}`); + }); // Mock the plans list endpoint await page.route("**/api/quota/plans**", async (route) => { const url = new URL(route.request().url()); @@ -143,12 +156,25 @@ test.describe("Group B — Quota Plans Config", () => { } // After selection, the page should not be in a broken state. - // Note: page.content() includes the full HTML source, which contains Next.js - // chunk filenames — those hashes can legitimately contain the string "500". - // Checking for "500" in raw HTML is unreliable; instead check for the actual - // error boundary text that OmniRoute renders on unrecoverable errors - // (src/app/error.tsx heading: "Internal Server Error"). - const pageContent = await page.content(); - expect(pageContent).not.toContain("Internal Server Error"); + // + // Assert on RENDERED TEXT, not on page.content(). The raw HTML always contains + // the string, on every route, so the old assertion could never pass: layout.tsx + // hands the whole message catalogue to NextIntlClientProvider, React serialises + // that prop into the RSC payload, and en.json carries "Internal Server Error" + // twice (publicSystem.error.title and errors.500.title). Probing /dashboard, + // /dashboard/costs, /dashboard/settings and even /login all showed the string + // present in the source with the page rendering perfectly. + // + // This is the same trap that killed the sibling `not.toContain("500")` here in + // fc77100c3f ("Checking for '500' in raw HTML is unreliable") — that one was + // removed, this one was kept, and it has the identical flaw. + // + // The error boundary renders the title as visible text (src/app/error.tsx + // `

{t("error.title")}

`), so innerText still catches the real defect + // while ignoring the serialised dictionary. + const bodyText = await page.locator("body").innerText(); + expect(bodyText, `client errors:\n${pageErrors.join("\n---\n")}`).not.toContain( + "Internal Server Error" + ); }); }); diff --git a/tests/integration/chat-pipeline.test.ts b/tests/integration/chat-pipeline.test.ts index 382dd00b6b..c17ce8eb6e 100644 --- a/tests/integration/chat-pipeline.test.ts +++ b/tests/integration/chat-pipeline.test.ts @@ -19,6 +19,7 @@ const { getLatestCallLog, getResponsesCallLogs } = await import("./_chatPipeline const { invalidateMemorySettingsCache } = await import("../../src/lib/memory/settings.ts"); const { skillRegistry } = await import("../../src/lib/skills/registry.ts"); const { skillExecutor } = await import("../../src/lib/skills/executor.ts"); +const { encodeSkillToolName } = await import("../../src/lib/skills/injection.ts"); const { handleChat } = await import("../../src/sse/handlers/chat.ts"); const { initTranslators } = await import("../../open-sse/translator/index.ts"); const { clearInflight } = await import("../../open-sse/services/requestDedup.ts"); @@ -726,7 +727,7 @@ test("chat pipeline applies Codex CLI fingerprint to OAuth responses requests", ); }); -test("chat pipeline strips previous_response_id from stateless Codex responses by default", async () => { +test("chat pipeline fails closed on an unresolvable previous_response_id and keeps stateless Codex responses stateless", async () => { await seedConnection("codex", { apiKey: "sk-codex-stateless-responses", providerSpecificData: { openaiStoreEnabled: false }, @@ -760,9 +761,38 @@ test("chat pipeline strips previous_response_id from stateless Codex responses b }) ); - await response.json(); + // #10262 virtualized `previous_response_id`: in any mode other than "preserve" + // the id is resolved against OmniRoute's own continuation store BEFORE routing. + // An id it cannot resolve fails closed with OpenAI's own contract instead of + // being silently stripped and forwarded as a fresh turn (which would have + // dropped the conversation history without telling the client). + const failClosed = (await response.json()) as { error?: { code?: string } }; + assert.equal(response.status, 400); + assert.equal(failClosed.error?.code, "previous_response_not_found"); + assert.equal(fetchCalls.length, 0, "a request that fails closed must not reach the upstream"); - assert.equal(response.status, 200); + // Positive anchor: the same stateless Codex connection, without the unresolvable + // continuation id, still dispatches — and the stateless contract still holds + // (store:false, no previous_response_id on the wire). + const followUp = await handleChat( + buildRequest({ + url: "http://localhost/v1/responses", + body: { + model: "codex/gpt-5.5", + stream: false, + input: [ + { + type: "message", + role: "user", + content: [{ type: "input_text", text: "First VS Code turn" }], + }, + ], + }, + }) + ); + await followUp.json(); + + assert.equal(followUp.status, 200); assert.equal(fetchCalls.length, 1); assert.match(fetchCalls[0].url, /\/responses$/); assert.equal(fetchCalls[0].body.previous_response_id, undefined); @@ -1440,13 +1470,23 @@ test("chat pipeline injects skills into tools and intercepts tool calls with ski enabled: true, }); + // #9058: provider tool names must match ^[a-zA-Z0-9_-]+$, so `name@version` + // identifiers travel base64url-encoded. Derive the expectation from the helper + // instead of pinning the encoded literal. + const expectedSkillToolName = encodeSkillToolName("lookupWeather", "1.0.0"); + assert.match(expectedSkillToolName, /^[a-zA-Z0-9_-]+$/); + assert.notEqual(expectedSkillToolName, "lookupWeather@1.0.0"); + const fetchCalls = []; globalThis.fetch = async (url, init: RequestInit = {}) => { fetchCalls.push({ url: String(url), body: init.body ? JSON.parse(String(init.body)) : null, }); - return buildOpenAIToolCallResponse(); + // #9058: the upstream echoes back exactly the tool name it was given — the + // provider-safe encoded one — so this also exercises decodeSkillToolName() + // on the interception path. + return buildOpenAIToolCallResponse({ toolName: expectedSkillToolName }); }; const response = await handleChat( @@ -1464,7 +1504,7 @@ test("chat pipeline injects skills into tools and intercepts tool calls with ski assert.equal(response.status, 200); assert.equal(fetchCalls.length, 1); assert.ok(Array.isArray(fetchCalls[0].body.tools)); - assert.equal(fetchCalls[0].body.tools[0].function.name, "lookupWeather@1.0.0"); + assert.equal(fetchCalls[0].body.tools[0].function.name, expectedSkillToolName); assert.equal(json.choices[0].finish_reason, "tool_calls"); assert.equal(json.tool_results[0].tool_call_id, "call_weather"); assert.equal(JSON.parse(json.tool_results[0].output).forecast, "Sunny in Sao Paulo"); diff --git a/tests/integration/monitoring-health-cache.test.ts b/tests/integration/monitoring-health-cache.test.ts index 3c7a66b397..3df1d63b10 100644 --- a/tests/integration/monitoring-health-cache.test.ts +++ b/tests/integration/monitoring-health-cache.test.ts @@ -22,8 +22,25 @@ process.env.JWT_SECRET = "test-health-cache-secret"; await import("../../src/lib/db/core.ts"); const { GET, DELETE } = await import("../../src/app/api/monitoring/health/route.ts"); +// GHSA-mvf8-qc78-5mxm: the detailed health payload (the one carrying `timestamp`) +// is reserved for a management principal — GET now takes the Request and an +// anonymous caller only gets the liveness verdict. Every probe below therefore +// authenticates with a dashboard-session cookie, exactly like the DELETE probe. +const { SignJWT } = await import("jose"); +const AUTH_TOKEN = await new SignJWT({ authenticated: true }) + .setProtectedHeader({ alg: "HS256" }) + .setExpirationTime("30d") + .sign(new TextEncoder().encode(process.env.JWT_SECRET as string)); + +function authedRequest(method = "GET"): Request { + return new Request("http://localhost/api/monitoring/health", { + method, + headers: { cookie: `auth_token=${AUTH_TOKEN}` }, + }); +} + async function healthTimestamp(): Promise { - const res = await GET(); + const res = await GET(authedRequest()); const body = (await res.json()) as { timestamp?: string; adaptiveAdmission?: unknown; @@ -48,19 +65,8 @@ test("cache expires after the TTL — a fresh payload is built", async () => { }); test("DELETE (circuit-breaker reset) invalidates the cache immediately", async () => { - const { SignJWT } = await import("jose"); - const authToken = await new SignJWT({ authenticated: true }) - .setProtectedHeader({ alg: "HS256" }) - .setExpirationTime("30d") - .sign(new TextEncoder().encode(process.env.JWT_SECRET as string)); - const t1 = await healthTimestamp(); // populate cache - const delRes = await DELETE( - new Request("http://localhost/api/monitoring/health", { - method: "DELETE", - headers: { cookie: `auth_token=${authToken}` }, - }) - ); + const delRes = await DELETE(authedRequest("DELETE")); assert.ok(delRes.status < 400, `DELETE should succeed, got ${delRes.status}`); await new Promise((r) => setTimeout(r, 5)); // ensure the clock advances past ms precision const t2 = await healthTimestamp(); diff --git a/tests/integration/proxy-registry-flow.test.ts b/tests/integration/proxy-registry-flow.test.ts index 809d9b3466..4de8f0b1a2 100644 --- a/tests/integration/proxy-registry-flow.test.ts +++ b/tests/integration/proxy-registry-flow.test.ts @@ -176,6 +176,11 @@ test("integration: proxy registry full flow works and enforces safe delete", asy provider: "openai", }); + // #11182 made SQLite persistence a background batch (1s timer / 100-entry + // threshold); the health aggregate reads the table, so drain the queue first + // instead of racing the timer. + proxyLogger.flushProxyLogsSync(); + const healthRes = await proxyHealthRoute.GET( new Request("http://localhost/api/settings/proxies/health?hours=24") ); diff --git a/tests/integration/reasoning-routing-pipeline.test.ts b/tests/integration/reasoning-routing-pipeline.test.ts index 6d0a9b2bac..6343da23df 100644 --- a/tests/integration/reasoning-routing-pipeline.test.ts +++ b/tests/integration/reasoning-routing-pipeline.test.ts @@ -3,6 +3,10 @@ import assert from "node:assert/strict"; import { createChatPipelineHarness } from "./_chatPipelineHarness.ts"; const harness = await createChatPipelineHarness("reasoning-routing-pipeline"); +// Imported only AFTER the harness has set DATA_DIR and opened the DB: a static +// import is evaluated before any module body runs, and this module touches the +// settings/DB layer at load time. +const { getResolvedModelCapabilities } = await import("../../src/lib/modelCapabilities.ts"); const { BaseExecutor, buildOpenAIResponse, @@ -346,10 +350,20 @@ test("reasoning routing filters incompatible combo targets and rejects an empty await reasoningRulesDb.deleteReasoningRoutingRule( (await reasoningRulesDb.getReasoningRoutingRules())[0].id ); + // The target must be a model that DECLARES it cannot think (supportsThinking: + // false). `unknown` capability is deliberately kept by + // filterComboForReasoningDecision, so a model whose thinking support later + // becomes true/unknown silently stops exercising this path — assert the premise. + const INCOMPATIBLE_TARGET = "openai/gpt-4o"; + assert.equal( + getResolvedModelCapabilities(INCOMPATIBLE_TARGET).supportsThinking, + false, + `${INCOMPATIBLE_TARGET} must declare supportsThinking:false for this test to mean anything` + ); const incompatibleCombo = await combosDb.createCombo({ name: "incompatible-reasoning-combo", strategy: "priority", - models: ["antigravity/gemini-3-pro"], + models: [INCOMPATIBLE_TARGET], }); await reasoningRulesDb.createReasoningRoutingRule({ name: "Empty combo target", diff --git a/tests/integration/resilience-http-e2e.test.ts b/tests/integration/resilience-http-e2e.test.ts index 5ff70a2098..6f94fc02f6 100644 --- a/tests/integration/resilience-http-e2e.test.ts +++ b/tests/integration/resilience-http-e2e.test.ts @@ -555,12 +555,15 @@ test("resilience API only exposes configuration, not runtime breaker state", asy const { response, json } = await getJson(`${app.baseUrl}/api/resilience`); assert.equal(response.status, 200); + // Exact key set — this is the whole point of the test: configuration only. + // `providerQuotaOverrides` joined the projection in #9871. assert.deepEqual(Object.keys(json).sort(), [ "comboCooldownWait", "connectionCooldown", "legacy", "providerBreaker", "providerCooldown", + "providerQuotaOverrides", "quotaShareConcurrencyLimit", "requestQueue", "waitForCooldown", diff --git a/tests/integration/security-hardening.test.ts b/tests/integration/security-hardening.test.ts index 479f3ed59e..17370fbaeb 100644 --- a/tests/integration/security-hardening.test.ts +++ b/tests/integration/security-hardening.test.ts @@ -303,8 +303,40 @@ test("OAuth routes that can create provider connections require auth guard", () for (const relPath of targets) { const content = readIfExists(relPath); assert.ok(content, `${relPath} should exist`); - assert.ok(content.includes("isAuthRequired"), `${relPath} should check whether auth is active`); - assert.ok(content.includes("isAuthenticated"), `${relPath} should require authenticated users`); - assert.ok(content.includes("Unauthorized"), `${relPath} should reject anonymous requests`); + + // Two accepted guard shapes. GHSA-mg76 moved the cursor/kiro *import* routes + // onto requireManagementAuth, which is strictly STRONGER than the legacy + // pair: it demands a management principal (dashboard session, manage-scoped + // key, CLI token) instead of merely "any authenticated caller", and answers + // 401/403 itself — so the literal "Unauthorized" no longer appears in the + // route file. The remaining routes still carry the legacy triple. + const usesManagementGuard = content.includes("requireManagementAuth(request"); + const usesLegacyGuard = + content.includes("isAuthRequired") && + content.includes("isAuthenticated") && + content.includes("Unauthorized"); + assert.ok( + usesManagementGuard || usesLegacyGuard, + `${relPath} must guard connection-creating handlers with requireManagementAuth or the isAuthRequired/isAuthenticated pair` + ); + + // Positive anchor: a guard somewhere in the file proves nothing if one of the + // exported handlers skips it. Slice the file per exported handler and require + // EACH body to await a guard on its own `request` — a guard living only in a + // helper (or in a sibling handler) no longer satisfies this. + const handlerSlices = content + .split(/(?=export\s+async\s+function\s+(?:GET|POST|PUT|PATCH|DELETE)\b)/) + .filter((slice) => + /^export\s+async\s+function\s+(?:GET|POST|PUT|PATCH|DELETE)\b/.test(slice) + ); + assert.ok(handlerSlices.length > 0, `${relPath} should export at least one HTTP handler`); + for (const slice of handlerSlices) { + const verb = /export\s+async\s+function\s+(\w+)/.exec(slice)?.[1]; + assert.match( + slice, + /await\s+(?:require\w*Auth|isAuthRequired)\s*\(\s*(?:request|req)\b/, + `${relPath}: exported handler ${verb} does not await an auth guard on its own request` + ); + } } }); diff --git a/tests/integration/sse-correctness.test.ts b/tests/integration/sse-correctness.test.ts index d5874861ca..b901df3aaf 100644 --- a/tests/integration/sse-correctness.test.ts +++ b/tests/integration/sse-correctness.test.ts @@ -76,17 +76,43 @@ test("2. client cancel propagates to upstream (abort propagation)", async () => test("3. no leaked idle timers across N sequential streams", async () => { // createSSEStream installs a setInterval idle watchdog per stream. // If cleanup (clearInterval) does not run on stream close, timers accumulate. - // This test creates 10 streams and drains them; it acts as a smoke test that - // the process does not hang (a leaked setInterval that fires 10s later would - // prevent the test process from exiting cleanly in --test-force-exit mode). - for (let i = 0; i < 10; i++) { + // Each stream carries a real content delta: a stream whose upstream forwards + // no valuable chunk is rejected by the empty-content guard + // (open-sse/utils/streamEmptyChoices.ts) and would never reach the flush path + // whose cleanup this test is about. + // + // Drained inline, without drain()'s timeout guard: that guard leaves its own + // uncleared setTimeout behind and would drown out the very signal measured here. + const activeTimers = () => + process.getActiveResourcesInfo().filter((resource) => resource === "Timeout").length; + + const timersBefore = activeTimers(); + const N = 10; + for (let i = 0; i < N; i++) { const { up, out } = makeStream(); + up.push(`data: {"choices":[{"delta":{"content":"chunk-${i}"}}]}\n\n`); up.push("data: [DONE]\n\n"); up.close(); - await drain(out); + + const reader = out.getReader(); + const decoder = new TextDecoder(); + let text = ""; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + text += decoder.decode(value); + } + // Positive anchor: the stream really ran and really closed. + assert.ok(text.includes(`chunk-${i}`), `stream ${i} lost its content: ${JSON.stringify(text)}`); } - // If we reach here without a timeout, no blocking resources were leaked. - assert.ok(true, "all 10 streams completed without hanging"); + + // The watchdog of every closed stream must have been cleared. One slot of slack + // absorbs unrelated runtime timers, but N leaked watchdogs cannot hide in it. + const timersAfter = activeTimers(); + assert.ok( + timersAfter <= timersBefore + 1, + `idle watchdog timers leaked across ${N} streams: ${timersBefore} active before, ${timersAfter} after` + ); }); test("4. final snapshot does not duplicate tail text", async () => { diff --git a/tests/unit/electron-packaging.test.ts b/tests/unit/electron-packaging.test.ts index 1e231f9aca..e08a2f9ee9 100644 --- a/tests/unit/electron-packaging.test.ts +++ b/tests/unit/electron-packaging.test.ts @@ -7,7 +7,29 @@ import { pruneElectronRuntimeDocs } from "../../scripts/build/electronRuntimeDoc const ROOT = join(import.meta.dirname, "..", ".."); -test("electron build copies the standalone runtime into resources/app exactly once", () => { +// The SECOND entry looks like a redundant duplicate of the first — it is not, and +// removing it ships a desktop app that cannot boot. +// +// electron-builder's file matcher hard-codes an exclusion of the source root's +// `node_modules` directory for extraResources/extraFiles, BEFORE any `filter` +// pattern is consulted (app-builder-lib/out/util/filter.js: `if (relative === +// "node_modules") return false`). So `{ from: ".build/electron-standalone", to: +// "app", filter: ["**/*"] }` copies server.js, server-ws.mjs and every NESTED +// node_modules, but silently drops `.build/electron-standalone/node_modules` — +// the tree that holds `next`, `better-sqlite3` and the whole server closure. +// +// Pointing a second matcher AT the node_modules directory sidesteps the check +// (its relative paths never equal "node_modules") and is the only way to get that +// tree into `resources/app/node_modules`, which main.js also puts on the server's +// NODE_PATH. +// +// Regression history: #10325 "de-duplicated" the two entries into one on +// 2026-08-16; the packaged app then died on `Cannot find module 'next'` at +// resources/app/server.js. It went unnoticed for nine days because the Electron +// Package Smoke was already red on an earlier defect (lib/loginHeaderCapture.js +// missing from build.files since #9984), so the main process never got far enough +// to spawn the server. +test("electron build copies the standalone runtime AND its root node_modules into resources/app", () => { const electronPackage = JSON.parse(readFileSync(join(ROOT, "electron", "package.json"), "utf8")); const extraResources = electronPackage.build?.extraResources; @@ -21,6 +43,11 @@ test("electron build copies the standalone runtime into resources/app exactly on { from: "../.build/electron-standalone", to: "app", + filter: ["**/*", "node_modules/**/*"], + }, + { + from: "../.build/electron-standalone/node_modules", + to: "app/node_modules", filter: ["**/*"], }, ]);