From 79c5bdf681d693add5f96b462d6053f0a6bf151b Mon Sep 17 00:00:00 2001 From: backryun Date: Sun, 23 Aug 2026 11:09:16 +0900 Subject: [PATCH] fix(release): repair v3.8.50 base-red tail after latest root lift (#10964) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged after conflict triage: the six base-red repair files (vi.json, opencode.ts JSDoc, context-manager test, the three webhook dispatcher tests, the uncloseai orphan-test rename) were already drained on the tip by today's #11130/#11157/#11160/#11113 — those hunks resolved to the tip shape. What lands is the production-fix set: GLM transport-aware Anthropic headers, Claude Code-compatible model-listing rejection, combo live-test single-probe, zero-cost Auto-Combo interval normalization, recovery-clearing union handling, LLMLingua real-path compare, macOS netstat PID discovery, AI Horde R2 strict public-host validation. Sweep of every touched test file: 243/243 green; typecheck + file-size clean. (guide-settings-route's 4 reds reproduce on the pure tip — pre-existing drift from #11079, not from here.) Thank you @backryun! --- docs/reference/ENVIRONMENT.md | 1 + .../providers/registry/uncloseai/index.ts | 1 + open-sse/executors/glm.ts | 19 +++++- .../imageGeneration/providers/aihorde.ts | 5 +- open-sse/services/autoCombo/virtualFactory.ts | 3 +- scripts/build/colocateOptionals.mjs | 6 +- src/app/(dashboard)/dashboard/combos/page.tsx | 2 +- .../[id]/components/HarImportButton.tsx | 2 +- src/app/api/providers/[id]/models/route.ts | 15 +++-- src/i18n/messages/pt-BR.json | 20 +++++- src/lib/services/portProbe.ts | 27 ++++++-- src/sse/handlers/chat.ts | 1 + src/sse/services/auth.ts | 22 +++---- tests/unit/account-rotation-lot-c.test.ts | 31 ++++++--- tests/unit/auth-clear-account-error.test.ts | 5 ++ tests/unit/capture-critical-db-state.test.ts | 2 + tests/unit/cc-compatible-provider.test.ts | 13 ++-- .../unit/cli-helper/config-generator.test.ts | 24 +++---- ...mbo-quota-exhaustion-only-fallback.test.ts | 2 +- .../combo-runtime-unit-concurrency.test.ts | 4 +- tests/unit/cursor-image-input.test.ts | 37 ++++++++++- tests/unit/glm-executor.test.ts | 16 +++-- tests/unit/guide-settings-route.test.ts | 10 ++- ...ard-session-lease-bypass-inventory.test.ts | 2 +- .../unit/mitm-cert-install-mode-9442.test.ts | 7 +- ...outer-free-model-credits-exhausted.test.ts | 14 ++-- .../provider-sweep-live-discovery.test.ts | 12 ++-- tests/unit/readyz-route.test.ts | 4 +- tests/unit/rejected-request-usage.test.ts | 43 +++++++++---- tests/unit/services/ServiceSupervisor.test.ts | 3 + tests/unit/services/portProbePid.test.ts | 6 ++ .../unit/sse-auth-codex-account-pool.test.ts | 4 +- tests/unit/systemd-notify.test.mjs | 12 ++-- tests/unit/terminal-status-origin.test.ts | 64 ++++++++++++++++--- tests/unit/vision-bridge-maxchars.test.ts | 9 +++ 35 files changed, 328 insertions(+), 120 deletions(-) diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index ae06e62090..068c2b0206 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -772,6 +772,7 @@ REQUEST_TIMEOUT_MS (global override) | `KIMI_WEB_BASE_URL` | `https://www.kimi.ai` | Base URL for the Kimi Web (international kimi.ai Connect-RPC) executor (`kimi-web.ts`); override only for mirror/proxy endpoints. | | `KIMI_WEB_CHAT_URL` | `/apiv2/kimi.gateway.chat.v1.ChatService/Chat` | Full chat endpoint for the Kimi Web executor (`kimi-web.ts`). | | `OMNIROUTE_LOGIN_BROWSER_PATH` | _(auto-detected)_ | Path to a system Chrome/Edge executable for the Adobe Firefly interactive browser sign-in (`adobeFireflyBrowserLogin.ts`); overrides per-OS auto-detection. | +| `OMNIROUTE_STANDALONE_DIR` | _.build/ standalone output_ | Build-time override for the standalone output directory consumed by the post-build colocation step (`scripts/build/colocate-standalone.mjs`); build tooling, not runtime. | Combo target attempts inherit the resolved upstream request timeout (`FETCH_TIMEOUT_MS`, or `REQUEST_TIMEOUT_MS` when it supplies the fetch default). Set `targetTimeoutMs` in a combo, diff --git a/open-sse/config/providers/registry/uncloseai/index.ts b/open-sse/config/providers/registry/uncloseai/index.ts index baea064e3c..2a7b59f586 100644 --- a/open-sse/config/providers/registry/uncloseai/index.ts +++ b/open-sse/config/providers/registry/uncloseai/index.ts @@ -6,6 +6,7 @@ export const uncloseaiProvider: RegistryEntry = { format: "openai", executor: "default", baseUrl: "https://hermes.ai.unturf.com/v1/chat/completions", + modelsUrl: "https://hermes.ai.unturf.com/v1/models", authType: "optional", authHeader: "bearer", models: [ diff --git a/open-sse/executors/glm.ts b/open-sse/executors/glm.ts index 59e1871907..eda8296255 100644 --- a/open-sse/executors/glm.ts +++ b/open-sse/executors/glm.ts @@ -399,7 +399,24 @@ export class GlmExecutor extends DefaultExecutor { ): Promise { const credentials = input.credentials; const url = buildGlmChatUrl(credentials?.providerSpecificData, transport, this.config.baseUrl); - const headers = this.buildHeaders(credentials, input.stream, input.clientHeaders, input.model); + // #10798 moved the transport out of buildHeaders' signature; the Anthropic + // transport must therefore be visible to buildHeaders through + // providerSpecificData (primaryTransport / anthropic-shaped baseUrl). + const headers = + transport === "anthropic" + ? this.buildHeaders( + { + ...credentials, + providerSpecificData: { + ...credentials?.providerSpecificData, + primaryTransport: "anthropic", + }, + }, + input.stream, + input.clientHeaders, + input.model + ) + : this.buildHeaders(credentials, input.stream, input.clientHeaders, input.model); applyConfiguredUserAgent(headers, credentials.providerSpecificData); mergeUpstreamExtraHeaders(headers, input.upstreamExtraHeaders); diff --git a/open-sse/handlers/imageGeneration/providers/aihorde.ts b/open-sse/handlers/imageGeneration/providers/aihorde.ts index 4d39270c03..13fa50f5ab 100644 --- a/open-sse/handlers/imageGeneration/providers/aihorde.ts +++ b/open-sse/handlers/imageGeneration/providers/aihorde.ts @@ -103,11 +103,12 @@ async function fetchHordeImageBytes( if (value.startsWith("http://") || value.startsWith("https://")) { // Horde's response supplies this URL (a signed R2 storage link), not a // fixed OmniRoute-controlled host — route it through the repository's - // established bounded remote-image fetch (SSRF host guard + DNS-rebinding - // pin, streaming byte cap, redirect limit, abort-aware timeout) instead of + // established bounded remote-image fetch (strict public-host validation, + // streaming byte cap, redirect limit, abort-aware timeout) instead of // a bare fetch(). Same helper `imageGeneration.ts` already uses for other // providers' remote image URLs. const remote = await fetchRemoteImage(value, { + guard: "public-only", timeoutMs: options.timeoutMs, signal: options.signal ?? undefined, maxBytes: MAX_HORDE_IMAGE_BYTES, diff --git a/open-sse/services/autoCombo/virtualFactory.ts b/open-sse/services/autoCombo/virtualFactory.ts index 28071144fb..93d4a0b53e 100644 --- a/open-sse/services/autoCombo/virtualFactory.ts +++ b/open-sse/services/autoCombo/virtualFactory.ts @@ -7,6 +7,7 @@ import { getProviderRegistry } from "./providerRegistryAccessor"; import type { ConnectionFields } from "@/lib/db/encryption"; import { NOAUTH_PROVIDERS } from "@/shared/constants/providers"; import { hasUsableWebSessionCredential } from "@/shared/providers/webSessionCredentials"; +import { toNumber } from "@/shared/utils/numeric"; import { defaultLogger as log } from "@omniroute/open-sse/utils/logger"; import { getTokenLimit } from "../contextManager"; import { @@ -607,7 +608,7 @@ export async function prepareVirtualAutoComboInputs( // remaining allowance as a percentage, and a raw ">0" comparison would // let a reading of e.g. 0.3% (rounding noise, not real headroom) pass. minRemainingAllowance: 1, - maxStateAgeMs: (Number(settings.autoRefreshProviderQuotaInterval) || 180) * 1000, + maxStateAgeMs: toNumber(settings.autoRefreshProviderQuotaInterval, 180) * 1000, }); if (strictFilteredPool !== pool) pool = strictFilteredPool; diff --git a/scripts/build/colocateOptionals.mjs b/scripts/build/colocateOptionals.mjs index 0aa3f38fab..7b03a5c53f 100644 --- a/scripts/build/colocateOptionals.mjs +++ b/scripts/build/colocateOptionals.mjs @@ -47,7 +47,7 @@ * fail-open, so this never throws into the install. */ -import { cpSync, existsSync, mkdirSync, readFileSync } from "node:fs"; +import { cpSync, existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs"; import { createRequire } from "node:module"; import { dirname, join, sep } from "node:path"; @@ -119,7 +119,9 @@ function isPackageIntact(targetNodeModulesDir, name) { const resolved = probe.resolve(name); // A resolution that walked past the target into an ancestor tree does not // prove the target copy is usable. - return resolved.startsWith(targetNodeModulesDir + sep); + const realTarget = realpathSync(targetNodeModulesDir); + const realResolved = realpathSync(resolved); + return realResolved.startsWith(realTarget + sep); } catch { return false; } diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx index 7ca3af2024..04f73d54a0 100644 --- a/src/app/(dashboard)/dashboard/combos/page.tsx +++ b/src/app/(dashboard)/dashboard/combos/page.tsx @@ -533,7 +533,7 @@ function getStrategyBadgeClass(strategy) { return "bg-blue-500/15 text-blue-600 dark:text-blue-400"; } -function getI18nOrFallback(t, key, fallback, values) { +function getI18nOrFallback(t, key, fallback, values = undefined) { try { if (typeof t.has === "function" && t.has(key)) return t(key, values); } catch {} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/HarImportButton.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/HarImportButton.tsx index 0f4a3d3551..6066746a45 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/HarImportButton.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/HarImportButton.tsx @@ -68,7 +68,7 @@ export default function HarImportButton({ provider, onImport }: HarImportButtonP } const result = importer(text); - if (!result.ok) { + if (result.ok === false) { const [key, fallback] = ERROR_MESSAGE_KEYS[result.error] ?? [ "harImportErrorUnknown", "Couldn't extract a credential from that HAR file.", diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index 01f1bd7285..cecbb6776e 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -1909,12 +1909,9 @@ export async function GET( } if (isAnthropicCompatibleProvider(provider)) { - const cachedResponse = maybeReturnCachedDiscovery(); - if (cachedResponse) return cachedResponse; - - const autoFetchDisabledResponse = maybeReturnAutoFetchDisabled(); - if (autoFetchDisabledResponse) return autoFetchDisabledResponse; - + // CC providers never support models listing — this check must precede + // the cached-discovery / auto-fetch fallbacks, which would otherwise + // return a misleading 200 "no models" for a CC node (#10828 ordering). if (isClaudeCodeCompatibleProvider(provider)) { return NextResponse.json( { error: `Provider ${provider} does not support models listing` }, @@ -1922,6 +1919,12 @@ export async function GET( ); } + const cachedResponse = maybeReturnCachedDiscovery(); + if (cachedResponse) return cachedResponse; + + const autoFetchDisabledResponse = maybeReturnAutoFetchDisabled(); + if (autoFetchDisabledResponse) return autoFetchDisabledResponse; + let baseUrl = getProviderBaseUrl(connection.providerSpecificData); if (!baseUrl) { const fallback = buildDiscoveryFallbackResponse({ diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index f3e3c15d13..8df20f0f9d 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -4922,7 +4922,9 @@ "multiProvider": "Multi-Provedor", "usageTracking": "Rastreamento de Uso", "securityDesc": "Defina uma senha para proteger seu painel, ou pule por enquanto.", + "securityDescSkipWarning": "⚠️ Sem uma senha, você não poderá adicionar provedores durante a configuração. Você poderá adicioná-los depois pelo painel, após definir uma senha.", "providerDesc": "Conecte seu primeiro provedor de IA. Você pode adicionar mais depois.", + "providerRequiresPassword": "Você precisa definir uma senha primeiro para adicionar provedores. Volte à etapa de segurança e defina uma senha, ou adicione provedores depois pelo painel.", "apiKeyRequired": "Chave de API (obrigatório)", "customUrlOptional": "URL personalizada (opcional)", "testDesc": "Vamos verificar se a conexão com seu provedor funciona.", @@ -4979,9 +4981,7 @@ "skipped": "já configurado", "failed": "falhou" } - }, - "securityDescSkipWarning": "⚠️ Sem uma senha, você não poderá adicionar provedores durante a configuração. Você pode adicioná-los depois no painel após definir uma senha.", - "providerRequiresPassword": "Você precisa definir uma senha primeiro para adicionar provedores. Volte à etapa de segurança e defina uma senha, ou adicione provedores depois no painel." + } }, "providers": { "title": "Provedores", @@ -6269,6 +6269,20 @@ "webSessionGuideStep3": "Copie a credencial necessária do próprio domínio do provedor. Para cookies, copie apenas o valor do cabeçalho Cookie e omita Cookie:.", "webSessionGuideStep3Manual": "Caminho manual: abra as ferramentas do desenvolvedor do navegador (F12 → Network), atualize a página, abra uma requisição autenticada e copie o valor do cabeçalho Cookie em Request Headers — omita o prefixo Cookie:.", "webSessionGuideStep4": "Cole aqui e verifique a conexão. Se parar de funcionar, faça login novamente e substitua-o por um novo valor.", + "harImportButtonLabel": "Importar arquivo .har", + "harImportButtonBusy": "Importando…", + "harImportButtonHint": "Exporte pela aba Rede das Ferramentas do Desenvolvedor após enviar pelo menos uma mensagem no chat.", + "harImportStatusValid": "Importado — válido por cerca de {minutes} min.", + "harImportStatusExpiringSoon": "Importado — válido por apenas mais cerca de {minutes} min.", + "harImportStatusExpired": "Importado, mas este token expirou há {minutes} min — exporte um HAR novo.", + "harImportStatusUnknownExpiry": "Importado. Não foi possível ler a expiração.", + "harImportErrorNotJson": "Esse arquivo não é um JSON válido — ele é realmente uma exportação .har?", + "harImportErrorNoEntries": "Este HAR não contém entradas de rede.", + "harImportErrorNoChathubUrl": "Nenhuma conexão de chat do Copilot foi encontrada neste HAR. Envie pelo menos uma mensagem em m365.cloud.microsoft antes de exportar.", + "harImportErrorUnparsableUrl": "A conexão de chat foi encontrada, mas não foi possível ler a URL.", + "harImportErrorMissingFields": "A conexão de chat foi encontrada, mas o token estava ausente.", + "harImportErrorReadFailed": "Não foi possível ler esse arquivo.", + "harImportErrorUnknown": "Não foi possível extrair uma credencial desse arquivo HAR.", "webSessionSecurityHint": "Trate isso como uma senha: ela poderá acessar sua conta da web conectada até que ela expire ou seja revogada.", "webNoAuthGuideTitle": "Nenhuma credencial necessária", "webNoAuthGuideBody": "{provider} não precisa de chave de API ou cookie. Salve a conexão para usar seu endpoint web gratuito.", diff --git a/src/lib/services/portProbe.ts b/src/lib/services/portProbe.ts index 2a9fd502bd..9a890f541b 100644 --- a/src/lib/services/portProbe.ts +++ b/src/lib/services/portProbe.ts @@ -168,11 +168,24 @@ export function parseSsPid(stdout: string): number | null { export function parseNetstatPid(stdout: string, port: number): number | null { for (const line of stdout.split("\n")) { const columns = line.trim().split(/\s+/); - // proto recv-q send-q local-address foreign-address state pid/program + // Linux: proto recv-q send-q local-address foreign-address state pid/program if (columns.length < 7 || columns[5] !== "LISTEN") continue; - if (!columns[3].endsWith(`:${port}`)) continue; - const parsed = Number.parseInt(columns[6], 10); - if (Number.isFinite(parsed)) return parsed; + const linuxAddress = columns[3].endsWith(`:${port}`); + const macAddress = columns[3].endsWith(`.${port}`); + if (!linuxAddress && !macAddress) continue; + + if (linuxAddress) { + const linuxPid = Number.parseInt(columns[6], 10); + if (Number.isFinite(linuxPid)) return linuxPid; + } + + // macOS `netstat -anv -p tcp` appends a `process:pid` column after + // the socket counters. Process names may contain spaces, so scan instead + // of relying on one fixed column index. + for (const column of columns.slice(6)) { + const match = /:(\d+)$/.exec(column); + if (match) return Number.parseInt(match[1], 10); + } } return null; } @@ -197,7 +210,11 @@ const PID_PROBES: ReadonlyArray<{ args: (port) => ["-tlnp", `sport = :${port}`], parse: (stdout) => parseSsPid(stdout), }, - { command: "netstat", args: () => ["-tlnp"], parse: parseNetstatPid }, + { + command: "netstat", + args: () => (process.platform === "darwin" ? ["-anv", "-p", "tcp"] : ["-tlnp"]), + parse: parseNetstatPid, + }, ]; /** Run one probe, resolving null on a missing binary, a non-match or a timeout. */ diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 49a29c1b69..aadf7d29d3 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -2240,6 +2240,7 @@ async function handleSingleModelChat( if ( !runtimeOptions.emergencyFallbackTried && !comboName && + !forceLiveComboTest && shouldRetrySameAccountTransport({ status: result.status, errorText: errorStr, diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index b1df29552a..35304a05e6 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -3104,11 +3104,9 @@ export async function clearAccountError( } /** - * Optional CAS token. When provided, the clear is performed via an atomic - * conditional UPDATE (clearConnectionErrorIfUnchanged) that aborts if the row - * was written by a concurrent path between the caller's snapshot read and this - * clear. Closes the TOCTOU window in the quota-recovery path. When omitted, - * the clear is unconditional (preserves existing post-success-call behavior). + * Optional CAS token. When provided, clearConnectionErrorIfUnchanged atomically + * aborts if another path modified the row after the caller's snapshot. + * This closes the TOCTOU window; omission preserves unconditional clearing. */ export interface RecoveredStateExpectation { testStatus: string | null; @@ -3116,23 +3114,25 @@ export interface RecoveredStateExpectation { rateLimitedUntil: string | null; } export async function clearRecoveredProviderState( - credentials: Partial | null, + credentials: unknown, expectedState?: RecoveredStateExpectation ): Promise<{ applied: boolean }> { - if (!credentials?.connectionId) return { applied: false }; + const recoverable = credentials as Partial | null; + if (typeof recoverable?.connectionId !== "string" || !recoverable.connectionId) + return { applied: false }; if (expectedState) { - const applied = await clearConnectionErrorIfUnchanged(credentials.connectionId, expectedState); + const applied = await clearConnectionErrorIfUnchanged(recoverable.connectionId, expectedState); if (!applied) { log.info( "AUTH", - `Skipped recovery clear for ${credentials.connectionId.slice(0, 8)} — state changed concurrently (CAS miss)` + `Skipped recovery clear for ${recoverable.connectionId.slice(0, 8)} — state changed concurrently (CAS miss)` ); return { applied: false }; } - log.info("AUTH", `Account ${credentials.connectionId.slice(0, 8)} error cleared (CAS)`); + log.info("AUTH", `Account ${recoverable.connectionId.slice(0, 8)} error cleared (CAS)`); return { applied: true }; } - await clearAccountError(credentials.connectionId, credentials); + await clearAccountError(recoverable.connectionId, recoverable); return { applied: true }; } type AuthRequestLike = { diff --git a/tests/unit/account-rotation-lot-c.test.ts b/tests/unit/account-rotation-lot-c.test.ts index 0dde306f90..6ae41c42ed 100644 --- a/tests/unit/account-rotation-lot-c.test.ts +++ b/tests/unit/account-rotation-lot-c.test.ts @@ -1,6 +1,11 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { pickAccount, markCooldown, markSuccess, isAccountReady } from "../../open-sse/executors/accountRotation.ts"; +import { + pickAccount, + markCooldown, + markSuccess, + isAccountReady, +} from "../../open-sse/executors/accountRotation.ts"; import type { RotatableAccount } from "../../open-sse/executors/accountRotation.ts"; function acct(fp: string, proxy: RotatableAccount["proxy"] = null): RotatableAccount { @@ -11,7 +16,7 @@ test("markCooldown default is transient — no eviction, only backoff", () => { const a = acct("a"); markCooldown(a); // kind omitted → transient assert.ok(a.cooldownUntil > Date.now()); - assert.equal((a as Record).evictedAt, undefined); + assert.equal(a.evictedAt, undefined); // still picked when others are ready const state = { nextAccountIdx: 0 }; const picked = pickAccount([a, acct("b")], state); @@ -25,28 +30,34 @@ test("terminal kind evicts after threshold, pickAccount skips evicted unless all markCooldown(a, "terminal"); markCooldown(a, "terminal"); markCooldown(a, "terminal"); - assert.ok((a as Record).evictedAt != null); + assert.ok(a.evictedAt != null); const state = { nextAccountIdx: 0 }; // b is ready, a evicted → b is picked - const picked = pickAccount([a, b], state, (x) => isAccountReady(x) && !(x as Record).evictedAt); + const picked = pickAccount([a, b], state, (x) => isAccountReady(x) && !x.evictedAt); assert.equal(picked.fingerprint, "healthy"); // when all evicted, caller still gets an account rather than hanging (preserves :52-58) - (b as Record).evictedAt = Date.now(); - const fallback = pickAccount([a, b], { nextAccountIdx: 0 }, (x) => isAccountReady(x) && !(x as Record).evictedAt); + b.evictedAt = Date.now(); + const fallback = pickAccount( + [a, b], + { nextAccountIdx: 0 }, + (x) => isAccountReady(x) && !x.evictedAt + ); assert.ok(fallback.fingerprint === "dead" || fallback.fingerprint === "healthy"); }); test("transient does not evict even after many fails — only terminal does", () => { const a = acct("quota-hit"); for (let i = 0; i < 10; i++) markCooldown(a, "transient"); - assert.equal((a as Record).evictedAt, undefined); + assert.equal(a.evictedAt, undefined); }); test("markSuccess clears eviction and consecutiveFails", () => { const a = acct("revived"); - markCooldown(a, "terminal"); markCooldown(a, "terminal"); markCooldown(a, "terminal"); + markCooldown(a, "terminal"); + markCooldown(a, "terminal"); + markCooldown(a, "terminal"); markSuccess(a); - assert.equal((a as Record).evictedAt, null); + assert.equal(a.evictedAt, null); assert.equal(a.consecutiveFails, 0); }); @@ -57,5 +68,5 @@ test("cross-executor alias still works — opencode wrapper forwards kind", asyn assert.ok(mc.length >= 1 && mc.length <= 2); // Prove it accepts terminal without throw const tmp = acct("probe"); - assert.doesNotThrow(() => (mc as Record)(tmp, "terminal")); + assert.doesNotThrow(() => mc(tmp, "terminal")); }); diff --git a/tests/unit/auth-clear-account-error.test.ts b/tests/unit/auth-clear-account-error.test.ts index 1131ae6370..a303da4e7e 100644 --- a/tests/unit/auth-clear-account-error.test.ts +++ b/tests/unit/auth-clear-account-error.test.ts @@ -104,6 +104,11 @@ test("clearRecoveredProviderState ignores empty payloads and clears recoverable await auth.clearRecoveredProviderState(null); await auth.clearRecoveredProviderState({}); + await auth.clearRecoveredProviderState({ + allExpired: true, + expiredCount: 1, + expiredStatus: "expired", + }); await auth.clearRecoveredProviderState({ connectionId: created.id, testStatus: "unavailable", diff --git a/tests/unit/capture-critical-db-state.test.ts b/tests/unit/capture-critical-db-state.test.ts index fcf2a017b7..957c1afce3 100644 --- a/tests/unit/capture-critical-db-state.test.ts +++ b/tests/unit/capture-critical-db-state.test.ts @@ -4,6 +4,8 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +type CoreModule = typeof import("../../src/lib/db/core.ts"); + // Shared across all tests — the module caches DATA_DIR / SQLITE_FILE at load time, // so we must create the temp dir and import exactly once. type CoreModule = typeof import("../../src/lib/db/core.ts"); diff --git a/tests/unit/cc-compatible-provider.test.ts b/tests/unit/cc-compatible-provider.test.ts index 9784382ca3..cbb16a2819 100644 --- a/tests/unit/cc-compatible-provider.test.ts +++ b/tests/unit/cc-compatible-provider.test.ts @@ -1210,13 +1210,8 @@ test("provider models route reports CC compatible providers do not support model { params: { id: connection.id } } ); - assert.ok( - response.status === 400 || response.status === 200, - `CC-compatible models route should 400 (unsupported) or 200 (listed), got ${response.status}` - ); - if (response.status === 400) { - assert.deepEqual(await response.json(), { - error: "Provider anthropic-compatible-cc-test does not support models listing", - }); - } + assert.equal(response.status, 400); + assert.deepEqual(await response.json(), { + error: "Provider anthropic-compatible-cc-test does not support models listing", + }); }); diff --git a/tests/unit/cli-helper/config-generator.test.ts b/tests/unit/cli-helper/config-generator.test.ts index 1b2e85732f..cfe3f39809 100644 --- a/tests/unit/cli-helper/config-generator.test.ts +++ b/tests/unit/cli-helper/config-generator.test.ts @@ -414,7 +414,7 @@ describe("config-generator", () => { } }); - it("does NOT fabricate a default context when the catalog has no entry", async () => { + it("uses the required 128K context fallback when the catalog has no entry", async () => { const stub = stubFetchOnce(makeCatalogResponse(SAMPLE_CATALOG)); try { const { generateOpencodeConfig } = @@ -424,15 +424,14 @@ describe("config-generator", () => { apiKey: "sk-test", }); const cfg = JSON.parse(out); - // NO_CTX_COMBO has no context_length in the catalog — generator - // must NOT default to 128K (or any other value). The entry is - // emitted without limit.context so OpenCode's own heuristic - // applies and the user can fix the upstream. + // NO_CTX_COMBO has no context_length in the catalog. OpenCode v1 + // requires a complete limit object, so the compatibility fallback + // must be explicit rather than leaving the config invalid. const noCtx = cfg.provider.omniroute.models["NO_CTX_COMBO"]; assert.strictEqual( noCtx.limit?.context, - undefined, - `NO_CTX_COMBO should not have a fabricated limit.context (got ${noCtx.limit?.context})` + 128_000, + `NO_CTX_COMBO should use the 128K fallback (got ${noCtx.limit?.context})` ); } finally { stub.restore(); @@ -603,11 +602,12 @@ describe("config-generator", () => { input: 100000, output: 32768, }); - // #10940: `limit.output` is REQUIRED by OpenCode's v1 provider schema, - // so even a model with zero catalog metadata still gets a `limit` - // block carrying the fallback output value; `context`/`input` stay - // omitted since neither the catalog nor the user knows them. - assert.deepStrictEqual(models["no-metadata"].limit, { output: 8192 }); + // #10940/#11035: OpenCode's v1 provider schema requires both fields, + // so a model with zero metadata gets the compatibility fallbacks. + assert.deepStrictEqual(models["no-metadata"].limit, { + context: 128_000, + output: 8192, + }); for (const model of Object.values(models) as Array<{ limit?: { output?: number } }>) { assert.ok( diff --git a/tests/unit/combo-quota-exhaustion-only-fallback.test.ts b/tests/unit/combo-quota-exhaustion-only-fallback.test.ts index 2a90d7e3fb..cbb4f70c11 100644 --- a/tests/unit/combo-quota-exhaustion-only-fallback.test.ts +++ b/tests/unit/combo-quota-exhaustion-only-fallback.test.ts @@ -110,7 +110,7 @@ async function run( } test("quota classifier rejects terminal-looking evidence on ineligible statuses", async () => { - for (const status of [400, 401, 403, 404, 408, 409, 422, 500, 502, 503, 504]) { + for (const status of [400, 401, 404, 408, 409, 422, 500, 502, 503, 504]) { for (const terminal of ["insufficient_quota", "quota_exhausted", "credits_exhausted"]) { assert.equal( await isQuotaExhaustionResponse( diff --git a/tests/unit/combo-runtime-unit-concurrency.test.ts b/tests/unit/combo-runtime-unit-concurrency.test.ts index 36a14a59ce..8ccebe4603 100644 --- a/tests/unit/combo-runtime-unit-concurrency.test.ts +++ b/tests/unit/combo-runtime-unit-concurrency.test.ts @@ -28,8 +28,8 @@ const databases = db.pragma("database_list") as Array<{ file?: string; name?: st const activeDbPath = databases.find((database) => database.name === "main")?.file; assert.ok(activeDbPath, "test requires a file-backed main SQLite database"); assert.equal( - path.dirname(path.resolve(activeDbPath)), - path.resolve(TEST_DATA_DIR), + fs.realpathSync(path.dirname(path.resolve(activeDbPath))), + fs.realpathSync(path.resolve(TEST_DATA_DIR)), `active test database must be under TEST_DATA_DIR before inserts: ${activeDbPath}` ); diff --git a/tests/unit/cursor-image-input.test.ts b/tests/unit/cursor-image-input.test.ts index 16bffcb6b3..abc14d7d98 100644 --- a/tests/unit/cursor-image-input.test.ts +++ b/tests/unit/cursor-image-input.test.ts @@ -1,4 +1,4 @@ -import test from "node:test"; +import test, { type TestContext } from "node:test"; import assert from "node:assert/strict"; import crypto from "node:crypto"; import dns from "node:dns"; @@ -473,7 +473,37 @@ test("resolveCursorImages soft-caps a large PNG under the wire budget", async () // ─── Executor-level error body (response path, hard rule #12) ─────────────── -test("executor returns a sanitized 400 for an oversized image", async () => { +// #10804 moved agent-endpoint discovery (a live api2.cursor.sh call) ahead of +// request building inside CursorExecutor.execute. These tests exercise the +// image-validation 400 path with a fake token, so stub the discovery fetch to +// return a minimal valid Connect-RPC config response instead of hitting the +// network (which would 401 before image validation ever runs). +function mockCursorServerConfig(t: TestContext): void { + t.mock.method(globalThis, "fetch", async (input, init) => { + const url = String(input); + if (!url.includes("ServerConfigService/GetServerConfig")) { + throw new Error(`unexpected fetch in test: ${url}`); + } + void init; + // Minimal protobuf matching parseCursorAgentUrls: field 27 wraps a + // sub-message holding field 1 (agentUrl) + field 2 (agentnUrl), each a + // length-delimited https://host string. validateCursorAgentUrl only + // accepts *.api5.cursor.sh hosts, so use those. + const str = (field: number, host: string): Buffer => { + const value = Buffer.from(`https://${host}`); + return Buffer.concat([Buffer.from([(field << 3) | 0x02, value.length]), value]); + }; + const inner = Buffer.concat([str(1, "us.api5.cursor.sh"), str(2, "eu.api5.cursor.sh")]); + // Field-27 tag (218) needs proper varint encoding (2 bytes). + const tag = ((27 << 3) | 0x02) as number; + const header = Buffer.from([(tag & 0x7f) | 0x80, tag >>> 7, inner.length]); + const body = Buffer.concat([header, inner]); + return new Response(body, { status: 200 }); + }); +} + +test("executor returns a sanitized 400 for an oversized image", async (t) => { + mockCursorServerConfig(t); const exec = new CursorExecutor(); const big = Buffer.alloc(MAX_CURSOR_IMAGE_DECODE_BYTES + 16).toString("base64"); const result = await exec.execute({ @@ -508,7 +538,8 @@ test("executor returns a sanitized 400 for an oversized image", async () => { assert.ok(!/\/(root|home|usr)\//.test(body.error.message), "no absolute path in error body"); }); -test("executor returns a sanitized 400 for an SSRF-blocked image URL", async () => { +test("executor returns a sanitized 400 for an SSRF-blocked image URL", async (t) => { + mockCursorServerConfig(t); const exec = new CursorExecutor(); const result = await exec.execute({ model: "gpt-5.2", diff --git a/tests/unit/glm-executor.test.ts b/tests/unit/glm-executor.test.ts index 4c1a494235..3d47b375c3 100644 --- a/tests/unit/glm-executor.test.ts +++ b/tests/unit/glm-executor.test.ts @@ -164,7 +164,12 @@ test("GlmExecutor separates OpenAI-compatible coding headers from Anthropic head const anthropicHeaders = executor.buildHeaders( { apiKey: "glm-key", - providerSpecificData: { baseUrl: "https://api.z.ai/api/anthropic/v1/messages" }, + providerSpecificData: { + baseUrl: "https://api.z.ai/api/anthropic/v1/messages", + // Same #10798 signature change — Anthropic transport via + // providerSpecificData (baseUrl is anthropic-shaped anyway). + primaryTransport: "anthropic", + }, }, true, null, @@ -191,6 +196,8 @@ test("GlmExecutor preserves extra API key rotation", () => { connectionId: "glm-rotation-test", providerSpecificData: { baseUrl: "https://api.z.ai/api/anthropic/v1/messages", + // #10798 signature change — Anthropic transport via providerSpecificData. + primaryTransport: "anthropic", extraApiKeys: ["extra-key"], }, }, @@ -426,10 +433,9 @@ test("GlmExecutor falls back internally to Anthropic transport and returns OpenA assert.equal(calls[0].url, "https://api.z.ai/api/coding/paas/v4/chat/completions"); assert.equal(calls[0].headers.Authorization, "Bearer glm-key"); assert.equal(calls[1].url, "https://api.z.ai/api/anthropic/v1/messages?beta=true"); - const fallbackKey = - calls[1].headers["x-api-key"] || - String(calls[1].headers.Authorization || "").replace(/^Bearer\s+/i, ""); - assert.equal(fallbackKey, "glm-key"); + assert.equal(calls[1].headers["x-api-key"], "glm-key"); + assert.equal(calls[1].headers.Authorization, undefined); + assert.equal(calls[1].headers["anthropic-version"], "2023-06-01"); assert.equal(calls[1].body.messages[0].role, "user"); assert.equal(calls[1].body._disableToolPrefix, undefined); assert.equal(result.targetFormat, "openai"); diff --git a/tests/unit/guide-settings-route.test.ts b/tests/unit/guide-settings-route.test.ts index 735240dc1e..7516bd0be5 100644 --- a/tests/unit/guide-settings-route.test.ts +++ b/tests/unit/guide-settings-route.test.ts @@ -198,8 +198,14 @@ test("guide-settings POST preserves existing OpenCode config fields while only u assert.equal(content.provider.omniroute.options.baseURL, "http://my-omni/v1"); assert.ok(content.provider.omniroute.options.apiKey.startsWith("sk-")); assert.deepEqual(content.provider.omniroute.models, { - "cx/gpt-5.6-sol": { name: "GPT-5.6 Sol" }, - "opencode-go/kimi-k2.6": { name: "Kimi K2.6" }, + "cx/gpt-5.6-sol": { + name: "GPT-5.6 Sol", + limit: { context: 128_000, output: 8192 }, + }, + "opencode-go/kimi-k2.6": { + name: "Kimi K2.6", + limit: { context: 128_000, output: 8192 }, + }, }); }); diff --git a/tests/unit/hard-session-lease-bypass-inventory.test.ts b/tests/unit/hard-session-lease-bypass-inventory.test.ts index bafa297146..396c54015c 100644 --- a/tests/unit/hard-session-lease-bypass-inventory.test.ts +++ b/tests/unit/hard-session-lease-bypass-inventory.test.ts @@ -86,7 +86,7 @@ const EXPECTED: Record> = { "src/app/api/providers/client/route.ts": 1, "src/app/api/providers/free-onboarding/route.ts": 2, "src/app/api/providers/import/route.ts": 1, - "src/app/api/providers/route.ts": 4, + "src/app/api/providers/route.ts": 2, "src/app/api/providers/test-batch/route.ts": 2, "src/app/api/rate-limits/route.ts": 1, "src/app/api/services/dario/admin/import-from-omniroute/route.ts": 2, diff --git a/tests/unit/mitm-cert-install-mode-9442.test.ts b/tests/unit/mitm-cert-install-mode-9442.test.ts index 979dee7d4c..4128719c5c 100644 --- a/tests/unit/mitm-cert-install-mode-9442.test.ts +++ b/tests/unit/mitm-cert-install-mode-9442.test.ts @@ -165,11 +165,14 @@ test("filesystem proof: cp under umask 0077 creates mode 0600 (why the fix is ne const oldUmask = process.umask(0o077); try { - // Use the real `cp` (GNU coreutils) by absolute path — the exact command + // Use the real `cp` (GNU/BSD coreutils) by absolute path — the exact command // installCertLinux runs — so the umask actually applies. Node's // fs.copyFileSync preserves the source mode, which would mask the bug, and // the bare `cp` on PATH below is a logging stub from the install tests. - execFileSync("/usr/bin/cp", [src, dst]); + // macOS keeps coreutils at /bin/cp; Linux (GNU coreutils) at /usr/bin/cp. + const realCp = ["/usr/bin/cp", "/bin/cp"].find((p) => fs.existsSync(p)); + assert.ok(realCp, "a real cp binary must exist for this filesystem proof"); + execFileSync(realCp, [src, dst]); const mode = fs.statSync(dst).mode & 0o777; assert.equal(mode, 0o600, "cp under umask 0077 must produce 0600 — the bug this fix repairs"); } finally { diff --git a/tests/unit/openrouter-free-model-credits-exhausted.test.ts b/tests/unit/openrouter-free-model-credits-exhausted.test.ts index 601553b658..129d2fac6d 100644 --- a/tests/unit/openrouter-free-model-credits-exhausted.test.ts +++ b/tests/unit/openrouter-free-model-credits-exhausted.test.ts @@ -78,7 +78,11 @@ test("getProviderCredentials still refuses a PAID OpenRouter model on a credits_ "anthropic/claude-opus-4.5" ); - assert.equal(selected, null, "paid-model requests must still be blocked on the exhausted connection"); + assert.deepEqual( + selected, + { allExpired: true, expiredCount: 1, expiredStatus: "credits_exhausted" }, + "paid-model requests must still be blocked on the exhausted connection" + ); }); test("getProviderCredentials still refuses a :free OpenRouter model on a banned connection", async () => { @@ -99,9 +103,9 @@ test("getProviderCredentials still refuses a :free OpenRouter model on a banned "meta-llama/llama-3.1-8b-instruct:free" ); - assert.equal( + assert.deepEqual( selected, - null, + { allExpired: true, expiredCount: 1, expiredStatus: "banned" }, "the free-model exemption only applies to credits_exhausted, not other terminal statuses" ); }); @@ -119,9 +123,9 @@ test("getProviderCredentials still refuses a :free model on a credits_exhausted const selected = await auth.getProviderCredentials("openai", null, null, "some-model:free"); - assert.equal( + assert.deepEqual( selected, - null, + { allExpired: true, expiredCount: 1, expiredStatus: "credits_exhausted" }, "the exemption is OpenRouter-specific, since only OpenRouter uses the :free naming convention with a shared balance" ); }); diff --git a/tests/unit/provider-sweep-live-discovery.test.ts b/tests/unit/provider-sweep-live-discovery.test.ts index 323a1599f3..2431179fa7 100644 --- a/tests/unit/provider-sweep-live-discovery.test.ts +++ b/tests/unit/provider-sweep-live-discovery.test.ts @@ -48,7 +48,7 @@ interface ModelsBody { } // provider → the upstream /models URL the route resolves from its registry baseUrl. -const LIVE_CASES: Array<{ provider: string; liveUrl: string }> = [ +const LIVE_CASES: Array<{ provider: string; liveUrl: string; source?: string }> = [ { provider: "venice", liveUrl: "https://api.venice.ai/api/v1/models" }, { provider: "deepinfra", liveUrl: "https://api.deepinfra.com/v1/openai/models" }, { provider: "wandb", liveUrl: "https://api.inference.wandb.ai/v1/models" }, @@ -62,7 +62,11 @@ const LIVE_CASES: Array<{ provider: string; liveUrl: string }> = [ { provider: "ovhcloud", liveUrl: "https://oai.endpoints.kepler.ai.cloud.ovh.net/v1/models" }, { provider: "sambanova", liveUrl: "https://api.sambanova.ai/v1/models" }, { provider: "orcarouter", liveUrl: "https://api.orcarouter.ai/v1/models" }, - { provider: "uncloseai", liveUrl: "https://hermes.ai.unturf.com/v1/models" }, + { + provider: "uncloseai", + liveUrl: "https://hermes.ai.unturf.com/v1/models", + source: "upstream", + }, { provider: "opencode-go", liveUrl: "https://opencode.ai/zen/go/v1/models" }, { provider: "baseten", liveUrl: "https://inference.baseten.co/v1/models" }, { provider: "hyperbolic", liveUrl: "https://api.hyperbolic.xyz/v1/models" }, @@ -76,7 +80,7 @@ const LIVE_CASES: Array<{ provider: string; liveUrl: string }> = [ { provider: "api-airforce", liveUrl: "https://api.airforce/v1/models" }, ]; -for (const { provider, liveUrl } of LIVE_CASES) { +for (const { provider, liveUrl, source = "api" } of LIVE_CASES) { test(`sweep: ${provider} import fetches the live /models catalog`, async () => { await resetStorage(); const connection = await providersDb.createProviderConnection({ @@ -109,7 +113,7 @@ for (const { provider, liveUrl } of LIVE_CASES) { const body = (await response.json()) as ModelsBody; assert.equal(body.provider, provider); assert.ok(fetched, `should have probed ${liveUrl}`); - assert.equal(body.source, "api", "should serve the live upstream catalog, not local_catalog"); + assert.equal(body.source, source, "should serve the live upstream catalog, not local_catalog"); const ids = body.models.map((m) => m.id); assert.ok( ids.includes(`${provider}-live-a`) && ids.includes(`${provider}-live-b`), diff --git a/tests/unit/readyz-route.test.ts b/tests/unit/readyz-route.test.ts index ec9ed1cb12..ef5c0f67ee 100644 --- a/tests/unit/readyz-route.test.ts +++ b/tests/unit/readyz-route.test.ts @@ -49,9 +49,11 @@ test("/readyz is omitted from the centralized auth proxy matcher", () => { assert.equal(/["']\/healthz/.test(matcherBlock), false); }); -test("/readyz re-exports the /healthz handlers (no second lifecycle)", () => { +test("/readyz re-exports the /healthz handlers and declares its route config locally", () => { const source = fs.readFileSync("src/app/readyz/route.ts", "utf8"); assert.match(source, /from ["']\.\.\/healthz\/route["']/); + assert.match(source, /export const dynamic = ["']force-dynamic["']/); + assert.doesNotMatch(source, /export\s*\{[^}]*\bdynamic\b[^}]*\}\s*from/); assert.equal(/monitoring/i.test(source), false); assert.equal(/sqlite/i.test(source), false); }); diff --git a/tests/unit/rejected-request-usage.test.ts b/tests/unit/rejected-request-usage.test.ts index a25ada202b..300e77e013 100644 --- a/tests/unit/rejected-request-usage.test.ts +++ b/tests/unit/rejected-request-usage.test.ts @@ -60,12 +60,17 @@ test("gate-rejected request is attributed to the api key in usage_history", asyn assert.equal(keyRows.length, 1, "expected one usage_history row for the rejected request"); assert.equal(keyRows[0].success, false, "rejected request must be recorded as success:false"); - // call_logs visibility is preserved (dashboard/logs). - const logs = await callLogs.getCallLogs({}); - const rejected = (logs.logs ?? logs).filter?.( - (l: { apiKeyName?: string | null }) => l.apiKeyName === "opencode-mac" - ); - assert.ok(rejected && rejected.length >= 1, "expected a call_logs row for the rejected request"); + // call_logs visibility is preserved (dashboard/logs). saveCallLog is + // fire-and-forget inside recordRejectedRequestUsage, so poll briefly for the + // row instead of asserting synchronously after the await. + let rejected: Array<{ apiKeyName?: string | null }> = []; + for (let i = 0; i < 50 && rejected.length === 0; i++) { + const logs = await callLogs.getCallLogs({}); + const list = (logs.logs ?? logs) as Array<{ apiKeyName?: string | null }>; + rejected = (list ?? []).filter((l) => l.apiKeyName === "opencode-mac"); + if (rejected.length === 0) await new Promise((r) => setTimeout(r, 10)); + } + assert.ok(rejected.length >= 1, "expected a call_logs row for the rejected request"); }); test("combo-exhausted rejection is also counted per api key", async () => { @@ -111,10 +116,15 @@ test("combo-exhausted rejection persists the client request body for dashboard i requestBody: { model: "default", messages: [{ role: "user", content: "hello" }] }, }); - const logs = await callLogs.getCallLogs({}); - const rejected = (logs.logs ?? logs).find?.( - (l: { apiKeyName?: string | null }) => l.apiKeyName === "request-body-test" - ); + // saveCallLog is fire-and-forget — poll briefly for the row. + let rejected: { id: string; hasRequestBody: boolean } | undefined; + for (let i = 0; i < 50 && !rejected; i++) { + const logs = await callLogs.getCallLogs({}); + const list = (logs.logs ?? logs) as Array<{ apiKeyName?: string | null }>; + const found = (list ?? []).find((l) => l.apiKeyName === "request-body-test"); + if (found) rejected = found as unknown as { id: string; hasRequestBody: boolean }; + else await new Promise((r) => setTimeout(r, 10)); + } assert.ok(rejected, "expected a call_logs row for the rejected request"); assert.equal(rejected.hasRequestBody, true, "expected hasRequestBody to be true"); @@ -140,10 +150,15 @@ test("combo-exhausted rejection without a request body still logs cleanly (no re startTime: Date.now() - 100, }); - const logs = await callLogs.getCallLogs({}); - const rejected = (logs.logs ?? logs).find?.( - (l: { apiKeyName?: string | null }) => l.apiKeyName === "no-body-test" - ); + // saveCallLog is fire-and-forget — poll briefly for the row. + let rejected: { id: string } | undefined; + for (let i = 0; i < 50 && !rejected; i++) { + const logs = await callLogs.getCallLogs({}); + const list = (logs.logs ?? logs) as Array<{ apiKeyName?: string | null }>; + const found = (list ?? []).find((l) => l.apiKeyName === "no-body-test"); + if (found) rejected = found as unknown as { id: string }; + else await new Promise((r) => setTimeout(r, 10)); + } assert.ok(rejected, "expected a call_logs row even without a request body"); assert.equal(rejected.hasRequestBody, false); }); diff --git a/tests/unit/services/ServiceSupervisor.test.ts b/tests/unit/services/ServiceSupervisor.test.ts index 011395733a..6e04455e61 100644 --- a/tests/unit/services/ServiceSupervisor.test.ts +++ b/tests/unit/services/ServiceSupervisor.test.ts @@ -18,6 +18,9 @@ const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-superviso process.env.DATA_DIR = TEST_DATA_DIR; process.env.NODE_ENV = "test"; process.env.DISABLE_SQLITE_AUTO_BACKUP = "true"; +// Adoption is intentionally opt-in after GHSA-wg9p-6m2g-4v27. These tests +// exercise the explicit adoption path, so enable it for this isolated process. +process.env.OMNIROUTE_ADOPT_EXISTING_SERVICE = "1"; // Import DB core first to trigger migration (creates version_manager with new columns) const core = await import("../../../src/lib/db/core.ts"); diff --git a/tests/unit/services/portProbePid.test.ts b/tests/unit/services/portProbePid.test.ts index b88383fd80..c58bf86045 100644 --- a/tests/unit/services/portProbePid.test.ts +++ b/tests/unit/services/portProbePid.test.ts @@ -64,6 +64,12 @@ test("parseNetstatPid matches on the local address, not the foreign one", () => assert.equal(parseNetstatPid(stdout, 20128), 596922); }); +test("parseNetstatPid reads macOS process:pid output", () => { + const stdout = + "tcp4 0 0 127.0.0.1.20128 *.* LISTEN 0 0 131072 131072 node:596922 00100\n"; + assert.equal(parseNetstatPid(stdout, 20128), 596922); +}); + test("parseNetstatPid ignores non-listening rows and unknown ports", () => { const stdout = "tcp 0 0 127.0.0.1:20128 1.2.3.4:5555 ESTABLISHED 596922/node\n"; diff --git a/tests/unit/sse-auth-codex-account-pool.test.ts b/tests/unit/sse-auth-codex-account-pool.test.ts index 581efd1f33..927775d15f 100644 --- a/tests/unit/sse-auth-codex-account-pool.test.ts +++ b/tests/unit/sse-auth-codex-account-pool.test.ts @@ -242,8 +242,8 @@ test("Codex parent authentication failures block both virtual children without c const inventory = await providersDb.getProviderConnections({ provider: "codex" }); assert.equal(unavailable.shouldFallback, true); - assert.equal(spark, null); - assert.equal(normal, null); + assert.deepEqual(spark, { allExpired: true, expiredCount: 1, expiredStatus: "expired" }); + assert.deepEqual(normal, { allExpired: true, expiredCount: 1, expiredStatus: "expired" }); assert.deepEqual( inventory.map((item) => item.id), [connection.id] diff --git a/tests/unit/systemd-notify.test.mjs b/tests/unit/systemd-notify.test.mjs index cb9e441f8f..b716d57d9d 100644 --- a/tests/unit/systemd-notify.test.mjs +++ b/tests/unit/systemd-notify.test.mjs @@ -37,8 +37,10 @@ function python3Available() { } } -// Waits for the listener to emit `expected` lines (in order), then resolves -// with everything it saw. Fails loudly on timeout or premature exit. +// Waits for the listener to emit every `expected` line, then resolves with +// everything it saw. The notifier spawns one process per signal, so AF_UNIX +// datagram arrival order is not guaranteed across those processes. +// Fails loudly on timeout or premature exit. // BARRIER=1 datagrams (sd_notify synchronization emitted by the systemd-notify // CLI after every message) are noise for this contract and are skipped. function waitForLines(child, expected, timeoutMs) { @@ -58,14 +60,14 @@ function waitForLines(child, expected, timeoutMs) { buf = buf.slice(idx + 1); if (!line || line === "BARRIER=1") continue; seen.push(line); - if (seen.length === expected.length) { + if (expected.every((expectedLine) => seen.includes(expectedLine))) { clearTimeout(timer); resolve([...seen]); } } }); child.on("exit", () => { - if (seen.length < expected.length) { + if (expected.some((expectedLine) => !seen.includes(expectedLine))) { clearTimeout(timer); reject(new Error(`listener exited early; got: ${seen.join(", ")}`)); } @@ -248,7 +250,7 @@ test( notifier.watchdog(); notifier.stopping(); const received = await waitForLines(listener, ["READY=1", "WATCHDOG=1", "STOPPING=1"], 10000); - assert.deepEqual(received, ["READY=1", "WATCHDOG=1", "STOPPING=1"]); + assert.deepEqual(received.toSorted(), ["READY=1", "STOPPING=1", "WATCHDOG=1"]); notifier.dispose(); } finally { listener.kill(); diff --git a/tests/unit/terminal-status-origin.test.ts b/tests/unit/terminal-status-origin.test.ts index a4aacf767f..3a9221f2ca 100644 --- a/tests/unit/terminal-status-origin.test.ts +++ b/tests/unit/terminal-status-origin.test.ts @@ -1,6 +1,8 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; const DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-t11-")); process.env.DATA_DIR = DIR; @@ -9,15 +11,42 @@ const { createProviderConnection } = await import("../../src/lib/db/providers.ts const { runAsProbe } = await import("../../src/shared/utils/probeOrigin.ts"); const { writeTerminalStatus } = await import("../../src/shared/utils/terminalStatus.ts"); -test.after(() => { core.resetDbInstance(); fs.rmSync(DIR, {recursive:true, force:true}); }); +test.after(() => { + core.resetDbInstance(); + fs.rmSync(DIR, { recursive: true, force: true }); +}); -function row(id: string){ return (core.getDbInstance() as unknown as Record).prepare("SELECT is_active, test_status FROM provider_connections WHERE id=?").get(id); } +function row(id: string): { is_active: number; test_status: string } { + const result = core + .getDbInstance() + .prepare("SELECT is_active, test_status FROM provider_connections WHERE id=?") + .get(id); + assert.ok(result && typeof result === "object"); + return result as { is_active: number; test_status: string }; +} test("probe-origin writeTerminalStatus records error but never deactivates", async () => { - const conn = await createProviderConnection({ provider:"openai", authType:"apikey", name:"t11", apiKey:"sk-t11", isActive:true, testStatus:"active" } as unknown as Record); - const id = String((conn as unknown as Record).id); + const conn = await createProviderConnection({ + provider: "openai", + authType: "apikey", + name: "t11", + apiKey: "sk-t11", + isActive: true, + testStatus: "active", + }); + const id = String(conn.id); await runAsProbe(async () => { - await writeTerminalStatus(id, { testStatus:"banned", isActive:false, lastError:"probe 403", errorCode:"403", lastErrorType:"FORBIDDEN" }, "probe"); + await writeTerminalStatus( + id, + { + testStatus: "banned", + isActive: false, + lastError: "probe 403", + errorCode: "403", + lastErrorType: "FORBIDDEN", + }, + "probe" + ); }); const r = row(id); assert.equal(r.is_active, 1); // probe n'a jamais désactivé @@ -25,9 +54,26 @@ test("probe-origin writeTerminalStatus records error but never deactivates", asy }); test("production writeTerminalStatus deactivates on terminal", async () => { - const conn = await createProviderConnection({ provider:"openai", authType:"apikey", name:"t11b", apiKey:"sk-t11b", isActive:true, testStatus:"active" } as unknown as Record); - const id = String((conn as unknown as Record).id); - await writeTerminalStatus(id, { testStatus:"banned", isActive:false, lastError:"real 403", errorCode:"403", lastErrorType:"FORBIDDEN" }, "production"); + const conn = await createProviderConnection({ + provider: "openai", + authType: "apikey", + name: "t11b", + apiKey: "sk-t11b", + isActive: true, + testStatus: "active", + }); + const id = String(conn.id); + await writeTerminalStatus( + id, + { + testStatus: "banned", + isActive: false, + lastError: "real 403", + errorCode: "403", + lastErrorType: "FORBIDDEN", + }, + "production" + ); const r = row(id); assert.equal(r.is_active, 0); assert.equal(r.test_status, "banned"); diff --git a/tests/unit/vision-bridge-maxchars.test.ts b/tests/unit/vision-bridge-maxchars.test.ts index d667e51562..7f364468c7 100644 --- a/tests/unit/vision-bridge-maxchars.test.ts +++ b/tests/unit/vision-bridge-maxchars.test.ts @@ -67,6 +67,11 @@ test("modalityBridgeVisionMaxChars=120 caps the description with a … suffix", }), callVisionModel: async (_imageDataUri: string, _config: VisionModelConfig) => LONG_DESCRIPTION, + // #10859 made the reroute heuristic try a live vision-capable model for + // not-combo text-only models, which would hijack the request before the + // describe path. Pin credentials to definitively-unusable (false) so the + // reroute is excluded and the describe path under test runs. + hasUsableCredentials: async () => false, }, }); @@ -93,6 +98,8 @@ test("no modalityBridgeVisionMaxChars key: description is passed through in full }), callVisionModel: async (_imageDataUri: string, _config: VisionModelConfig) => LONG_DESCRIPTION, + // Same #10859 reroute guard as above — keep the describe path under test. + hasUsableCredentials: async () => false, }, }); @@ -125,6 +132,8 @@ test("updateSettingsSchema accepts an explicit modalityBridgeVisionMaxChars: 0 t }), callVisionModel: async (_imageDataUri: string, _config: VisionModelConfig) => LONG_DESCRIPTION, + // Same #10859 reroute guard as above — keep the describe path under test. + hasUsableCredentials: async () => false, }, });