fix(release): repair v3.8.50 base-red tail after latest root lift (#10964)

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!
This commit is contained in:
backryun
2026-08-23 11:09:16 +09:00
committed by GitHub
parent 2dd20331a7
commit 79c5bdf681
35 changed files with 328 additions and 120 deletions

View File

@@ -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` | `<KIMI_WEB_BASE_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,

View File

@@ -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: [

View File

@@ -399,7 +399,24 @@ export class GlmExecutor extends DefaultExecutor {
): Promise<GlmExecuteResult> {
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);

View File

@@ -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,

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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 {}

View File

@@ -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.",

View File

@@ -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({

View File

@@ -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.",

View File

@@ -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. */

View File

@@ -2240,6 +2240,7 @@ async function handleSingleModelChat(
if (
!runtimeOptions.emergencyFallbackTried &&
!comboName &&
!forceLiveComboTest &&
shouldRetrySameAccountTransport({
status: result.status,
errorText: errorStr,

View File

@@ -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<RecoverableConnectionState> | null,
credentials: unknown,
expectedState?: RecoveredStateExpectation
): Promise<{ applied: boolean }> {
if (!credentials?.connectionId) return { applied: false };
const recoverable = credentials as Partial<RecoverableConnectionState> | 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 = {

View File

@@ -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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).evictedAt = Date.now();
const fallback = pickAccount([a, b], { nextAccountIdx: 0 }, (x) => isAccountReady(x) && !(x as Record<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>)(tmp, "terminal"));
assert.doesNotThrow(() => mc(tmp, "terminal"));
});

View File

@@ -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",

View File

@@ -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");

View File

@@ -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",
});
});

View File

@@ -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(

View File

@@ -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(

View File

@@ -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}`
);

View File

@@ -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",

View File

@@ -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");

View File

@@ -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 },
},
});
});

View File

@@ -86,7 +86,7 @@ const EXPECTED: Record<InventoryKind, Record<string, number>> = {
"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,

View File

@@ -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 {

View File

@@ -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"
);
});

View File

@@ -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`),

View File

@@ -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);
});

View File

@@ -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);
});

View File

@@ -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");

View File

@@ -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";

View File

@@ -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]

View File

@@ -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();

View File

@@ -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<string, unknown>).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<string, unknown>);
const id = String((conn as unknown as Record<string, unknown>).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<string, unknown>);
const id = String((conn as unknown as Record<string, unknown>).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");

View File

@@ -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,
},
});