From 6e8fc947321faef26d285325029fedd6f8fad1ca Mon Sep 17 00:00:00 2001 From: Dizzle <112548150+maxmad64bis@users.noreply.github.com> Date: Tue, 25 Aug 2026 01:04:11 +0200 Subject: [PATCH] fix(cli): stop pre-filling the secrets the server owns (#11436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retargetado para release/v3.8.51 (release/v3.8.50 está congelada — freeze issue #11439). Resolvido um conflito de merge não-relacionado em src/shared/utils/wsPath.ts (originado de um refactor já mergeado nessa branch depois do fork deste PR; o diff real deste PR — scripts/dev/sync-env.mjs + tests/unit/sync-env.test.ts — ficou intacto) e revalidado: typecheck:core limpo, 13/13 testes focados passando. Segue o precedente correto do #1622 (STORAGE_ENCRYPTION_KEY) para os dois secrets restantes que a postinstall preenchia por engano, defeituando o mecanismo de ensureSecrets(). Obrigado pela contribuição! --- .gitignore | 1 + .../fixes/11388-live-ws-handshake-port.md | 1 + ...tinstall-stop-prefilling-server-secrets.md | 1 + .../kimi-health-check-jitter-determinism.md | 1 + open-sse/config/glmProvider.ts | 11 + open-sse/executors/glm.ts | 2 + scripts/dev/sync-env.mjs | 22 +- .../[id]/components/CustomModelsSection.tsx | 71 ++-- src/app/.well-known/agent-card.json/route.ts | 12 +- src/app/.well-known/agent.json/route.ts | 8 +- src/app/api/auth/login/route.ts | 17 +- src/app/api/v1/models/catalog.ts | 18 +- src/hooks/useLiveDashboard.ts | 41 +- src/lib/db/models.ts | 3 +- src/lib/providerModels/geminiModelsParser.ts | 20 +- src/lib/providers/staticModels.ts | 4 +- src/lib/tokenHealthCheckKimi.ts | 32 +- src/lib/wellKnown.ts | 11 + src/server/authz/headers.ts | 11 + src/server/authz/pipeline.ts | 11 + src/shared/constants/modelSpecs.ts | 8 + .../constants/modelSupportedEndpoints.ts | 54 +++ src/shared/constants/pricing/shared-tiers.ts | 7 + src/shared/validation/schemas/provider.ts | 20 +- tests/unit/gemini-models-parser.test.ts | 4 +- .../glm-5.3-catalog-and-effort-tiers.test.ts | 20 +- tests/unit/live-ws-url-11331.test.ts | 97 +++++ tests/unit/model-supported-endpoints.test.ts | 58 +++ .../provider-model-endpoint-schema.test.ts | 16 + tests/unit/security-s1-s2-s4.test.ts | 353 ++++++++++++++++++ .../static-model-operation-endpoints.test.ts | 20 + tests/unit/sync-env.test.ts | 13 +- tests/unit/token-health-check-kimi.test.ts | 60 ++- 33 files changed, 914 insertions(+), 114 deletions(-) create mode 100644 changelog.d/fixes/11388-live-ws-handshake-port.md create mode 100644 changelog.d/fixes/11436-postinstall-stop-prefilling-server-secrets.md create mode 100644 changelog.d/maintenance/kimi-health-check-jitter-determinism.md create mode 100644 src/lib/wellKnown.ts create mode 100644 src/shared/constants/modelSupportedEndpoints.ts create mode 100644 tests/unit/live-ws-url-11331.test.ts create mode 100644 tests/unit/model-supported-endpoints.test.ts create mode 100644 tests/unit/provider-model-endpoint-schema.test.ts create mode 100644 tests/unit/security-s1-s2-s4.test.ts create mode 100644 tests/unit/static-model-operation-endpoints.test.ts diff --git a/.gitignore b/.gitignore index 08bceafd36..07545f2a37 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ _tasks/ .agents/** .claude/** .gemini/** +.code-forge/** .config/** .data/** .logs/** diff --git a/changelog.d/fixes/11388-live-ws-handshake-port.md b/changelog.d/fixes/11388-live-ws-handshake-port.md new file mode 100644 index 0000000000..f00f428b01 --- /dev/null +++ b/changelog.d/fixes/11388-live-ws-handshake-port.md @@ -0,0 +1 @@ +- **Live dashboard:** honour the WebSocket port reported by `/api/v1/ws?handshake=1` instead of the port compiled into the bundle, so a `LIVE_WS_PORT` override reaches prebuilt Docker/npm images and Combo Studio Live connects behind a reverse proxy ([#11331](https://github.com/diegosouzapw/OmniRoute/issues/11331)). diff --git a/changelog.d/fixes/11436-postinstall-stop-prefilling-server-secrets.md b/changelog.d/fixes/11436-postinstall-stop-prefilling-server-secrets.md new file mode 100644 index 0000000000..4752b532e8 --- /dev/null +++ b/changelog.d/fixes/11436-postinstall-stop-prefilling-server-secrets.md @@ -0,0 +1 @@ +- **fix(cli):** postinstall no longer fills `JWT_SECRET` and `API_KEY_SECRET` in the installed package's `.env` (#11436). `.env.example` ships both blank on purpose: the server restores them from its durable store, or generates and persists them there, in `ensureSecrets()`. Pre-filling them defeated that — the file lives inside the package directory, so `npm i -g` replaced it and postinstall wrote _different_ values, while `ensureSecrets()` (which only acts on an empty variable) never got to restore the real ones. Both secrets rotated silently on every update, invalidating dashboard sessions and API-key CRCs. `STORAGE_ENCRYPTION_KEY` left the same list for the same reason in #1622; its comment pointed at a function that no longer exists and now names the real provisioning path. diff --git a/changelog.d/maintenance/kimi-health-check-jitter-determinism.md b/changelog.d/maintenance/kimi-health-check-jitter-determinism.md new file mode 100644 index 0000000000..5b57eaa422 --- /dev/null +++ b/changelog.d/maintenance/kimi-health-check-jitter-determinism.md @@ -0,0 +1 @@ +- **test(kimi):** the Kimi background health sweep no longer draws its refresh window inside the assertion. `checkKimiWebConnectionIfNeeded` spreads the refresh over `[60, 240)` seconds before expiry so a fleet of connections does not stampede the token endpoint, and the test used a token expiring in 90 seconds and asserted that a refresh happened — which is true only when the draw lands at 90 or above, i.e. 150 of the 180 possible values. Measured: the test fails 1 run in 6 (16.7% by construction; 4 of 20 local runs), and it is what the Node 26 nightly hit and reported as a Node-compat break (#11361). The spread is now `defaultKimiRefreshJitterSec()` and the window is injectable as `jitterSecFn`, so the test decides it instead of rolling for it; production behaviour is unchanged. Cases were added for a token outside the window and for the default spread's range. diff --git a/open-sse/config/glmProvider.ts b/open-sse/config/glmProvider.ts index 8668de2c63..c11e5d14e5 100644 --- a/open-sse/config/glmProvider.ts +++ b/open-sse/config/glmProvider.ts @@ -48,6 +48,17 @@ export const GLM_SHARED_MODELS = Object.freeze([ supportsReasoning: true, supportedThinkingEfforts: ["low"], }, + { + // Explicit alias for the upstream default (max) — pins reasoning_effort so + // the tier survives an upstream default change, and mirrors glm-5.2-max UX. + id: "glm-5.3-max", + name: "GLM 5.3 Max", + contextLength: 1000000, + maxOutputTokens: 131072, + toolCalling: true, + supportsReasoning: true, + supportedThinkingEfforts: ["max"], + }, { // GLM-5.2 has two positive effective tiers: low/medium map to high and xhigh // maps to max; disabling thinking remains the separate thinking toggle. diff --git a/open-sse/executors/glm.ts b/open-sse/executors/glm.ts index eda8296255..194c8a952b 100644 --- a/open-sse/executors/glm.ts +++ b/open-sse/executors/glm.ts @@ -85,6 +85,8 @@ function parseGlmEffortTier(model: string): GlmEffortTier | null { return { baseModel: "glm-5.3", effort: "high", transport: "openai" }; case "glm-5.3-low": return { baseModel: "glm-5.3", effort: "low", transport: "openai" }; + case "glm-5.3-max": + return { baseModel: "glm-5.3", effort: "max", transport: "openai" }; default: return null; } diff --git a/scripts/dev/sync-env.mjs b/scripts/dev/sync-env.mjs index 8e0701d20c..35bced0537 100644 --- a/scripts/dev/sync-env.mjs +++ b/scripts/dev/sync-env.mjs @@ -37,13 +37,23 @@ function resolveRootDir(rootDir) { } } +// Secrets this file may fill in when `.env.example` ships them blank. +// +// JWT_SECRET, API_KEY_SECRET and STORAGE_ENCRYPTION_KEY are deliberately NOT +// here: the server owns them. It restores each one from its durable store, or +// generates and persists it there on first use — STORAGE_ENCRYPTION_KEY in +// bin/omniroute.mjs (guarded by bin/cli/utils/storageKeyProvision.mjs), the +// other two in src/instrumentation-node.ts::ensureSecrets(), which persists to +// the `secrets` namespace of the database under DATA_DIR. +// +// Filling any of them here defeats that: this file lives inside the installed +// package, so `npm i -g` replaces it and postinstall writes a *different* +// value, while ensureSecrets() — which only acts on an empty variable — never +// gets to restore the real one. The secret then rotates silently on every +// update, invalidating dashboard sessions (JWT_SECRET) and API-key CRCs +// (API_KEY_SECRET). STORAGE_ENCRYPTION_KEY was pulled out first, for the same +// reason, when it cost users their encrypted credentials (issue #1622). const CRYPTO_SECRETS = { - JWT_SECRET: () => randomBytes(64).toString("hex"), - API_KEY_SECRET: () => randomBytes(32).toString("hex"), - // STORAGE_ENCRYPTION_KEY: Generated at server startup instead of postinstall. - // Generated in bin/omniroute.mjs:ensureStorageEncryptionKey() and persisted to - // ~/.omniroute/.env to survive across upgrades. This prevents credential loss - // when upgrading OmniRoute (issue #1622). MACHINE_ID_SALT: () => `omniroute-${randomBytes(8).toString("hex")}`, }; diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/CustomModelsSection.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/CustomModelsSection.tsx index abb840e51b..18801dcb0e 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/CustomModelsSection.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/CustomModelsSection.tsx @@ -11,6 +11,10 @@ import React, { useState, useEffect, useCallback, useMemo } from "react"; import { useTranslations } from "next-intl"; import { Button } from "@/shared/components"; +import { + normalizeModelSupportedEndpoints, + type ModelSupportedEndpoint, +} from "@/shared/constants/modelSupportedEndpoints"; import { useNotificationStore } from "@/store/notificationStore"; import { buildCompatMap, @@ -51,6 +55,29 @@ function targetFormatLabel(value: string, t: (key: string) => string): string { return key ? t(key) : value; } +const MODEL_ENDPOINT_OPTIONS: ModelSupportedEndpoint[] = [ + "chat", + "embeddings", + "rerank", + "images", + "videos", + "audio-speech", + "audio-transcriptions", +]; + +function endpointLabel(endpoint: ModelSupportedEndpoint, t: (key: string) => string): string { + const labels: Partial> = { + chat: `💬 ${t("supportedEndpointChat")}`, + embeddings: `📐 ${t("supportedEndpointEmbeddings")}`, + rerank: providerText(t, "rerankEndpoint", "Rerank"), + images: `🖼️ ${t("supportedEndpointImages")}`, + videos: "🎬 Video", + "audio-speech": `🔊 ${t("audioSpeech")}`, + "audio-transcriptions": `🎙️ ${t("audioTranscriptions")}`, + }; + return labels[endpoint] || endpoint; +} + /** * #4125: parse the free-text "Context Window Override" field. Blank → no override * (`value: null`, not an error). A non-empty value must be a positive whole number of @@ -232,7 +259,7 @@ export default function CustomModelsSection({ setEditingApiFormat(model.apiFormat || "chat-completions"); setEditingEndpoints( Array.isArray(model.supportedEndpoints) && model.supportedEndpoints.length - ? model.supportedEndpoints + ? normalizeModelSupportedEndpoints(model.supportedEndpoints) : ["chat"] ); setEditingTargetFormat(model.targetFormat || ""); @@ -428,6 +455,7 @@ export default function CustomModelsSection({ +
@@ -454,7 +482,7 @@ export default function CustomModelsSection({ {t("supportedEndpointsLabel")}
- {["chat", "embeddings", "rerank", "images", "audio"].map((ep) => ( + {MODEL_ENDPOINT_OPTIONS.map((ep) => ( ))}
@@ -594,6 +614,22 @@ export default function CustomModelsSection({ {`🔊 ${t("audioShortLabel")}`} )} + {(model.supportedEndpoints?.includes("videos") || + model.supportedEndpoints?.includes("video")) && ( + + 🎬 Video + + )} + {model.supportedEndpoints?.includes("audio-speech") && ( + + {`🔊 ${t("audioSpeech")}`} + + )} + {model.supportedEndpoints?.includes("audio-transcriptions") && ( + + {`🎙️ ${t("audioTranscriptions")}`} + + )} {anyNormalizeCompatBadge(model.id!, customMap, overrideMap) && ( {t("audioTranscriptions")} +
@@ -697,7 +734,7 @@ export default function CustomModelsSection({ {t("supportedEndpointsLabel")}
- {["chat", "embeddings", "rerank", "images", "audio"].map((ep) => ( + {MODEL_ENDPOINT_OPTIONS.map((ep) => ( ))}
diff --git a/src/app/.well-known/agent-card.json/route.ts b/src/app/.well-known/agent-card.json/route.ts index 031c4081c6..a041e47d6f 100644 --- a/src/app/.well-known/agent-card.json/route.ts +++ b/src/app/.well-known/agent-card.json/route.ts @@ -11,19 +11,21 @@ */ import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; import { getFleetSkills } from "@/lib/conductor/fleetSkills"; +import { getBaseUrl } from "@/lib/wellKnown"; const PACKAGE_VERSION = process.env.npm_package_version || "1.8.1"; -const BASE_URL = process.env.OMNIROUTE_BASE_URL || "http://localhost:20128"; /** * GET /.well-known/agent-card.json * * Returns the OmniRoute Agent Card (A2A v1.0). */ -export async function GET() { +export async function GET(request: NextRequest) { const fleetSkills = await getFleetSkills(); + const baseUrl = getBaseUrl(request); const agentCard = { name: "OmniRoute AI Gateway", @@ -31,16 +33,16 @@ export async function GET() { "Intelligent AI routing gateway with 36+ providers, smart fallback, quota tracking, " + "format translation, and auto-managed combos. Routes AI requests to the optimal " + "provider based on cost, latency, quota availability, and task requirements.", - url: `${BASE_URL}/a2a`, + url: `${baseUrl}/a2a`, version: PACKAGE_VERSION, supportedInterfaces: [ { - url: `${BASE_URL}/a2a`, + url: `${baseUrl}/a2a`, protocolBinding: "JSONRPC", protocolVersion: "1.0", }, { - url: `${BASE_URL}/a2a`, + url: `${baseUrl}/a2a`, protocolBinding: "JSONRPC", protocolVersion: "0.3", }, diff --git a/src/app/.well-known/agent.json/route.ts b/src/app/.well-known/agent.json/route.ts index be16ffa62d..e208888b05 100644 --- a/src/app/.well-known/agent.json/route.ts +++ b/src/app/.well-known/agent.json/route.ts @@ -9,11 +9,12 @@ */ import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; import { getFleetSkills } from "@/lib/conductor/fleetSkills"; +import { getBaseUrl } from "@/lib/wellKnown"; const PACKAGE_VERSION = process.env.npm_package_version || "1.8.1"; -const BASE_URL = process.env.OMNIROUTE_BASE_URL || "http://localhost:20128"; /** * GET /.well-known/agent.json @@ -21,17 +22,18 @@ const BASE_URL = process.env.OMNIROUTE_BASE_URL || "http://localhost:20128"; * Returns the OmniRoute Agent Card that describes this gateway's * capabilities as an A2A agent. */ -export async function GET() { +export async function GET(request: NextRequest) { // Conductor PRD RF2: fleet skills from the OmniConductor hub (cached ~60s; [] when // the hub is unset/offline — the card stays valid without the fleet section). const fleetSkills = await getFleetSkills(); + const baseUrl = getBaseUrl(request); const agentCard = { name: "OmniRoute AI 网关", description: "智能 AI 路由网关,支持 36+ 个提供者、智能回退、配额跟踪、" + "格式转换和自动管理组合。根据成本、延迟、配额可用性" + "和任务要求将 AI 请求路由到最优提供者。", - url: `${BASE_URL}/a2a`, + url: `${baseUrl}/a2a`, version: PACKAGE_VERSION, capabilities: { streaming: true, diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 8855c1a8c8..c4142a4768 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index"; import { classifyIpScope } from "@/lib/ipUtils"; import { getCachedSettings } from "@/lib/db/settings"; @@ -13,6 +14,7 @@ import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; import { loginSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { checkLoginGuard, clearLoginAttempts, recordLoginFailure } from "@/server/auth/loginGuard"; +import { AUTHZ_HEADER_TRUSTED_PEER_IP } from "@/server/authz/headers"; // SECURITY: No hardcoded fallback — JWT_SECRET must be configured. if (!process.env.JWT_SECRET) { @@ -28,7 +30,7 @@ export const authRouteInternals = { getCookieStore: cookies, }; -export async function POST(request) { +export async function POST(request: NextRequest) { const auditContext = getAuditRequestContext(request); try { @@ -75,7 +77,10 @@ export async function POST(request) { return NextResponse.json({ error: "Invalid password payload" }, { status: 400 }); } const settings = await getCachedSettings(); - const clientIp = auditContext.ipAddress || null; + const trustedPeerIp = process.env.OMNIROUTE_PEER_STAMP_TOKEN + ? request.headers.get(AUTHZ_HEADER_TRUSTED_PEER_IP) + : null; + const clientIp = trustedPeerIp || auditContext.ipAddress || null; const oidcDisabledPassword = settings.oidcEnabled === true && (settings.oidcDisablePasswordLogin === true || @@ -118,9 +123,7 @@ export async function POST(request) { { error: "Too many failed attempts. Try again later." }, { status: 429, - headers: guardCheck.retryAfterSeconds - ? { "Retry-After": String(guardCheck.retryAfterSeconds) } - : {}, + headers: { "Retry-After": String(guardCheck.retryAfterSeconds || 60) }, } ); } @@ -220,9 +223,7 @@ export async function POST(request) { { error: "Too many failed attempts. Try again later." }, { status: 429, - headers: failureDecision.retryAfterSeconds - ? { "Retry-After": String(failureDecision.retryAfterSeconds) } - : {}, + headers: { "Retry-After": String(failureDecision.retryAfterSeconds || 60) }, } ); } diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index 04bd7bcc49..d70a275e3d 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -69,6 +69,7 @@ import { import { createModelCapabilityResolutionSnapshot } from "@/lib/modelCapabilityResolutionSnapshot"; import { getModelsDevPricing, getSyncedCapability } from "@/lib/modelsDevSync"; import { getModelSpec } from "@/shared/constants/modelSpecs"; +import { classifyModelSupportedEndpoints } from "@/shared/constants/modelSupportedEndpoints"; import { getModelsCatalogPrefixMode } from "@/shared/utils/featureFlags"; import { buildReservedPrefixes, selectCompatibleNodeForPrefix } from "@/lib/providerNodePrefixes"; import { applyCatalogPostFilters, finalizeCatalogResponse } from "./catalogResponse"; @@ -1152,18 +1153,15 @@ async function buildUnifiedModelsResponseCore( const aliasId = `${alias}/${displayModelId}`; const endpoints = Array.isArray(sm.supportedEndpoints) ? sm.supportedEndpoints : ["chat"]; const apiFormat = typeof sm.apiFormat === "string" ? sm.apiFormat : "chat-completions"; - let modelType: string | undefined; - if (endpoints.includes("embeddings")) modelType = "embedding"; - else if (endpoints.includes("rerank")) modelType = "rerank"; - else if (endpoints.includes("images")) modelType = "image"; - else if (endpoints.includes("audio")) modelType = "audio"; + const classification = classifyModelSupportedEndpoints(endpoints); + const modelType = classification.type; // Same owned_by the alias/canonical entries below will carry — computed once // so the effort_tiers exclusion (codex/glm/kimi) and the entries agree. const syncedOwnedBy = resolvePublicOwnerId(providerId, canonicalProviderId); const syncedFields = { ...(modelType ? { type: modelType } : {}), ...(apiFormat !== "chat-completions" ? { api_format: apiFormat } : {}), - ...(modelType === "audio" ? { subtype: "transcription" } : {}), + ...(classification.subtype ? { subtype: classification.subtype } : {}), ...(sm.inputTokenLimit ? { context_length: sm.inputTokenLimit } : {}), ...(typeof sm.outputTokenLimit === "number" ? { max_output_tokens: sm.outputTokenLimit } @@ -1604,11 +1602,8 @@ async function buildUnifiedModelsResponseCore( : ["chat"]; const apiFormat = typeof model.apiFormat === "string" ? model.apiFormat : "chat-completions"; - let modelType: string | undefined; - if (endpoints.includes("embeddings")) modelType = "embedding"; - else if (endpoints.includes("rerank")) modelType = "rerank"; - else if (endpoints.includes("images")) modelType = "image"; - else if (endpoints.includes("audio")) modelType = "audio"; + const classification = classifyModelSupportedEndpoints(endpoints); + const modelType = classification.type; if ( modelType && hasEquivalentSpecialtyModel(canonicalProviderId, modelId, modelType, aliasId) @@ -1631,6 +1626,7 @@ async function buildUnifiedModelsResponseCore( parent: null, custom: true, ...(modelType ? { type: modelType } : {}), + ...(classification.subtype ? { subtype: classification.subtype } : {}), ...(apiFormat !== "chat-completions" ? { api_format: apiFormat } : {}), ...(endpoints.length > 1 || !endpoints.includes("chat") ? { supported_endpoints: endpoints } diff --git a/src/hooks/useLiveDashboard.ts b/src/hooks/useLiveDashboard.ts index d257605237..8f9b23575b 100644 --- a/src/hooks/useLiveDashboard.ts +++ b/src/hooks/useLiveDashboard.ts @@ -13,7 +13,7 @@ import { useEffect, useRef, useState, useCallback } from "react"; import type { DashboardChannel, DashboardEventName } from "@/lib/events/types"; -import { deriveLiveWsPath } from "@/shared/utils/wsPath"; +import { deriveLiveWsPath, resolveLiveWsUrl, sanitizeLiveWsPort } from "@/shared/utils/wsPath"; // ── Config ──────────────────────────────────────────────────────────────── @@ -40,14 +40,10 @@ function getDefaultWsUrl(): string { if (typeof window === "undefined") return `ws://localhost:20132${BUILD_TIME_WS_PATH}`; const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const { hostname } = window.location; - // Bug #1 fix: Use the WS server's actual port (20132) for both loopback - // and non-loopback clients. Previously the non-loopback branch tried to - // upgrade the HTTP port (window.location.host) which has no upgrade - // handler in src/proxy.ts. If the user wants the upgrade to go through - // Next.js (same-origin), they should explicitly pass `wsUrl`. - if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") { - return `${protocol}//${hostname}:20132${BUILD_TIME_WS_PATH}`; - } + // The WS server's own port, for loopback and non-loopback alike: the HTTP + // port has no upgrade handler in src/proxy.ts. This is only the starting + // point - the handshake below replaces the port when the server reports a + // different one, and a caller can always pass `wsUrl` outright. return `${protocol}//${hostname}:20132${BUILD_TIME_WS_PATH}`; } @@ -113,6 +109,7 @@ export function useLiveDashboard({ const needsHandshake = !wsUrl && !BUILD_TIME_PUBLIC_WS_URL && typeof window !== "undefined"; const [handshakeUrl, setHandshakeUrl] = useState(null); const [handshakePath, setHandshakePath] = useState(null); + const [handshakePort, setHandshakePort] = useState(null); const [wsUrlResolved, setWsUrlResolved] = useState(!needsHandshake); useEffect(() => { @@ -127,6 +124,11 @@ export function useLiveDashboard({ if (typeof body?.live?.path === "string" && body.live.path.startsWith("/")) { setHandshakePath(body.live.path); } + // The live server reports the port it is actually listening on, so a + // LIVE_WS_PORT override reaches a prebuilt image instead of being + // overruled by the compiled-in default (#11331). + const port = sanitizeLiveWsPort(body?.live?.port); + if (port !== null) setHandshakePort(port); }) .catch(() => { // Handshake unavailable — fall back to the default URL. @@ -139,20 +141,13 @@ export function useLiveDashboard({ }; }, [needsHandshake, wsUrlResolved]); - const effectiveWsUrl = (() => { - if (wsUrl) return wsUrl; - if (handshakeUrl) return handshakeUrl; - if (handshakePath && handshakePath !== BUILD_TIME_WS_PATH) { - try { - const url = new URL(DEFAULT_WS_URL); - url.pathname = handshakePath; - return url.toString(); - } catch { - return DEFAULT_WS_URL; - } - } - return DEFAULT_WS_URL; - })(); + const effectiveWsUrl = resolveLiveWsUrl({ + explicit: wsUrl, + handshakeUrl, + handshakePort, + handshakePath: handshakePath !== BUILD_TIME_WS_PATH ? handshakePath : null, + defaultUrl: DEFAULT_WS_URL, + }); const [events, setEvents] = useState([]); const wsRef = useRef(null); diff --git a/src/lib/db/models.ts b/src/lib/db/models.ts index 56e5c20f29..24b7707730 100644 --- a/src/lib/db/models.ts +++ b/src/lib/db/models.ts @@ -190,7 +190,8 @@ export async function addCustomModel( | "rerank" | "audio-transcriptions" | "audio-speech" - | "images-generations" = "chat-completions", + | "images-generations" + | "video" = "chat-completions", supportedEndpoints: string[] = ["chat"], // #2905: optional per-model wire format override (e.g. "claude" for an // opencode-go custom model). When unset, routing falls back to the provider diff --git a/src/lib/providerModels/geminiModelsParser.ts b/src/lib/providerModels/geminiModelsParser.ts index 26c0d8f4cf..e4fd1bfdd7 100644 --- a/src/lib/providerModels/geminiModelsParser.ts +++ b/src/lib/providerModels/geminiModelsParser.ts @@ -3,11 +3,15 @@ * * Each model's `supportedGenerationMethods` is mapped to OmniRoute endpoints: * - generateContent / generateAnswer → "chat" - * - predictLongRunning → "video" (Veo video generation) + * - predict → "images" (Imagen image generation) + * - predictLongRunning → "videos" (Veo video generation) * - embedContent → "embeddings" * - bidiGenerateContent → "audio" (Live real-time audio) * - * Model-id heuristics ensure Veo models remain in the video bucket. + * Model-id heuristics refine the long-running bucket because Google exposes both + * Imagen and Veo via long-running methods on the same endpoint: + * - id contains "veo" → ensure "videos" + * - id contains "imagen" → force "images" (never "videos") * * Note: `gemini-*-image` models (e.g. gemini-3-pro-image) generate images via the * regular `generateContent` path, so they stay "chat" (image output is a chat @@ -21,7 +25,8 @@ const METHOD_TO_ENDPOINT: Record = { generateContent: "chat", embedContent: "embeddings", - predictLongRunning: "video", + predict: "images", + predictLongRunning: "videos", bidiGenerateContent: "audio", generateAnswer: "chat", }; @@ -63,9 +68,14 @@ export function parseGeminiModelsList(data: any): GeminiDiscoveryModel[] { const id = ((m.name as string) || (m.id as string) || "").replace(/^models\//, ""); const lowerId = id.toLowerCase(); - // Keep Veo models in the video bucket even when the method list is incomplete. + // Google exposes Imagen (image) and Veo (video) via long-running methods; the + // method alone can't always distinguish them, so refine by model id. if (lowerId.includes("veo")) { - endpoints.add("video"); + endpoints.add("videos"); + } + if (lowerId.includes("imagen")) { + endpoints.delete("videos"); + endpoints.add("images"); } if (endpoints.size === 0) endpoints.add("chat"); diff --git a/src/lib/providers/staticModels.ts b/src/lib/providers/staticModels.ts index a62c90b110..9c86c8cc6f 100644 --- a/src/lib/providers/staticModels.ts +++ b/src/lib/providers/staticModels.ts @@ -221,7 +221,7 @@ export function getStaticModelsForProvider(provider: string): LocalCatalogModel[ if (speechProvider) { appendModels(speechProvider.models, { apiFormat: "audio", - supportedEndpoints: ["audio"], + supportedEndpoints: ["audio-speech"], }); } @@ -229,7 +229,7 @@ export function getStaticModelsForProvider(provider: string): LocalCatalogModel[ if (transcriptionProvider) { appendModels(transcriptionProvider.models, { apiFormat: "audio", - supportedEndpoints: ["audio"], + supportedEndpoints: ["audio-transcriptions"], }); } diff --git a/src/lib/tokenHealthCheckKimi.ts b/src/lib/tokenHealthCheckKimi.ts index 79916a1423..621abc6260 100644 --- a/src/lib/tokenHealthCheckKimi.ts +++ b/src/lib/tokenHealthCheckKimi.ts @@ -2,6 +2,21 @@ import { isKimiTokenExpiringSoon } from "@omniroute/open-sse/utils/kimiJwt.ts"; import { exchangeKimiRefreshToken } from "@/lib/kimi/tokenRefresh"; import { updateProviderConnection } from "@/lib/db/providers"; +/** + * Refresh window, spread over [60, 240) seconds before expiry so a fleet of + * connections does not stampede the token endpoint at the same instant. + * + * Kept as a named export rather than inline: it is the only nondeterminism in this + * path, and a caller that needs a decision it can predict — a test — has to be able + * to replace it. `tests/unit/token-health-check-kimi.test.ts` used a token expiring + * in 90 s and asserted a refresh, which is a coin the draw loses 1 in 6 times + * (a refresh needs `jitter >= 90`, i.e. 150 of the 180 possible values). It failed + * that way on the Node 26 nightly and was triaged as a Node-compat break. + */ +export function defaultKimiRefreshJitterSec(): number { + return 60 + Math.floor(Math.random() * 180); +} + export async function checkKimiWebConnectionIfNeeded(params: { conn: any; now: string; @@ -12,6 +27,12 @@ export async function checkKimiWebConnectionIfNeeded(params: { logPrefix: string; exchangeFn?: typeof exchangeKimiRefreshToken; persistFn?: typeof updateProviderConnection; + /** + * Seconds before expiry at which a refresh is triggered. Defaults to the random + * spread below; injectable so a caller — a test above all — can decide the window + * instead of drawing it. + */ + jitterSecFn?: () => number; }): Promise { const { conn, log, logWarn, getConnectionLogLabel, logPrefix } = params; const provider = String(conn?.provider || "").toLowerCase(); @@ -21,20 +42,23 @@ export async function checkKimiWebConnectionIfNeeded(params: { if (!refreshToken) return true; // Handled, but cannot refresh without refresh_token const token = conn.apiKey || conn.accessToken; - // Calculate jitter: random value between 60 and 240 seconds (1 to 4 min before expiry) - const jitterSec = 60 + Math.floor(Math.random() * 180); + const jitterSec = (params.jitterSecFn ?? defaultKimiRefreshJitterSec)(); const expiringSoon = isKimiTokenExpiringSoon(token, jitterSec); if (!expiringSoon) return true; - log(`${logPrefix} Kimi Web connection ${getConnectionLogLabel(conn)} token expiring soon; refreshing in background...`); + log( + `${logPrefix} Kimi Web connection ${getConnectionLogLabel(conn)} token expiring soon; refreshing in background...` + ); const exchange = params.exchangeFn || exchangeKimiRefreshToken; const persist = params.persistFn || updateProviderConnection; const res = await exchange(refreshToken); if (res.success && res.accessToken) { - log(`${logPrefix} Kimi Web connection ${getConnectionLogLabel(conn)} token refreshed successfully.`); + log( + `${logPrefix} Kimi Web connection ${getConnectionLogLabel(conn)} token refreshed successfully.` + ); await persist(conn.id, { apiKey: res.accessToken, accessToken: res.accessToken, diff --git a/src/lib/wellKnown.ts b/src/lib/wellKnown.ts new file mode 100644 index 0000000000..627a41d88c --- /dev/null +++ b/src/lib/wellKnown.ts @@ -0,0 +1,11 @@ +import type { NextRequest } from "next/server"; + +/** + * Derive the base URL for A2A agent card endpoints. + * Prefers OMNIROUTE_BASE_URL env var for admin override; falls back to the + * request's dynamic origin so the gateway works behind any hostname without + * hardcoded localhost:20128 (S2 security fix). + */ +export function getBaseUrl(request: NextRequest): string { + return process.env.OMNIROUTE_BASE_URL || request.nextUrl.origin; +} \ No newline at end of file diff --git a/src/server/authz/headers.ts b/src/server/authz/headers.ts index 002e679739..399be96eab 100644 --- a/src/server/authz/headers.ts +++ b/src/server/authz/headers.ts @@ -62,6 +62,16 @@ export const VIA_PROXY_HEADER = "x-omniroute-via-proxy"; */ export const AUTHZ_HEADER_PEER_LOCALITY = "x-omniroute-peer-locality"; +/** + * The resolved real peer IP, stamped by the pipeline AFTER verifying the + * token-stamped PEER_IP_HEADER. This is the trusted, non-spoofable IP that + * route handlers (e.g. login rate-limit key) should use instead of re-deriving + * from X-Forwarded-For / X-Real-IP. Set only when the stamp token is configured + * and the HMAC signature validates; absent when the stamp is not in use. + * Stripped from incoming requests like all other trusted headers. + */ +export const AUTHZ_HEADER_TRUSTED_PEER_IP = "x-omniroute-trusted-peer-ip"; + /** * Headers the pipeline must NEVER trust on incoming requests. They are * stripped before route classification to prevent header-spoofing attacks. @@ -73,4 +83,5 @@ export const AUTHZ_TRUSTED_HEADERS: ReadonlyArray = [ AUTHZ_HEADER_AUTH_LABEL, AUTHZ_HEADER_AUTH_SCOPES, AUTHZ_HEADER_PEER_LOCALITY, + AUTHZ_HEADER_TRUSTED_PEER_IP, ]; diff --git a/src/server/authz/pipeline.ts b/src/server/authz/pipeline.ts index d8dacf376d..9ef7fed2fd 100644 --- a/src/server/authz/pipeline.ts +++ b/src/server/authz/pipeline.ts @@ -25,6 +25,7 @@ import { AUTHZ_HEADER_PEER_LOCALITY, AUTHZ_HEADER_REQUEST_ID, AUTHZ_HEADER_ROUTE_CLASS, + AUTHZ_HEADER_TRUSTED_PEER_IP, AUTHZ_TRUSTED_HEADERS, CLI_TOKEN_HEADER, PEER_IP_HEADER, @@ -332,6 +333,16 @@ export async function runAuthzPipeline( process.env.OMNIROUTE_PEER_STAMP_TOKEN ); requestHeaders.set(AUTHZ_HEADER_PEER_LOCALITY, peerLocality); + // Stamp the resolved, non-spoofable peer IP for route handlers that need + // the real client IP (e.g. login rate-limit key). Only set when the stamp + // token is configured and the HMAC signature validates; absent otherwise. + const trustedPeerIp = resolveStampedPeer( + request.headers.get(PEER_IP_HEADER), + process.env.OMNIROUTE_PEER_STAMP_TOKEN + ); + if (trustedPeerIp) { + requestHeaders.set(AUTHZ_HEADER_TRUSTED_PEER_IP, trustedPeerIp); + } // Local CLI-token auth is decided centrally above. Preserve that trusted // decision for route-level requireManagementAuth without forwarding the // machine token itself: custom client auth headers are stripped before the diff --git a/src/shared/constants/modelSpecs.ts b/src/shared/constants/modelSpecs.ts index 8428284627..a8c238f080 100644 --- a/src/shared/constants/modelSpecs.ts +++ b/src/shared/constants/modelSpecs.ts @@ -70,6 +70,7 @@ const AUTHORITATIVE_CONTEXT_WINDOW_MODEL_IDS = new Set([ "glm-5.3", "glm-5.3-high", "glm-5.3-low", + "glm-5.3-max", "glm-5.2", "glm-5.2-high", "glm-5.2-max", @@ -573,6 +574,13 @@ export const MODEL_SPECS: Record = { supportsThinking: true, supportsTools: true, }, + "glm-5.3-max": { + maxOutputTokens: 131072, + contextWindow: 1000000, + thinkingBudgetCap: 38912, + supportsThinking: true, + supportsTools: true, + }, // ── Z.AI GLM-5.2 (1M context, 128K max output, effort tiers) ──── "glm-5.2": { diff --git a/src/shared/constants/modelSupportedEndpoints.ts b/src/shared/constants/modelSupportedEndpoints.ts new file mode 100644 index 0000000000..6201c79b48 --- /dev/null +++ b/src/shared/constants/modelSupportedEndpoints.ts @@ -0,0 +1,54 @@ +export const MODEL_SUPPORTED_ENDPOINT_VALUES = [ + "chat", + "embeddings", + "rerank", + "images", + "videos", + "audio-speech", + "audio-transcriptions", + "images-generations", + // Persisted legacy values remain valid input and normalize on write/edit. + "video", + "audio", +] as const; + +export type ModelSupportedEndpoint = (typeof MODEL_SUPPORTED_ENDPOINT_VALUES)[number]; + +export function normalizeModelSupportedEndpoints(endpoints: readonly string[]): string[] { + const normalized: string[] = []; + const add = (endpoint: string) => { + if (!normalized.includes(endpoint)) normalized.push(endpoint); + }; + + for (const endpoint of endpoints) { + if (endpoint === "video") { + add("videos"); + } else if (endpoint === "audio") { + add("audio-speech"); + add("audio-transcriptions"); + } else { + add(endpoint); + } + } + return normalized; +} + +export function classifyModelSupportedEndpoints(endpoints: readonly string[]): { + type?: "embedding" | "rerank" | "image" | "video" | "audio"; + subtype?: "speech" | "transcription"; +} { + if (endpoints.includes("embeddings")) return { type: "embedding" }; + if (endpoints.includes("rerank")) return { type: "rerank" }; + if (endpoints.includes("images")) return { type: "image" }; + if (endpoints.includes("videos") || endpoints.includes("video")) return { type: "video" }; + + const supportsSpeech = endpoints.includes("audio-speech"); + const supportsTranscription = + endpoints.includes("audio-transcriptions") || endpoints.includes("audio"); + if (!supportsSpeech && !supportsTranscription) return {}; + if (supportsSpeech && !supportsTranscription) return { type: "audio", subtype: "speech" }; + if (supportsTranscription && !supportsSpeech) { + return { type: "audio", subtype: "transcription" }; + } + return { type: "audio" }; +} diff --git a/src/shared/constants/pricing/shared-tiers.ts b/src/shared/constants/pricing/shared-tiers.ts index 8bd2e4ae4f..8004e43a47 100644 --- a/src/shared/constants/pricing/shared-tiers.ts +++ b/src/shared/constants/pricing/shared-tiers.ts @@ -135,6 +135,13 @@ export const GLM_PRICING = { reasoning: 5, cache_creation: 1.2, }, + "glm-5.3-max": { + input: 1.2, + output: 5, + cached: 0.3, + reasoning: 5, + cache_creation: 1.2, + }, "glm-5.2": { input: 1.2, output: 5, diff --git a/src/shared/validation/schemas/provider.ts b/src/shared/validation/schemas/provider.ts index 7e6cd052b4..a99ba7c947 100644 --- a/src/shared/validation/schemas/provider.ts +++ b/src/shared/validation/schemas/provider.ts @@ -6,6 +6,10 @@ import { import { SUPPORTED_BATCH_ENDPOINTS } from "@/shared/constants/batchEndpoints"; import { MAX_REQUEST_BODY_LIMIT_MB, MIN_REQUEST_BODY_LIMIT_MB } from "@/shared/constants/bodySize"; import { COMBO_CONFIG_MODES } from "@/shared/constants/comboConfigMode"; +import { + MODEL_SUPPORTED_ENDPOINT_VALUES, + normalizeModelSupportedEndpoints, +} from "@/shared/constants/modelSupportedEndpoints"; import { providerAllowsOptionalApiKey } from "@/shared/constants/providers"; import { HIDEABLE_SIDEBAR_ITEM_IDS } from "@/shared/constants/sidebarVisibility"; import { @@ -238,22 +242,12 @@ export const providerModelMutationSchema = z.object({ "audio-transcriptions", "audio-speech", "images-generations", + "video", ]) .default("chat-completions"), supportedEndpoints: z - .array( - z.enum([ - "chat", - "embeddings", - "rerank", - "images", - "audio", - "audio-transcriptions", - "audio-speech", - "images-generations", - "videos", - ]) - ) + .array(z.enum(MODEL_SUPPORTED_ENDPOINT_VALUES)) + .transform(normalizeModelSupportedEndpoints) .default(["chat"]), // #2905: optional per-model wire format override for custom models (e.g. a // custom opencode-go model that must use the Anthropic Messages shape). diff --git a/tests/unit/gemini-models-parser.test.ts b/tests/unit/gemini-models-parser.test.ts index 0e07a6f7a1..8c9da3dac4 100644 --- a/tests/unit/gemini-models-parser.test.ts +++ b/tests/unit/gemini-models-parser.test.ts @@ -82,11 +82,11 @@ test("parseGeminiModelsList maps embedContent and bidiGenerateContent", () => { ]); }); -test("parseGeminiModelsList maps Veo predictLongRunning models to the video endpoint", () => { +test("parseGeminiModelsList maps Veo predictLongRunning models to the videos endpoint", () => { const models = parseGeminiModelsList(SAMPLE); const veo = models.find((m) => m.id === "veo-3.0-generate-001"); assert.ok(veo, "veo-3.0-generate-001 should be present"); - assert.deepEqual(veo!.supportedEndpoints, ["video"]); + assert.deepEqual(veo!.supportedEndpoints, ["videos"]); }); test("parseGeminiModelsList defaults to chat and tolerates empty/missing input", () => { diff --git a/tests/unit/glm-5.3-catalog-and-effort-tiers.test.ts b/tests/unit/glm-5.3-catalog-and-effort-tiers.test.ts index 5d927de02a..ffe975d71d 100644 --- a/tests/unit/glm-5.3-catalog-and-effort-tiers.test.ts +++ b/tests/unit/glm-5.3-catalog-and-effort-tiers.test.ts @@ -22,7 +22,7 @@ const metadataRegistry = await import("../../src/lib/modelMetadataRegistry.ts"); const { shouldExposeSyncedEffortVariants, SYNCED_EFFORT_SKIP_PROVIDERS } = await import("../../open-sse/utils/syncedEffortVariants.ts"); -const GLM_5_3_IDS = ["glm-5.3", "glm-5.3-high", "glm-5.3-low"] as const; +const GLM_5_3_IDS = ["glm-5.3", "glm-5.3-high", "glm-5.3-low", "glm-5.3-max"] as const; // transformForTransport returns an opaque body; surface only the fields asserted below. type TransformedRequest = { @@ -99,6 +99,7 @@ test("catalog exposes only GLM effort tiers that each provider can route", () => ["glm-5.3", ["low", "high", "max"]], ["glm-5.3-high", ["high"]], ["glm-5.3-low", ["low"]], + ["glm-5.3-max", ["max"]], ["glm-5.2", ["high", "max"]], ["glm-5.2-high", ["high"]], ["glm-5.2-max", ["max"]], @@ -119,7 +120,6 @@ test("catalog exposes only GLM effort tiers that each provider can route", () => } } }); - for (const provider of ["glm", "glm-cn", "glmt"]) { test(`${provider} advertises the GLM-5.3 base model and effort tiers (GLM_SHARED_MODELS)`, () => { const ids = modelIds(provider); @@ -142,7 +142,7 @@ for (const provider of ["glm", "glm-cn", "glmt"]) { test("zai advertises the GLM-5.3 base model only (DefaultExecutor sends ids verbatim)", () => { const ids = modelIds("zai"); assert.ok(ids.includes("glm-5.3"), `zai should advertise glm-5.3; got ${ids.join(", ")}`); - for (const alias of ["glm-5.3-high", "glm-5.3-low"]) { + for (const alias of ["glm-5.3-high", "glm-5.3-low", "glm-5.3-max"]) { assert.ok( !ids.includes(alias), `zai must not list ${alias}: GlmExecutor-only alias, unknown upstream on the Anthropic endpoint` @@ -200,6 +200,20 @@ test("GlmExecutor resolves glm-5.3-low to reasoning_effort=low with thinking ena assert.equal(transformed.thinking?.type, "enabled"); }); +test("GlmExecutor resolves glm-5.3-max to an explicit reasoning_effort=max (pins the tier even if the upstream default changes)", () => { + const executor = new GlmExecutor("glm"); + const transformed = executor.transformForTransport( + "glm-5.3-max", + { messages: [{ role: "user", content: "hi" }] }, + false, + { apiKey: "glm-key" }, + "openai" + ) as TransformedRequest; + + assert.equal(transformed.model, "glm-5.3"); + assert.equal(transformed.reasoning_effort, "max"); + assert.equal(transformed.thinking?.type, "enabled"); +}); test("GlmExecutor leaves base glm-5.3 without an injected reasoning_effort (upstream default = max)", () => { const executor = new GlmExecutor("glm"); const transformed = executor.transformForTransport( diff --git a/tests/unit/live-ws-url-11331.test.ts b/tests/unit/live-ws-url-11331.test.ts new file mode 100644 index 0000000000..83fc56a216 --- /dev/null +++ b/tests/unit/live-ws-url-11331.test.ts @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + deriveLiveWsPath, + resolveLiveWsUrl, + sanitizeLiveWsPort, +} from "../../src/shared/utils/wsPath.ts"; + +/** + * The /api/v1/ws?handshake=1 response reports `live.port` — the port the live + * server is actually listening on — but the dashboard client read only + * `publicUrl` and `path`. An operator who moved the server with LIVE_WS_PORT + * still got the compiled-in 20132 and a permanently disconnected Combo Studio + * (#11331). + */ +const DEFAULT_URL = "wss://omniroute.example.tld:20132/live-ws"; + +describe("sanitizeLiveWsPort", () => { + it("accepts a port in range, as a number or a string", () => { + assert.equal(sanitizeLiveWsPort(20140), 20140); + assert.equal(sanitizeLiveWsPort("20140"), 20140); + }); + + it("rejects anything that is not a usable port", () => { + for (const value of [0, -1, 65536, 1.5, "", "abc", null, undefined, {}, NaN]) { + assert.equal(sanitizeLiveWsPort(value), null, `expected null for ${String(value)}`); + } + }); +}); + +describe("resolveLiveWsUrl", () => { + it("uses the port the handshake reports instead of the compiled-in one", () => { + const url = resolveLiveWsUrl({ handshakePort: 20140, defaultUrl: DEFAULT_URL }); + assert.equal(new URL(url).port, "20140"); + assert.equal(new URL(url).hostname, "omniroute.example.tld"); + assert.equal(new URL(url).pathname, "/live-ws"); + }); + + it("keeps the default when the handshake reports nothing", () => { + assert.equal(resolveLiveWsUrl({ defaultUrl: DEFAULT_URL }), DEFAULT_URL); + }); + + it("ignores a port the handshake cannot mean", () => { + assert.equal(resolveLiveWsUrl({ handshakePort: 0, defaultUrl: DEFAULT_URL }), DEFAULT_URL); + assert.equal( + resolveLiveWsUrl({ handshakePort: 70000 as number, defaultUrl: DEFAULT_URL }), + DEFAULT_URL + ); + }); + + it("applies the port and the path together", () => { + const url = new URL( + resolveLiveWsUrl({ handshakePort: 9443, handshakePath: "/ws/live", defaultUrl: DEFAULT_URL }) + ); + assert.equal(url.port, "9443"); + assert.equal(url.pathname, "/ws/live"); + }); + + it("ignores a path that is not a path", () => { + const url = new URL(resolveLiveWsUrl({ handshakePath: "live-ws", defaultUrl: DEFAULT_URL })); + assert.equal(url.pathname, "/live-ws"); + }); + + it("lets a complete publicUrl win over the reported port", () => { + assert.equal( + resolveLiveWsUrl({ + handshakeUrl: "wss://omniroute.example.tld/live-ws", + handshakePort: 20140, + defaultUrl: DEFAULT_URL, + }), + "wss://omniroute.example.tld/live-ws" + ); + }); + + it("lets an explicit wsUrl win over everything", () => { + assert.equal( + resolveLiveWsUrl({ + explicit: "wss://elsewhere.example/socket", + handshakeUrl: "wss://omniroute.example.tld/live-ws", + handshakePort: 20140, + defaultUrl: DEFAULT_URL, + }), + "wss://elsewhere.example/socket" + ); + }); + + it("falls back to the default rather than throwing on an unparseable default", () => { + assert.equal(resolveLiveWsUrl({ handshakePort: 20140, defaultUrl: "not a url" }), "not a url"); + }); + + it("leaves deriveLiveWsPath alone", () => { + assert.equal(deriveLiveWsPath("wss://host:20132/ws/live"), "/ws/live"); + assert.equal(deriveLiveWsPath("wss://host:20132/"), "/live-ws"); + assert.equal(deriveLiveWsPath(undefined), "/live-ws"); + }); +}); diff --git a/tests/unit/model-supported-endpoints.test.ts b/tests/unit/model-supported-endpoints.test.ts new file mode 100644 index 0000000000..30d93f67fc --- /dev/null +++ b/tests/unit/model-supported-endpoints.test.ts @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + classifyModelSupportedEndpoints, + MODEL_SUPPORTED_ENDPOINT_VALUES, + normalizeModelSupportedEndpoints, +} from "../../src/shared/constants/modelSupportedEndpoints.ts"; + +test("normalizes legacy video and audio metadata to operation-specific endpoint ids", () => { + assert.deepEqual(normalizeModelSupportedEndpoints(["chat", "video", "audio"]), [ + "chat", + "videos", + "audio-speech", + "audio-transcriptions", + ]); +}); + +test("deduplicates canonical endpoint ids while preserving order", () => { + assert.deepEqual( + normalizeModelSupportedEndpoints([ + "videos", + "video", + "audio-speech", + "audio", + "audio-transcriptions", + ]), + ["videos", "audio-speech", "audio-transcriptions"] + ); +}); + +test("exports operation-specific values accepted by model metadata", () => { + assert.ok(MODEL_SUPPORTED_ENDPOINT_VALUES.includes("videos")); + assert.ok(MODEL_SUPPORTED_ENDPOINT_VALUES.includes("audio-speech")); + assert.ok(MODEL_SUPPORTED_ENDPOINT_VALUES.includes("audio-transcriptions")); +}); + +test("preserves endpoint ids introduced by external discovery", () => { + assert.deepEqual(normalizeModelSupportedEndpoints(["responses", "video"]), [ + "responses", + "videos", + ]); +}); + +test("classifies operation-specific media endpoints for the model catalog", () => { + assert.deepEqual(classifyModelSupportedEndpoints(["videos"]), { type: "video" }); + assert.deepEqual(classifyModelSupportedEndpoints(["audio-speech"]), { + type: "audio", + subtype: "speech", + }); + assert.deepEqual(classifyModelSupportedEndpoints(["audio-transcriptions"]), { + type: "audio", + subtype: "transcription", + }); + assert.deepEqual(classifyModelSupportedEndpoints(["audio-speech", "audio-transcriptions"]), { + type: "audio", + }); +}); diff --git a/tests/unit/provider-model-endpoint-schema.test.ts b/tests/unit/provider-model-endpoint-schema.test.ts new file mode 100644 index 0000000000..145ab449c4 --- /dev/null +++ b/tests/unit/provider-model-endpoint-schema.test.ts @@ -0,0 +1,16 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { providerModelMutationSchema } from "../../src/shared/validation/schemas/provider.ts"; + +test("provider model mutations accept video and persist canonical operation endpoints", () => { + const parsed = providerModelMutationSchema.parse({ + provider: "example", + modelId: "media-model", + apiFormat: "video", + supportedEndpoints: ["video", "audio"], + }); + + assert.equal(parsed.apiFormat, "video"); + assert.deepEqual(parsed.supportedEndpoints, ["videos", "audio-speech", "audio-transcriptions"]); +}); diff --git a/tests/unit/security-s1-s2-s4.test.ts b/tests/unit/security-s1-s2-s4.test.ts new file mode 100644 index 0000000000..74dc6152af --- /dev/null +++ b/tests/unit/security-s1-s2-s4.test.ts @@ -0,0 +1,353 @@ +/** + * Security compliance tickets S1, S2, S4 — unit tests. + * + * S1 — Login rate-limit key uses anti-spoofed peer IP (x-omniroute-trusted-peer-ip) + * S2 — A2A agent-card topology sanitisation (no hardcoded localhost:20128) + * S4 — 429 Retry-After header always present on lockout responses + */ +import { describe, it, beforeEach, 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"; +import type { NextRequest } from "next/server"; + +// ── S2: agent-card route tests (no heavy mocking needed) ────────────── + +describe("S2 — agent-card topology sanitisation", () => { + const BASE_URL_SAVED = process.env.OMNIROUTE_BASE_URL; + + beforeEach(() => { + delete process.env.OMNIROUTE_BASE_URL; + }); + + after(() => { + if (BASE_URL_SAVED !== undefined) { + process.env.OMNIROUTE_BASE_URL = BASE_URL_SAVED; + } else { + delete process.env.OMNIROUTE_BASE_URL; + } + }); + + it("agent-card.json derives URL from request.nextUrl.origin when OMNIROUTE_BASE_URL is unset", async () => { + const mod = await import("../../src/app/.well-known/agent-card.json/route.ts"); + const request = new Request("https://gateway.example.com/.well-known/agent-card.json") as unknown as NextRequest; + Object.defineProperty(request, "nextUrl", { + value: new URL("https://gateway.example.com/.well-known/agent-card.json"), + configurable: true, + }); + + const res = await mod.GET(request); + assert.equal(res.status, 200); + const card = (await res.json()) as { url?: string; supportedInterfaces?: { url?: string }[] }; + assert.ok(card.url, "card must have a url"); + assert.ok(card.url.startsWith("https://gateway.example.com"), `expected gateway.example.com, got ${card.url}`); + if (card.supportedInterfaces && card.supportedInterfaces.length > 0) { + assert.ok( + card.supportedInterfaces[0].url?.startsWith("https://gateway.example.com"), + `interface URL should use dynamic origin, got ${card.supportedInterfaces[0].url}` + ); + } + }); + + it("agent-card.json uses OMNIROUTE_BASE_URL when set", async () => { + process.env.OMNIROUTE_BASE_URL = "https://custom.example.com"; + const mod = await import("../../src/app/.well-known/agent-card.json/route.ts"); + const request = new Request("http://localhost:20128/.well-known/agent-card.json") as unknown as NextRequest; + Object.defineProperty(request, "nextUrl", { + value: new URL("http://localhost:20128/.well-known/agent-card.json"), + configurable: true, + }); + + const res = await mod.GET(request); + assert.equal(res.status, 200); + const card = (await res.json()) as { url?: string }; + assert.ok(card.url?.startsWith("https://custom.example.com"), `expected custom.example.com, got ${card.url}`); + }); + + it("agent.json derives URL from request.nextUrl.origin when OMNIROUTE_BASE_URL is unset", async () => { + const mod = await import("../../src/app/.well-known/agent.json/route.ts"); + const request = new Request("https://gateway.example.com/.well-known/agent.json") as unknown as NextRequest; + Object.defineProperty(request, "nextUrl", { + value: new URL("https://gateway.example.com/.well-known/agent.json"), + configurable: true, + }); + + const res = await mod.GET(request); + assert.equal(res.status, 200); + const card = (await res.json()) as { url?: string }; + assert.ok(card.url?.startsWith("https://gateway.example.com"), `expected gateway.example.com, got ${card.url}`); + }); +}); + +// ── Login guard module (loaded once for S4 tests) ───────────────────── +const loginGuardMod = await import("../../src/server/auth/loginGuard"); + +// ── S4: login guard Retry-After tests ───────────────────────────────── + +describe("S4 — 429 Retry-After header", () => { + const { + checkLoginGuard, + recordLoginFailure, + resetLoginGuardForTests, + LOGIN_GUARD_TUNABLES, + } = loginGuardMod; + + beforeEach(() => { + resetLoginGuardForTests(); + }); + + it("checkLoginGuard returns retryAfterSeconds when locked", () => { + const ip = "10.0.0.99"; + for (let i = 0; i < LOGIN_GUARD_TUNABLES.FAILURE_THRESHOLD; i++) { + recordLoginFailure(ip, { enabled: true }); + } + const decision = checkLoginGuard(ip, { enabled: true }); + assert.equal(decision.allowed, false); + assert.ok(typeof decision.retryAfterSeconds === "number" && decision.retryAfterSeconds > 0, + `retryAfterSeconds should be > 0, got ${decision.retryAfterSeconds}`); + }); + + it("recordLoginFailure returns retryAfterSeconds on threshold hit", () => { + const ip = "10.0.0.100"; + for (let i = 0; i < LOGIN_GUARD_TUNABLES.FAILURE_THRESHOLD; i++) { + const dec = recordLoginFailure(ip, { enabled: true }); + if (i < LOGIN_GUARD_TUNABLES.FAILURE_THRESHOLD - 1) { + assert.equal(dec.allowed, true, `attempt #${i + 1} should still be allowed`); + } else { + assert.equal(dec.allowed, false, `attempt #${i + 1} (threshold) should be locked`); + assert.ok(typeof dec.retryAfterSeconds === "number" && dec.retryAfterSeconds > 0, + `retryAfterSeconds should be > 0 on threshold hit, got ${dec.retryAfterSeconds}`); + } + } + }); + + it("both guard functions provide retryAfterSeconds for the response header", () => { + const ip = "10.0.0.101"; + for (let i = 0; i < LOGIN_GUARD_TUNABLES.FAILURE_THRESHOLD; i++) { + recordLoginFailure(ip, { enabled: true }); + } + const guardDec = checkLoginGuard(ip, { enabled: true }); + assert.equal(guardDec.allowed, false); + const headerValue = String(guardDec.retryAfterSeconds || 60); + assert.ok(/^\d+$/.test(headerValue), `Retry-After should be an integer string, got ${headerValue}`); + assert.ok(Number.parseInt(headerValue, 10) > 0, "Retry-After should be positive"); + + resetLoginGuardForTests(); + const ip2 = "10.0.0.102"; + let failureDec: ReturnType | undefined; + for (let i = 0; i < LOGIN_GUARD_TUNABLES.FAILURE_THRESHOLD; i++) { + failureDec = recordLoginFailure(ip2, { enabled: true }); + } + assert.equal(failureDec!.allowed, false); + const headerValue2 = String(failureDec!.retryAfterSeconds || 60); + assert.ok(/^\d+$/.test(headerValue2), `Retry-After should be an integer string, got ${headerValue2}`); + assert.ok(Number.parseInt(headerValue2, 10) > 0, "Retry-After should be positive"); + }); +}); + +// ── S1: login route uses trusted peer IP for rate-limit key ─────────── +// Integration test: sets up the real DB, management password, and settings, +// then calls the login route POST function to verify the clientIp derivation. +// The route uses: clientIp = request.headers.get("x-omniroute-trusted-peer-ip") || auditContext.ipAddress || null + +describe("S1 — login rate-limit key uses anti-spoofed peer IP", () => { + const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-security-s1-s2-s4-")); + const JWT_SAVED = process.env.JWT_SECRET; + const INITIAL_PASSWORD_SAVED = process.env.INITIAL_PASSWORD; + + let loginRoute: typeof import("../../src/app/api/auth/login/route.ts"); + let loginGuardModRef: typeof import("../../src/server/auth/loginGuard"); + let settingsDb: typeof import("../../src/lib/db/settings.ts"); + + beforeEach(async () => { + // Reset env + process.env.DATA_DIR = TEST_DATA_DIR; + process.env.JWT_SECRET = "test-jwt-secret-for-s1-s2-s4-tests"; + // Use a bcrypt hash of "test-password" as the initial password so the + // login route already has a valid hash in the DB settings. + process.env.INITIAL_PASSWORD = "test-password"; + delete process.env.OMNIROUTE_PEER_STAMP_TOKEN; + delete process.env.OMNIROUTE_BASE_URL; + + // Create data dir + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + + // Reset DB and set up settings + const core = await import("../../src/lib/db/core.ts"); + core.resetDbInstance(); + settingsDb = await import("../../src/lib/db/settings.ts"); + await settingsDb.updateSettings({ bruteForceProtection: true }); + + // Import login guard and reset state + loginGuardModRef = await import("../../src/server/auth/loginGuard"); + loginGuardModRef.resetLoginGuardForTests(); + + // Now import the login route + loginRoute = await import("../../src/app/api/auth/login/route.ts"); + }); + + after(() => { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + if (JWT_SAVED !== undefined) { + process.env.JWT_SECRET = JWT_SAVED; + } else { + delete process.env.JWT_SECRET; + } + // Restore INITIAL_PASSWORD + if (INITIAL_PASSWORD_SAVED !== undefined) { + process.env.INITIAL_PASSWORD = INITIAL_PASSWORD_SAVED; + } else { + delete process.env.INITIAL_PASSWORD; + } + }); + + it("uses x-omniroute-trusted-peer-ip for rate-limit key when header is present", async () => { + // The login route derives clientIp from the trusted peer IP header. + // We make multiple requests with the same trusted peer IP but different + // forged XFF headers to verify they share the same rate-limit bucket. + // + // The route only trusts the header when OMNIROUTE_PEER_STAMP_TOKEN is set. + // Without the token, spoofed headers are rejected (tested separately below). + + process.env.OMNIROUTE_PEER_STAMP_TOKEN = "test-stamp-token"; + + const TRUSTED_IP = "203.0.113.42"; + const FORGED_XFF = "192.168.1.1, 10.0.0.1"; + + // Make enough requests to trigger the rate limit + for (let i = 0; i < loginGuardMod.LOGIN_GUARD_TUNABLES.FAILURE_THRESHOLD + 1; i++) { + const request = new Request("http://localhost:20128/api/auth/login", { + method: "POST", + headers: { + "content-type": "application/json", + "x-omniroute-trusted-peer-ip": TRUSTED_IP, + "x-forwarded-for": i === 0 ? FORGED_XFF : `10.0.0.${i}, 172.16.0.1`, + }, + body: JSON.stringify({ password: "wrong-password" }), + }) as unknown as NextRequest; + Object.defineProperty(request, "nextUrl", { + value: new URL("http://localhost:20128/api/auth/login"), + configurable: true, + }); + + // The email is not checked in the login route — only password matters + // Let's also try the correct password to make sure login works + const res = await loginRoute.POST(request); + if (res.status === 429) { + // Locked out — rate-limit key is tied to the trusted peer IP, not XFF + const retryAfter = res.headers.get("Retry-After"); + assert.ok(retryAfter !== null, "429 response must include Retry-After header"); + assert.ok(/^\d+$/.test(retryAfter!), `Retry-After should be a positive integer, got ${retryAfter}`); + return; + } + } + assert.fail("Expected at least one 429 response after threshold failed attempts with the same trusted peer IP"); + }); + + it("ignores spoofed x-omniroute-trusted-peer-ip when OMNIROUTE_PEER_STAMP_TOKEN is not set", async () => { + loginGuardModRef.resetLoginGuardForTests(); + + // OMNIROUTE_PEER_STAMP_TOKEN is already deleted in beforeEach. + // The route should NOT trust the spoofed header and fall back to + // auditContext.ipAddress (derived from X-Forwarded-For). + // + // TDD: each iteration uses a DIFFERENT spoofed IP. With the bug + // (unconditional trust), each request goes to a different rate-limit + // bucket — no bucket reaches the threshold → test FAILS (RED). + // With the fix (gate on OMNIROUTE_PEER_STAMP_TOKEN), all requests + // share the REAL_IP bucket → threshold hit → test PASSES (GREEN). + + const REAL_IP = "10.0.0.200"; + + for (let i = 0; i < loginGuardMod.LOGIN_GUARD_TUNABLES.FAILURE_THRESHOLD + 1; i++) { + const SPOOFED_IP = `203.0.113.${i}`; + const request = new Request("http://localhost:20128/api/auth/login", { + method: "POST", + headers: { + "content-type": "application/json", + "x-omniroute-trusted-peer-ip": SPOOFED_IP, + "x-forwarded-for": REAL_IP, + }, + body: JSON.stringify({ password: "wrong-password" }), + }) as unknown as NextRequest; + Object.defineProperty(request, "nextUrl", { + value: new URL("http://localhost:20128/api/auth/login"), + configurable: true, + }); + + const res = await loginRoute.POST(request); + if (res.status === 429) { + // Locked out — rate-limit key is tied to REAL_IP (XFF), not the spoofed header + const retryAfter = res.headers.get("Retry-After"); + assert.ok(retryAfter !== null, "429 response must include Retry-After header"); + return; + } + } + assert.fail("Expected 429 after threshold failures — spoofed header should not bypass rate-limit"); + }); + + it("falls back to auditContext.ipAddress when trusted peer IP header is absent", async () => { + loginGuardModRef.resetLoginGuardForTests(); + + // Without the trusted peer IP header, the rate-limit key falls back to + // auditContext.ipAddress which reads from X-Forwarded-For / X-Real-IP. + // We set XFF to a specific IP and verify that requests with that IP get + // rate-limited, while requests with a different IP do not. + + const REQUEST_IP = "10.0.0.99"; + + for (let i = 0; i < loginGuardMod.LOGIN_GUARD_TUNABLES.FAILURE_THRESHOLD + 1; i++) { + const request = new Request("http://localhost:20128/api/auth/login", { + method: "POST", + headers: { + "content-type": "application/json", + "x-forwarded-for": REQUEST_IP, + }, + body: JSON.stringify({ password: "wrong-password" }), + }) as unknown as NextRequest; + Object.defineProperty(request, "nextUrl", { + value: new URL("http://localhost:20128/api/auth/login"), + configurable: true, + }); + + const res = await loginRoute.POST(request); + if (res.status === 429) { + // Locked out — rate-limit key is tied to the XFF-derived IP + const retryAfter = res.headers.get("Retry-After"); + assert.ok(retryAfter !== null, "429 response must include Retry-After header"); + assert.ok(Number.parseInt(retryAfter!, 10) > 0, `Retry-After should be > 0, got ${retryAfter}`); + return; + } + } + assert.fail("Expected 429 after threshold failures from the same IP"); + }); + + it("S4 — 429 response includes Retry-After header in login route", async () => { + loginGuardModRef.resetLoginGuardForTests(); + + for (let i = 0; i < loginGuardMod.LOGIN_GUARD_TUNABLES.FAILURE_THRESHOLD + 1; i++) { + const request = new Request("http://localhost:20128/api/auth/login", { + method: "POST", + headers: { + "content-type": "application/json", + "x-omniroute-trusted-peer-ip": "203.0.113.99", + }, + body: JSON.stringify({ password: "wrong-password" }), + }) as unknown as NextRequest; + Object.defineProperty(request, "nextUrl", { + value: new URL("http://localhost:20128/api/auth/login"), + configurable: true, + }); + + const res = await loginRoute.POST(request); + if (res.status === 429) { + const retryAfter = res.headers.get("Retry-After"); + assert.ok(retryAfter !== null, "429 response must include Retry-After header"); + assert.ok(Number.parseInt(retryAfter!, 10) > 0, `Retry-After should be > 0, got ${retryAfter}`); + return; + } + } + assert.fail("Expected at least one 429 response after threshold failed attempts"); + }); +}); \ No newline at end of file diff --git a/tests/unit/static-model-operation-endpoints.test.ts b/tests/unit/static-model-operation-endpoints.test.ts new file mode 100644 index 0000000000..be9605afa4 --- /dev/null +++ b/tests/unit/static-model-operation-endpoints.test.ts @@ -0,0 +1,20 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { getStaticModelsForProvider } from "../../src/lib/providers/staticModels.ts"; + +test("speech-only static models advertise the speech operation", () => { + const models = getStaticModelsForProvider("elevenlabs") || []; + + assert.ok(models.length > 0); + assert.ok(models.every((model) => model.supportedEndpoints?.includes("audio-speech"))); + assert.ok(models.every((model) => !model.supportedEndpoints?.includes("audio"))); +}); + +test("transcription-only static models advertise the transcription operation", () => { + const models = getStaticModelsForProvider("gladia") || []; + + assert.ok(models.length > 0); + assert.ok(models.every((model) => model.supportedEndpoints?.includes("audio-transcriptions"))); + assert.ok(models.every((model) => !model.supportedEndpoints?.includes("audio"))); +}); diff --git a/tests/unit/sync-env.test.ts b/tests/unit/sync-env.test.ts index 415e689a3b..6804e0917a 100644 --- a/tests/unit/sync-env.test.ts +++ b/tests/unit/sync-env.test.ts @@ -52,7 +52,7 @@ function writeOauthEnvExample(rootDir: string) { ); } -test("syncEnv creates .env from .env.example and generates install-time secrets", () => { +test("syncEnv creates .env from .env.example and leaves runtime-owned secrets blank", () => { const rootDir = createTempRoot(); // Temporarily override DATA_DIR so the encrypted-credentials guard doesn't @@ -66,8 +66,13 @@ test("syncEnv creates .env from .env.example and generates install-time secrets" const envContent = fs.readFileSync(path.join(rootDir, ".env"), "utf8"); assert.deepEqual(result, { created: true, added: 7 }); - assert.match(envContent, /^JWT_SECRET=.{32,}$/m); - assert.match(envContent, /^API_KEY_SECRET=.{32,}$/m); + // The three secrets the server provisions itself stay blank here. Filling + // them in the package directory hides ensureSecrets() (instrumentation-node), + // which restores them from the durable store or generates and persists them + // there — so a pre-filled value is silently replaced by a new one on every + // reinstall. STORAGE_ENCRYPTION_KEY was pulled out for that reason (#1622). + assert.match(envContent, /^JWT_SECRET=$/m); + assert.match(envContent, /^API_KEY_SECRET=$/m); assert.match(envContent, /^STORAGE_ENCRYPTION_KEY=$/m); assert.match(envContent, /^MACHINE_ID_SALT=omniroute-/m); assert.match(envContent, /^CLAUDE_OAUTH_CLIENT_ID=claude-default$/m); @@ -103,7 +108,7 @@ test("syncEnv appends only missing keys and preserves existing values", () => { assert.deepEqual(result, { created: false, added: 5 }); assert.match(envContent, /^JWT_SECRET=my-custom-secret-that-should-stay$/m); assert.match(envContent, /^CLAUDE_OAUTH_CLIENT_ID=custom-claude$/m); - assert.match(envContent, /^API_KEY_SECRET=.{32,}$/m); + assert.match(envContent, /^API_KEY_SECRET=$/m); assert.match(envContent, /^STORAGE_ENCRYPTION_KEY=$/m); assert.match(envContent, /^MACHINE_ID_SALT=omniroute-/m); assert.match(envContent, /^CODEX_OAUTH_CLIENT_ID=codex-default$/m); diff --git a/tests/unit/token-health-check-kimi.test.ts b/tests/unit/token-health-check-kimi.test.ts index 87b8169528..aaf6950d07 100644 --- a/tests/unit/token-health-check-kimi.test.ts +++ b/tests/unit/token-health-check-kimi.test.ts @@ -1,6 +1,9 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { checkKimiWebConnectionIfNeeded } from "../../src/lib/tokenHealthCheckKimi.ts"; +import { + checkKimiWebConnectionIfNeeded, + defaultKimiRefreshJitterSec, +} from "../../src/lib/tokenHealthCheckKimi.ts"; describe("Kimi Background Health Sweep", () => { it("skips non-kimi-web connections", async () => { @@ -16,9 +19,12 @@ describe("Kimi Background Health Sweep", () => { assert.equal(handled, false); }); - it("triggers refresh when Kimi token is within jittered expiration window", async () => { + it("triggers refresh when the token is inside the refresh window", async () => { const nowSec = Math.floor(Date.now() / 1000); - // Token expiring in 90 seconds (within 60-240s window) + // Token expiring in 90 seconds. The window is decided by the caller here, not + // drawn: with the default spread of [60, 240) a 90 s token is refreshed only + // when the draw lands >= 90, which is 150 of 180 values — so this assertion + // used to fail 1 run in 6, and did so on the Node 26 nightly (#11361). const token = "eyJhbGciOiJIUzUxMiJ9." + Buffer.from(JSON.stringify({ exp: nowSec + 90, iat: nowSec })).toString("base64url") + @@ -38,6 +44,7 @@ describe("Kimi Background Health Sweep", () => { logError: () => {}, getConnectionLogLabel: () => "kimi-web-1", logPrefix: "[Test]", + jitterSecFn: () => 120, exchangeFn: async () => { calledRefresh = true; return { @@ -53,4 +60,51 @@ describe("Kimi Background Health Sweep", () => { assert.equal(handled, true); assert.equal(calledRefresh, true); }); + + it("leaves a token outside the window alone", async () => { + const nowSec = Math.floor(Date.now() / 1000); + const token = + "eyJhbGciOiJIUzUxMiJ9." + + Buffer.from(JSON.stringify({ exp: nowSec + 900, iat: nowSec })).toString("base64url") + + ".sig"; + + let calledRefresh = false; + const handled = await checkKimiWebConnectionIfNeeded({ + conn: { + id: "kimi-conn-2", + provider: "kimi-web", + apiKey: token, + refreshToken: "refresh_123", + }, + now: new Date().toISOString(), + log: () => {}, + logWarn: () => {}, + logError: () => {}, + getConnectionLogLabel: () => "kimi-web-2", + logPrefix: "[Test]", + jitterSecFn: () => 240, + exchangeFn: async () => { + calledRefresh = true; + return { + success: true, + accessToken: "new_token", + refreshToken: "new_refresh", + expiresAtSec: nowSec + 900, + }; + }, + persistFn: async () => {}, + }); + + // Handled (it is a kimi-web connection) but not refreshed. + assert.equal(handled, true); + assert.equal(calledRefresh, false); + }); + + it("the default spread stays inside [60, 240)", () => { + for (let i = 0; i < 2_000; i++) { + const jitter = defaultKimiRefreshJitterSec(); + assert.ok(Number.isInteger(jitter), `jitter must be whole seconds, got ${jitter}`); + assert.ok(jitter >= 60 && jitter < 240, `jitter out of range: ${jitter}`); + } + }); });