mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 13:22:11 +03:00
fix(review): resolve /review-reviews battery findings (LEDGER-1..11) on v3.8.14 (#3350)
Integrated into release/v3.8.14 — /review-reviews battery hardening (LEDGER-1..11) for #3338 custom-headers + #3333 kiro, plus cycle-test drift fixes (#3329/#3330/#3332).
This commit is contained in:
committed by
GitHub
parent
559b97c0e8
commit
9c2beb7312
@@ -21,6 +21,14 @@ _Development cycle in progress — entries are added as work merges into `releas
|
||||
- **fix(startup):** correct the #3292 auto-refresh daemon import (`@/open-sse/...` → `@omniroute/open-sse/services/autoRefreshDaemon`); the `@/` alias maps to `src/`, so the daemon silently never ran in the built standalone (non-fatal "Cannot find module", caught at runtime). Adds a regression test banning `@/open-sse/*` imports in `src/`. ([#3335](https://github.com/diegosouzapw/OmniRoute/pull/3335) — thanks @diegosouzapw)
|
||||
- **fix(electron):** wrap `autoUpdater.checkForUpdates()` so a 404/offline/rate-limited update check can no longer surface as an unhandled rejection (the `error` event still notifies the user); fixes the macOS-intel packaged-app smoke failure. ([#3339](https://github.com/diegosouzapw/OmniRoute/pull/3339) — thanks @diegosouzapw)
|
||||
|
||||
### 📝 Maintenance
|
||||
|
||||
- **fix(review):** harden the per-provider custom-headers feature surfaced by the `/review-reviews` battery — `updateProviderNode` no longer wipes stored `custom_headers_json` on a partial update that omits the field; `customHeadersSchema` reuses the canonical `upstreamHeadersRecordSchema` guards (CRLF/control-char/length/16-max) and rejects auth header names via a single shared `isForbiddenCustomHeaderName()` denylist (executor + schema no longer keep divergent copies); custom headers now reach the wire for `anthropic-compatible-cc-*` nodes and override the executor's own `Content-Type`/`Accept` case-insensitively instead of duplicating them; and `rowToCamel` normalizes a NULL `_json` column to `baseKey: null`. (thanks @diegosouzapw)
|
||||
- **fix(catalog):** flag every `minimax-m3` registry entry `supportsVision` (not just the opencode free tier) so the vision-bridge guardrail and the compression layer agree the model is multimodal on all tiers (completes #3328). (thanks @diegosouzapw)
|
||||
- **fix(oauth):** Kiro Builder ID import forwards the requested `region` to the OIDC validation refresh (no longer pinned to `us-east-1`), prefers the region-matching cached SSO client registration over the first file found, and falls `expiresIn` back to 3600 on the OIDC path. (thanks @diegosouzapw)
|
||||
- **fix(db):** migration `095` gains an `isSchemaAlreadyApplied` guard so a fresh DB (where `SCHEMA_SQL` already creates `custom_headers_json`) skips it cleanly instead of throwing-then-catching a duplicate-column error. (thanks @diegosouzapw)
|
||||
- **test:** align stale cycle tests with shipped behavior — NVIDIA `minimaxai/minimax-m3` removal (#3329), the 29th feature flag (`PROXY_AUTO_SELECT_ENABLED`, #3332), and the OpenCode `~/.config` path on Windows (#3330). (thanks @diegosouzapw)
|
||||
|
||||
---
|
||||
|
||||
## [3.8.13] — 2026-06-06
|
||||
|
||||
@@ -1035,7 +1035,12 @@ const _REGISTRY_EAGER: Record<string, RegistryEntry> = {
|
||||
{ id: "gpt-5-mini", name: "GPT-5 Mini", targetFormat: "openai-responses" },
|
||||
{ id: "gpt-5.3-codex", name: "GPT-5.3 Codex", targetFormat: "openai-responses" },
|
||||
{ id: "gpt-5.4-mini", name: "GPT-5.4 Mini", targetFormat: "openai-responses" },
|
||||
{ id: "gpt-5.4", name: "GPT-5.4", targetFormat: "openai-responses", supportsXHighEffort: true },
|
||||
{
|
||||
id: "gpt-5.4",
|
||||
name: "GPT-5.4",
|
||||
targetFormat: "openai-responses",
|
||||
supportsXHighEffort: true,
|
||||
},
|
||||
{ id: "gpt-5.5", name: "GPT-5.5", ...GPT_5_5_CODEX_CAPABILITIES },
|
||||
{
|
||||
id: "claude-haiku-4.5",
|
||||
@@ -1224,7 +1229,7 @@ const _REGISTRY_EAGER: Record<string, RegistryEntry> = {
|
||||
{ id: "gemini-3.1-pro", name: "Gemini 3.1 Pro" },
|
||||
{ id: "gemini-3-flash-solo", name: "Gemini 3 Flash" },
|
||||
// #3110: MiniMax M3 via Trae
|
||||
{ id: "minimax-m3", name: "MiniMax M3", contextLength: 1048576 },
|
||||
{ id: "minimax-m3", name: "MiniMax M3", contextLength: 1048576, supportsVision: true },
|
||||
{ id: "minimax-m2.7", name: "MiniMax M2.7" },
|
||||
{ id: "kimi-k2.5", name: "Kimi K2.5" },
|
||||
{ id: "gpt-5.4", name: "GPT 5.4" },
|
||||
@@ -1460,7 +1465,13 @@ const _REGISTRY_EAGER: Record<string, RegistryEntry> = {
|
||||
{ id: "mimo-v2-pro", name: "MiMo-V2-Pro" },
|
||||
{ id: "mimo-v2-omni", name: "MiMo-V2-Omni" },
|
||||
// #3110: MiniMax M3 via OpenCode Go tier
|
||||
{ id: "minimax-m3", name: "MiniMax M3", targetFormat: "claude", contextLength: 1048576 },
|
||||
{
|
||||
id: "minimax-m3",
|
||||
name: "MiniMax M3",
|
||||
targetFormat: "claude",
|
||||
contextLength: 1048576,
|
||||
supportsVision: true,
|
||||
},
|
||||
{ id: "minimax-m2.7", name: "MiniMax M2.7", targetFormat: "claude" },
|
||||
{ id: "minimax-m2.5", name: "MiniMax M2.5", targetFormat: "claude" },
|
||||
// Issue #2292: Qwen models on opencode-go reject oa-compat format
|
||||
@@ -1546,7 +1557,7 @@ const _REGISTRY_EAGER: Record<string, RegistryEntry> = {
|
||||
|
||||
// ── MiniMax ────────────────────────────────────────────────
|
||||
// #3110: MiniMax M3 — frontier coding model with 1M context
|
||||
{ id: "minimax-m3", name: "MiniMax M3", contextLength: 1048576 },
|
||||
{ id: "minimax-m3", name: "MiniMax M3", contextLength: 1048576, supportsVision: true },
|
||||
{ id: "minimax-m2.5", name: "MiniMax M2.5" },
|
||||
{ id: "minimax-m2.7", name: "MiniMax M2.7" },
|
||||
|
||||
@@ -2229,7 +2240,7 @@ const _REGISTRY_EAGER: Record<string, RegistryEntry> = {
|
||||
models: [
|
||||
// T12/T28: MiniMax default upgraded from M2.5 to M2.7
|
||||
// #3110: MiniMax M3 — frontier coding model with 1M context
|
||||
{ id: "MiniMax-M3", name: "MiniMax M3", contextLength: 1048576 },
|
||||
{ id: "MiniMax-M3", name: "MiniMax M3", contextLength: 1048576, supportsVision: true },
|
||||
{ id: "MiniMax-M2.7", name: "MiniMax M2.7" },
|
||||
{ id: "MiniMax-M2.7-highspeed", name: "MiniMax M2.7 Highspeed" },
|
||||
{ id: "MiniMax-M2.5", name: "MiniMax M2.5" },
|
||||
@@ -2252,7 +2263,7 @@ const _REGISTRY_EAGER: Record<string, RegistryEntry> = {
|
||||
models: [
|
||||
// Keep parity with minimax to ensure model discovery works for minimax-cn connections.
|
||||
// #3110: MiniMax M3 — frontier coding model with 1M context
|
||||
{ id: "MiniMax-M3", name: "MiniMax M3", contextLength: 1048576 },
|
||||
{ id: "MiniMax-M3", name: "MiniMax M3", contextLength: 1048576, supportsVision: true },
|
||||
{ id: "MiniMax-M2.7", name: "MiniMax M2.7" },
|
||||
{ id: "MiniMax-M2.7-highspeed", name: "MiniMax M2.7 Highspeed" },
|
||||
{ id: "MiniMax-M2.5", name: "MiniMax M2.5" },
|
||||
@@ -2711,7 +2722,7 @@ const _REGISTRY_EAGER: Record<string, RegistryEntry> = {
|
||||
{ id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro" },
|
||||
{ id: "mimo-v2.5", name: "MiMo-V2.5" },
|
||||
// #3110: MiniMax M3 via OpenCode Zen
|
||||
{ id: "minimax-m3", name: "MiniMax M3", contextLength: 1048576 },
|
||||
{ id: "minimax-m3", name: "MiniMax M3", contextLength: 1048576, supportsVision: true },
|
||||
{ id: "minimax-m2.7", name: "MiniMax M2.7" },
|
||||
{ id: "minimax-m2.5", name: "MiniMax M2.5" },
|
||||
{ id: "llama-4-maverick", name: "Llama 4 Maverick" },
|
||||
@@ -3184,7 +3195,7 @@ const _REGISTRY_EAGER: Record<string, RegistryEntry> = {
|
||||
{ id: "kimi-k2.6", name: "Kimi K2.6" },
|
||||
{ id: "glm-5.1", name: "GLM 5.1" },
|
||||
// #3110: MiniMax M3 via Ollama
|
||||
{ id: "minimax-m3", name: "MiniMax M3", contextLength: 1048576 },
|
||||
{ id: "minimax-m3", name: "MiniMax M3", contextLength: 1048576, supportsVision: true },
|
||||
{ id: "minimax-m2.7", name: "MiniMax M2.7" },
|
||||
{ id: "gemma4:31b", name: "Gemma 4 31B" },
|
||||
{ id: "nemotron-3-super", name: "NVIDIA Nemotron 3 Super" },
|
||||
@@ -4338,9 +4349,7 @@ const _REGISTRY_EAGER: Record<string, RegistryEntry> = {
|
||||
baseUrls: ["https://amelia.chipotle.com"],
|
||||
authType: "none",
|
||||
authHeader: "none",
|
||||
models: [
|
||||
{ id: "pepper-1", name: "Pepper (Chipotle AI 🌯)" },
|
||||
],
|
||||
models: [{ id: "pepper-1", name: "Pepper (Chipotle AI 🌯)" }],
|
||||
passthroughModels: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -29,9 +29,52 @@ import { buildOciChatUrl } from "../config/oci.ts";
|
||||
import { buildSapChatUrl, getSapResourceGroup } from "../config/sap.ts";
|
||||
import { buildMaritalkChatUrl } from "../config/maritalk.ts";
|
||||
import { LOCAL_PROVIDERS } from "@/shared/constants/providers";
|
||||
import { isForbiddenCustomHeaderName } from "@/shared/constants/upstreamHeaders";
|
||||
|
||||
import type { PoolConfig } from "../services/sessionPool/types.ts";
|
||||
|
||||
/**
|
||||
* Apply operator-configured per-provider custom headers onto an outgoing header
|
||||
* map. Defense-in-depth on top of the Zod `customHeadersSchema`:
|
||||
* - skip hop-by-hop/framing AND auth header names (canonical denylist, so a row
|
||||
* written before the schema tightening still can't override credential auth);
|
||||
* - skip control-char (CR/LF/NUL) names/values before they reach undici;
|
||||
* - assign case-insensitively, replacing any existing same-named header (e.g.
|
||||
* the executor's own Content-Type/Accept) instead of emitting a duplicate.
|
||||
* Used for every *-compatible node, INCLUDING anthropic-compatible-cc-* (whose
|
||||
* header builder returns early, so custom headers must be merged in explicitly).
|
||||
*/
|
||||
function applyCustomHeaders(headers: Record<string, string>, rawCustomHeaders: unknown): void {
|
||||
let customHeaders: Record<string, unknown> | null = null;
|
||||
if (
|
||||
rawCustomHeaders &&
|
||||
typeof rawCustomHeaders === "object" &&
|
||||
!Array.isArray(rawCustomHeaders)
|
||||
) {
|
||||
customHeaders = rawCustomHeaders as Record<string, unknown>;
|
||||
} else if (typeof rawCustomHeaders === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(rawCustomHeaders);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
customHeaders = parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
/* ignore invalid JSON */
|
||||
}
|
||||
}
|
||||
if (!customHeaders) return;
|
||||
for (const [k, v] of Object.entries(customHeaders)) {
|
||||
if (typeof k !== "string" || typeof v !== "string") continue;
|
||||
if (isForbiddenCustomHeaderName(k)) continue;
|
||||
if (/[\r\n\0]/.test(k) || /[\r\n]/.test(v)) continue;
|
||||
const lower = k.toLowerCase();
|
||||
for (const existing of Object.keys(headers)) {
|
||||
if (existing.toLowerCase() === lower) delete headers[existing];
|
||||
}
|
||||
headers[k] = v;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(baseUrl) {
|
||||
return (baseUrl || "").trim().replace(/\/$/, "");
|
||||
}
|
||||
@@ -375,11 +418,15 @@ export class DefaultExecutor extends BaseExecutor {
|
||||
break;
|
||||
default:
|
||||
if (isClaudeCodeCompatible(this.provider)) {
|
||||
return buildClaudeCodeCompatibleHeaders(
|
||||
const ccHeaders = buildClaudeCodeCompatibleHeaders(
|
||||
effectiveKey || credentials.accessToken || "",
|
||||
stream,
|
||||
credentials?.providerSpecificData?.ccSessionId
|
||||
);
|
||||
// CC nodes are also anthropic-compatible-*, so honor operator custom
|
||||
// headers here (the early return skips the shared block below).
|
||||
applyCustomHeaders(ccHeaders, credentials.providerSpecificData?.customHeaders);
|
||||
return ccHeaders;
|
||||
}
|
||||
if (this.provider?.startsWith?.("anthropic-compatible-")) {
|
||||
if (effectiveKey) {
|
||||
@@ -424,31 +471,7 @@ export class DefaultExecutor extends BaseExecutor {
|
||||
this.provider?.startsWith?.("anthropic-compatible-");
|
||||
|
||||
if (isCompatibleProvider) {
|
||||
const rawCustomHeaders = credentials.providerSpecificData?.customHeaders;
|
||||
let customHeaders: Record<string, string> | null = null;
|
||||
if (rawCustomHeaders && typeof rawCustomHeaders === "object" && !Array.isArray(rawCustomHeaders)) {
|
||||
customHeaders = rawCustomHeaders as Record<string, string>;
|
||||
} else if (typeof rawCustomHeaders === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(rawCustomHeaders);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
customHeaders = parsed;
|
||||
}
|
||||
} catch { /* ignore invalid JSON */ }
|
||||
}
|
||||
if (customHeaders) {
|
||||
const forbidden = new Set([
|
||||
"host", "connection", "content-length", "keep-alive",
|
||||
"proxy-connection", "transfer-encoding", "te", "trailer", "upgrade",
|
||||
]);
|
||||
const authHeaders = new Set(["authorization", "x-api-key", "x-goog-api-key", "api-key"]);
|
||||
for (const [k, v] of Object.entries(customHeaders)) {
|
||||
if (typeof k !== "string" || typeof v !== "string") continue;
|
||||
if (forbidden.has(k.toLowerCase())) continue;
|
||||
if (authHeaders.has(k.toLowerCase())) continue;
|
||||
headers[k] = v;
|
||||
}
|
||||
}
|
||||
applyCustomHeaders(headers, credentials.providerSpecificData?.customHeaders);
|
||||
}
|
||||
|
||||
// Forward client request metadata headers (from OpenCode or similar clients)
|
||||
|
||||
@@ -463,15 +463,21 @@ export function rowToCamel(row: unknown): JsonRecord | null {
|
||||
} catch {
|
||||
result[camelKey] = v;
|
||||
}
|
||||
} else if (camelKey.endsWith("Json") && typeof v === "string") {
|
||||
} else if (camelKey.endsWith("Json")) {
|
||||
// Convention: any column with a `_json` suffix is JSON-encoded TEXT.
|
||||
// Surface the parsed object under the friendlier name (key minus the
|
||||
// "Json" suffix) — e.g. quotaWindowThresholdsJson → quotaWindowThresholds.
|
||||
// A NULL/absent column normalizes to `baseKey: null` (not the suffixed
|
||||
// key) so read and write paths expose a consistent shape.
|
||||
const baseKey = camelKey.slice(0, -"Json".length);
|
||||
try {
|
||||
result[baseKey] = JSON.parse(v);
|
||||
} catch {
|
||||
result[baseKey] = null;
|
||||
if (typeof v === "string") {
|
||||
try {
|
||||
result[baseKey] = JSON.parse(v);
|
||||
} catch {
|
||||
result[baseKey] = null;
|
||||
}
|
||||
} else {
|
||||
result[baseKey] = v == null ? null : v;
|
||||
}
|
||||
} else {
|
||||
result[camelKey] = v;
|
||||
@@ -1273,7 +1279,7 @@ export function getDbInstance(): SqliteDatabase {
|
||||
) {
|
||||
throw e;
|
||||
}
|
||||
preservedCriticalState = captureCriticalDbState(sqliteFile);
|
||||
preservedCriticalState = captureCriticalDbState(sqliteFile);
|
||||
|
||||
// SAFETY: Never delete the database — rename to backup so data can be recovered.
|
||||
// The old code would silently destroy all user data on any probe failure.
|
||||
|
||||
@@ -399,6 +399,8 @@ function isSchemaAlreadyApplied(
|
||||
switch (migration.version) {
|
||||
case "003":
|
||||
return hasColumn(db, "provider_nodes", "chat_path");
|
||||
case "095":
|
||||
return hasColumn(db, "provider_nodes", "custom_headers_json");
|
||||
case "005":
|
||||
return hasColumn(db, "combos", "system_message");
|
||||
case "007":
|
||||
@@ -910,7 +912,9 @@ export function runMigrations(db: SqliteAdapter, options?: { isNewDb?: boolean }
|
||||
}
|
||||
return isMissing;
|
||||
});
|
||||
const deferredUnsupported = pending.filter((migration) => isDeferredUnsupportedMigration(db, migration));
|
||||
const deferredUnsupported = pending.filter((migration) =>
|
||||
isDeferredUnsupportedMigration(db, migration)
|
||||
);
|
||||
const actionablePending = pending.filter(
|
||||
(migration) => !deferredUnsupported.some((deferred) => deferred.version === migration.version)
|
||||
);
|
||||
@@ -920,7 +924,9 @@ export function runMigrations(db: SqliteAdapter, options?: { isNewDb?: boolean }
|
||||
}
|
||||
|
||||
if (deferredUnsupported.length > 0) {
|
||||
const summary = deferredUnsupported.map((migration) => `${migration.version}_${migration.name}`).join(", ");
|
||||
const summary = deferredUnsupported
|
||||
.map((migration) => `${migration.version}_${migration.name}`)
|
||||
.join(", ");
|
||||
console.warn(
|
||||
`[Migration] Deferring optional FTS5 migrations on driver ${db.driver}: ${summary}. ` +
|
||||
`Memory search will fall back until a SQLite driver with FTS5 support is available.`
|
||||
|
||||
@@ -372,9 +372,7 @@ export async function createProviderConnection(data: JsonRecord) {
|
||||
// Same sanitization for rateLimitOverrides — keep in-memory representation
|
||||
// in sync with what gets persisted.
|
||||
if ("rateLimitOverrides" in connection) {
|
||||
connection.rateLimitOverrides = sanitizeRateLimitOverrides(
|
||||
connection.rateLimitOverrides
|
||||
);
|
||||
connection.rateLimitOverrides = sanitizeRateLimitOverrides(connection.rateLimitOverrides);
|
||||
}
|
||||
|
||||
_insertConnectionRow(db, encryptConnectionFields({ ...connection }));
|
||||
@@ -835,6 +833,14 @@ export async function updateProviderNode(id: string, data: JsonRecord) {
|
||||
|
||||
if (data.customHeaders !== undefined) {
|
||||
merged["customHeadersJson"] = data.customHeaders ? JSON.stringify(data.customHeaders) : null;
|
||||
} else {
|
||||
// Partial update that omits customHeaders must PRESERVE the stored value.
|
||||
// rowToCamel surfaces the column under `customHeaders` (suffix stripped),
|
||||
// never `customHeadersJson`, so read the raw stored JSON from `existing`
|
||||
// directly instead of relying on the (absent) merged key — otherwise the
|
||||
// UPDATE would bind null and silently wipe the saved headers.
|
||||
const existingJson = (existing as JsonRecord).custom_headers_json;
|
||||
merged["customHeadersJson"] = typeof existingJson === "string" ? existingJson : null;
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
|
||||
@@ -226,7 +226,7 @@ export class KiroService {
|
||||
return {
|
||||
accessToken: retryData.accessToken,
|
||||
refreshToken: retryData.refreshToken || refreshToken,
|
||||
expiresIn: retryData.expiresIn,
|
||||
expiresIn: retryData.expiresIn || 3600,
|
||||
_newClientId: newReg.clientId,
|
||||
_newClientSecret: newReg.clientSecret,
|
||||
_newClientSecretExpiresAt: newReg.clientSecretExpiresAt,
|
||||
@@ -246,9 +246,13 @@ export class KiroService {
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
// Builder ID / IDC OIDC refresh: no profileArn (the social path supplies
|
||||
// one; Builder ID connections legitimately have none). expiresIn falls
|
||||
// back to 3600 so the import route never computes Date(NaN) if upstream
|
||||
// omits it (the social path already guards the same way).
|
||||
accessToken: data.accessToken,
|
||||
refreshToken: data.refreshToken || refreshToken,
|
||||
expiresIn: data.expiresIn,
|
||||
expiresIn: data.expiresIn || 3600,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -290,7 +294,7 @@ export class KiroService {
|
||||
}
|
||||
|
||||
// Try to read cached clientId/clientSecret from AWS SSO cache (Builder ID tokens)
|
||||
const cachedClient = await this.readCachedClientCredentials();
|
||||
const cachedClient = await this.readCachedClientCredentials(region);
|
||||
|
||||
// Attempt 1: Try Builder ID refresh using cached credentials
|
||||
if (cachedClient) {
|
||||
@@ -299,10 +303,16 @@ export class KiroService {
|
||||
clientId: cachedClient.clientId,
|
||||
clientSecret: cachedClient.clientSecret,
|
||||
authMethod: "builder-id",
|
||||
// Forward the requested region so a non-us-east-1 Builder ID validates
|
||||
// against the right OIDC endpoint instead of defaulting to us-east-1.
|
||||
region,
|
||||
});
|
||||
return {
|
||||
accessToken: result.accessToken,
|
||||
refreshToken: result.refreshToken || refreshToken,
|
||||
// profileArn is intentionally absent for Builder ID (OIDC) imports —
|
||||
// only the social-auth path returns one, and Builder ID connections
|
||||
// don't require it. The Kiro executor adds profileArn conditionally.
|
||||
profileArn: result.profileArn,
|
||||
expiresIn: result.expiresIn,
|
||||
authMethod: "builder-id",
|
||||
@@ -351,7 +361,9 @@ export class KiroService {
|
||||
* The cache is located at ~/.aws/sso/cache/ and contains JSON files from
|
||||
* the OIDC client registration step of the device code flow.
|
||||
*/
|
||||
private async readCachedClientCredentials(): Promise<{ clientId: string; clientSecret: string } | null> {
|
||||
private async readCachedClientCredentials(
|
||||
region?: string
|
||||
): Promise<{ clientId: string; clientSecret: string } | null> {
|
||||
try {
|
||||
const { readdir, readFile } = await import("fs/promises");
|
||||
const { homedir } = await import("os");
|
||||
@@ -359,18 +371,42 @@ export class KiroService {
|
||||
const cachePath = join(homedir(), ".aws", "sso", "cache");
|
||||
const files = await readdir(cachePath);
|
||||
|
||||
const candidates: {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
region?: string;
|
||||
expiresAt?: string;
|
||||
}[] = [];
|
||||
for (const file of files) {
|
||||
if (!file.endsWith(".json")) continue;
|
||||
try {
|
||||
const content = await readFile(join(cachePath, file), "utf-8");
|
||||
const data = JSON.parse(content);
|
||||
if (data.clientId && data.clientSecret) {
|
||||
return { clientId: data.clientId, clientSecret: data.clientSecret };
|
||||
candidates.push({
|
||||
clientId: data.clientId,
|
||||
clientSecret: data.clientSecret,
|
||||
region: data.region,
|
||||
expiresAt: data.clientSecretExpiresAt || data.expiresAt,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
// A host can cache OIDC client registrations for several SSO sessions;
|
||||
// adopting the wrong pair makes the Builder ID refresh fail. Prefer a
|
||||
// registration whose region matches the requested import region, then —
|
||||
// among the candidates — the one with the latest secret expiry, instead
|
||||
// of blindly taking the first file readdir happens to return.
|
||||
const byLatestExpiry = (a: { expiresAt?: string }, b: { expiresAt?: string }): number =>
|
||||
String(b.expiresAt || "").localeCompare(String(a.expiresAt || ""));
|
||||
const matching = region ? candidates.filter((c) => c.region === region) : [];
|
||||
const ordered = (matching.length > 0 ? matching : candidates).slice().sort(byLatestExpiry);
|
||||
const best = ordered[0];
|
||||
return { clientId: best.clientId, clientSecret: best.clientSecret };
|
||||
} catch {
|
||||
// Cache not available
|
||||
}
|
||||
|
||||
@@ -20,3 +20,23 @@ const FORBIDDEN = new Set(
|
||||
export function isForbiddenUpstreamHeaderName(name: string): boolean {
|
||||
return FORBIDDEN.has(String(name).trim().toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth headers that must come from the connection's credentials, never from
|
||||
* operator-set per-provider custom headers. Kept here as the single source of
|
||||
* truth so the Zod `customHeadersSchema` (schemas.ts) and the executor's
|
||||
* apply-loop (open-sse/executors/default.ts) cannot drift apart.
|
||||
*/
|
||||
const FORBIDDEN_AUTH = new Set(
|
||||
["authorization", "x-api-key", "x-goog-api-key", "api-key"].map((s) => s.toLowerCase())
|
||||
);
|
||||
|
||||
/**
|
||||
* Forbidden for operator-supplied custom headers: the hop-by-hop/framing set
|
||||
* PLUS auth headers (owned by the credential layer). Use this in both the
|
||||
* validation schema and the executor so there is one canonical denylist.
|
||||
*/
|
||||
export function isForbiddenCustomHeaderName(name: string): boolean {
|
||||
const n = String(name).trim().toLowerCase();
|
||||
return isForbiddenUpstreamHeaderName(n) || FORBIDDEN_AUTH.has(n);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,10 @@ import { MAX_REQUEST_BODY_LIMIT_MB, MIN_REQUEST_BODY_LIMIT_MB } from "@/shared/c
|
||||
import { COMBO_CONFIG_MODES } from "@/shared/constants/comboConfigMode";
|
||||
import { providerAllowsOptionalApiKey } from "@/shared/constants/providers";
|
||||
import { HIDEABLE_SIDEBAR_ITEM_IDS } from "@/shared/constants/sidebarVisibility";
|
||||
import { isForbiddenUpstreamHeaderName } from "@/shared/constants/upstreamHeaders";
|
||||
import {
|
||||
isForbiddenUpstreamHeaderName,
|
||||
isForbiddenCustomHeaderName,
|
||||
} from "@/shared/constants/upstreamHeaders";
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "@/shared/utils/runtimeTimeouts";
|
||||
|
||||
function isHttpUrl(value: string): boolean {
|
||||
@@ -1917,31 +1920,20 @@ export const updateKeyPermissionsSchema = z
|
||||
}
|
||||
});
|
||||
|
||||
const customHeadersSchema = z
|
||||
.record(z.string(), z.string())
|
||||
// Reuse the canonical upstream-headers record schema (control-char / whitespace
|
||||
// / ":" / 128-name / 4096-value / max-16 guards) so per-provider custom headers
|
||||
// inherit the same hardening as `modelCompat.upstreamHeaders` — then additionally
|
||||
// reject auth header names (the credential layer owns those; the executor drops
|
||||
// them at send time, so reject up front for an actionable error instead of a
|
||||
// silent no-op). Single denylist source: isForbiddenCustomHeaderName.
|
||||
const customHeadersSchema = upstreamHeadersRecordSchema
|
||||
.refine((rec) => !Object.keys(rec).some((k) => isForbiddenCustomHeaderName(k)), {
|
||||
message:
|
||||
"Custom headers cannot include hop-by-hop, framing, or auth headers " +
|
||||
"(authorization / x-api-key / x-goog-api-key / api-key)",
|
||||
})
|
||||
.nullable()
|
||||
.optional()
|
||||
.refine(
|
||||
(val) => {
|
||||
if (!val) return true;
|
||||
const forbidden = new Set([
|
||||
"host",
|
||||
"connection",
|
||||
"content-length",
|
||||
"keep-alive",
|
||||
"proxy-connection",
|
||||
"transfer-encoding",
|
||||
"te",
|
||||
"trailer",
|
||||
"upgrade",
|
||||
]);
|
||||
for (const key of Object.keys(val)) {
|
||||
if (forbidden.has(key.toLowerCase())) return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{ message: "Custom headers contain forbidden hop-by-hop or framing headers" }
|
||||
);
|
||||
.optional();
|
||||
|
||||
export const createProviderNodeSchema = z
|
||||
.object({
|
||||
|
||||
@@ -231,23 +231,28 @@ describe("resolveOpencodeConfigPath — cross-platform", () => {
|
||||
assert.equal(result, path.join("/home/dev", ".config", "opencode", "opencode.json"));
|
||||
});
|
||||
|
||||
it("should resolve on Windows with APPDATA", () => {
|
||||
it("should resolve on Windows under ~/.config (XDG, NOT %APPDATA% — #3330)", () => {
|
||||
// #3330: OpenCode reads its config from ~/.config/opencode on every
|
||||
// platform, including Windows (%USERPROFILE%\.config). %APPDATA% is ignored.
|
||||
const result = resolveOpencodeConfigPathFn(
|
||||
"win32",
|
||||
{ APPDATA: "C:\\Users\\dev\\AppData\\Roaming" },
|
||||
"C:\\Users\\dev"
|
||||
);
|
||||
assert.equal(
|
||||
result,
|
||||
path.join("C:\\Users\\dev\\AppData\\Roaming", "opencode", "opencode.json")
|
||||
);
|
||||
assert.equal(result, path.join("C:\\Users\\dev", ".config", "opencode", "opencode.json"));
|
||||
});
|
||||
|
||||
it("should fallback to home/AppData/Roaming on Windows without APPDATA", () => {
|
||||
it("should resolve on Windows under ~/.config without APPDATA (#3330)", () => {
|
||||
const result = resolveOpencodeConfigPathFn("win32", {}, "C:\\Users\\dev");
|
||||
assert.equal(
|
||||
result,
|
||||
path.join("C:\\Users\\dev", "AppData", "Roaming", "opencode", "opencode.json")
|
||||
assert.equal(result, path.join("C:\\Users\\dev", ".config", "opencode", "opencode.json"));
|
||||
});
|
||||
|
||||
it("should honor XDG_CONFIG_HOME on Windows too (#3330)", () => {
|
||||
const result = resolveOpencodeConfigPathFn(
|
||||
"win32",
|
||||
{ XDG_CONFIG_HOME: "D:\\xdg" },
|
||||
"C:\\Users\\dev"
|
||||
);
|
||||
assert.equal(result, path.join("D:\\xdg", "opencode", "opencode.json"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,8 @@ const providersDb = await import("../../src/lib/db/providers.ts");
|
||||
const providerNodesRoute = await import("../../src/app/api/provider-nodes/route.ts");
|
||||
const providerNodesIdRoute = await import("../../src/app/api/provider-nodes/[id]/route.ts");
|
||||
const { OPENAI_COMPATIBLE_PREFIX } = await import("../../src/shared/constants/providers.ts");
|
||||
const { createProviderNodeSchema, updateProviderNodeSchema } = await import("../../src/shared/validation/schemas.ts");
|
||||
const { createProviderNodeSchema, updateProviderNodeSchema } =
|
||||
await import("../../src/shared/validation/schemas.ts");
|
||||
const { DefaultExecutor } = await import("../../open-sse/executors/default.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
@@ -49,7 +50,12 @@ test.after(async () => {
|
||||
test("createProviderNodeSchema accepts valid customHeaders as record of strings", () => {
|
||||
const validInputs = [
|
||||
{ name: "Test", prefix: "test", apiType: "chat", customHeaders: { "X-Custom-1": "value1" } },
|
||||
{ name: "Test", prefix: "test", apiType: "chat", customHeaders: { "X-Header": "value", "X-Another": "value2" } },
|
||||
{
|
||||
name: "Test",
|
||||
prefix: "test",
|
||||
apiType: "chat",
|
||||
customHeaders: { "X-Header": "value", "X-Another": "value2" },
|
||||
},
|
||||
{ name: "Test", prefix: "test", apiType: "chat", customHeaders: {} },
|
||||
{ name: "Test", prefix: "test", apiType: "chat" },
|
||||
];
|
||||
@@ -77,8 +83,15 @@ test("createProviderNodeSchema rejects customHeaders with non-string values", ()
|
||||
|
||||
test("createProviderNodeSchema rejects forbidden hop-by-hop headers", () => {
|
||||
const forbiddenHeaders = [
|
||||
"host", "connection", "content-length", "keep-alive",
|
||||
"proxy-connection", "transfer-encoding", "te", "trailer", "upgrade",
|
||||
"host",
|
||||
"connection",
|
||||
"content-length",
|
||||
"keep-alive",
|
||||
"proxy-connection",
|
||||
"transfer-encoding",
|
||||
"te",
|
||||
"trailer",
|
||||
"upgrade",
|
||||
];
|
||||
|
||||
for (const header of forbiddenHeaders) {
|
||||
@@ -89,7 +102,7 @@ test("createProviderNodeSchema rejects forbidden hop-by-hop headers", () => {
|
||||
}
|
||||
|
||||
const result = createProviderNodeSchema.safeParse({
|
||||
customHeaders: { "HOST": "evil", "Content-Length": "999" },
|
||||
customHeaders: { HOST: "evil", "Content-Length": "999" },
|
||||
});
|
||||
assert.equal(result.success, false, "Should reject case-insensitive forbidden headers");
|
||||
});
|
||||
@@ -118,7 +131,7 @@ test("updateProviderNodeSchema rejects forbidden headers", () => {
|
||||
name: "Test",
|
||||
prefix: "test",
|
||||
baseUrl: "https://test.com",
|
||||
customHeaders: { "host": "evil.com" },
|
||||
customHeaders: { host: "evil.com" },
|
||||
});
|
||||
assert.equal(result.success, false);
|
||||
});
|
||||
@@ -181,10 +194,9 @@ test("provider nodes route update modifies customHeaders", async () => {
|
||||
baseUrl: "https://updated.example.com/v1",
|
||||
customHeaders: { "X-Updated": "updated-value", "X-New": "new-header" },
|
||||
};
|
||||
const updateResponse = await providerNodesIdRoute.PUT(
|
||||
makeUpdateRequest(nodeId, updateBody),
|
||||
{ params: Promise.resolve({ id: nodeId }) }
|
||||
);
|
||||
const updateResponse = await providerNodesIdRoute.PUT(makeUpdateRequest(nodeId, updateBody), {
|
||||
params: Promise.resolve({ id: nodeId }),
|
||||
});
|
||||
const updated = (await updateResponse.json()) as any;
|
||||
|
||||
assert.equal(updateResponse.status, 200);
|
||||
@@ -207,17 +219,16 @@ test("provider nodes route update can clear customHeaders by passing null", asyn
|
||||
const created = (await createResponse.json()) as any;
|
||||
const nodeId = created.node.id;
|
||||
|
||||
const clearBody = {
|
||||
const clearBody = {
|
||||
name: "Node Without Headers",
|
||||
prefix: "no-headers",
|
||||
apiType: "chat",
|
||||
baseUrl: "https://noclear.example.com/v1",
|
||||
customHeaders: null,
|
||||
};
|
||||
const updateResponse = await providerNodesIdRoute.PUT(
|
||||
makeUpdateRequest(nodeId, clearBody),
|
||||
{ params: Promise.resolve({ id: nodeId }) }
|
||||
);
|
||||
const updateResponse = await providerNodesIdRoute.PUT(makeUpdateRequest(nodeId, clearBody), {
|
||||
params: Promise.resolve({ id: nodeId }),
|
||||
});
|
||||
const updated = (await updateResponse.json()) as any;
|
||||
|
||||
assert.equal(updateResponse.status, 200);
|
||||
@@ -227,7 +238,7 @@ const clearBody = {
|
||||
test("DefaultExecutor.buildHeaders applies customHeaders from providerSpecificData", () => {
|
||||
const executor = new DefaultExecutor("openai-compatible-test");
|
||||
|
||||
const headers = executor.buildHeaders(
|
||||
const headers = executor.buildHeaders(
|
||||
{
|
||||
apiKey: "test-key",
|
||||
providerSpecificData: {
|
||||
@@ -258,7 +269,7 @@ test("DefaultExecutor.buildHeaders does NOT override auth headers with customHea
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://proxy.example.com/v1",
|
||||
customHeaders: {
|
||||
"Authorization": "Bearer fake-token",
|
||||
Authorization: "Bearer fake-token",
|
||||
"x-api-key": "fake-key",
|
||||
"X-Custom": "custom-value",
|
||||
},
|
||||
@@ -281,9 +292,9 @@ test("DefaultExecutor.buildHeaders blocks forbidden hop-by-hop headers from cust
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://proxy.example.com/v1",
|
||||
customHeaders: {
|
||||
"host": "evil.com",
|
||||
host: "evil.com",
|
||||
"content-length": "999",
|
||||
"connection": "close",
|
||||
connection: "close",
|
||||
"X-Legitimate": "good-header",
|
||||
},
|
||||
},
|
||||
@@ -476,7 +487,7 @@ test("DefaultExecutor.execute does NOT send forbidden headers from customHeaders
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://test.proxy.com/v1",
|
||||
customHeaders: {
|
||||
"host": "evil.com",
|
||||
host: "evil.com",
|
||||
"content-length": "9999",
|
||||
"X-Legitimate": "good",
|
||||
},
|
||||
@@ -516,7 +527,7 @@ test("DefaultExecutor.execute does NOT allow customHeaders to override Authoriza
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://test.proxy.com/v1",
|
||||
customHeaders: {
|
||||
"Authorization": "Bearer forged-key",
|
||||
Authorization: "Bearer forged-key",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -542,7 +553,9 @@ test("db: createProviderNode and getProviderNodeById handle customHeaders as JSO
|
||||
|
||||
assert.deepEqual(node.customHeaders, { "X-DB-Header": "db-value", "X-Another": "another" });
|
||||
|
||||
const retrieved = await providersDb.getProviderNodeById("openai-compatible-chat-custom-headers-db");
|
||||
const retrieved = await providersDb.getProviderNodeById(
|
||||
"openai-compatible-chat-custom-headers-db"
|
||||
);
|
||||
assert.deepEqual(retrieved.customHeaders, { "X-DB-Header": "db-value", "X-Another": "another" });
|
||||
});
|
||||
|
||||
@@ -559,15 +572,17 @@ test("db: updateProviderNode modifies customHeaders", async () => {
|
||||
|
||||
assert.deepEqual(node.customHeaders, { "X-Initial": "initial-value" });
|
||||
|
||||
const updated = await providersDb.updateProviderNode(
|
||||
"openai-compatible-chat-update-custom",
|
||||
{ customHeaders: { "X-Updated": "updated-value", "X-New-Header": "new" } }
|
||||
);
|
||||
const updated = await providersDb.updateProviderNode("openai-compatible-chat-update-custom", {
|
||||
customHeaders: { "X-Updated": "updated-value", "X-New-Header": "new" },
|
||||
});
|
||||
|
||||
assert.deepEqual(updated.customHeaders, { "X-Updated": "updated-value", "X-New-Header": "new" });
|
||||
|
||||
const retrieved = await providersDb.getProviderNodeById("openai-compatible-chat-update-custom");
|
||||
assert.deepEqual(retrieved.customHeaders, { "X-Updated": "updated-value", "X-New-Header": "new" });
|
||||
assert.deepEqual(retrieved.customHeaders, {
|
||||
"X-Updated": "updated-value",
|
||||
"X-New-Header": "new",
|
||||
});
|
||||
});
|
||||
|
||||
test("db: updateProviderNode can clear customHeaders by passing null", async () => {
|
||||
@@ -583,10 +598,9 @@ test("db: updateProviderNode can clear customHeaders by passing null", async ()
|
||||
|
||||
assert.deepEqual(node.customHeaders, { "X-ToClear": "clear-me" });
|
||||
|
||||
const updated = await providersDb.updateProviderNode(
|
||||
"openai-compatible-chat-clear-custom",
|
||||
{ customHeaders: null }
|
||||
);
|
||||
const updated = await providersDb.updateProviderNode("openai-compatible-chat-clear-custom", {
|
||||
customHeaders: null,
|
||||
});
|
||||
|
||||
assert.equal(updated.customHeaders, null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,13 +31,13 @@ const {
|
||||
// Test group 1 — Flag definitions registry
|
||||
// ──────────────────────────────────────────────────────
|
||||
describe("featureFlagDefinitions", () => {
|
||||
it("has exactly 28 flag definitions", () => {
|
||||
assert.strictEqual(FEATURE_FLAG_DEFINITIONS.length, 28);
|
||||
it("has exactly 29 flag definitions", () => {
|
||||
assert.strictEqual(FEATURE_FLAG_DEFINITIONS.length, 29);
|
||||
});
|
||||
|
||||
it("has unique keys for all flags", () => {
|
||||
const keys = FEATURE_FLAG_DEFINITIONS.map((d) => d.key);
|
||||
assert.strictEqual(new Set(keys).size, 28);
|
||||
assert.strictEqual(new Set(keys).size, 29);
|
||||
});
|
||||
|
||||
it("has valid categories for all flags", () => {
|
||||
@@ -222,9 +222,9 @@ describe("resolveFeatureFlag", () => {
|
||||
});
|
||||
|
||||
describe("resolveAllFeatureFlags", () => {
|
||||
it("returns all 28 flags", () => {
|
||||
it("returns all 29 flags", () => {
|
||||
const all = resolveAllFeatureFlags();
|
||||
assert.strictEqual(all.length, 28);
|
||||
assert.strictEqual(all.length, 29);
|
||||
});
|
||||
|
||||
it("marks DB-overridden flags with source 'db'", () => {
|
||||
|
||||
@@ -82,7 +82,11 @@ test("validateImportToken falls back to social auth when no cached creds exist",
|
||||
// Social-auth refresh endpoint.
|
||||
if (u.includes("auth.desktop.kiro.dev")) {
|
||||
return new Response(
|
||||
JSON.stringify({ accessToken: "access-social", refreshToken: "social-rt", expiresIn: 3600 }),
|
||||
JSON.stringify({
|
||||
accessToken: "access-social",
|
||||
refreshToken: "social-rt",
|
||||
expiresIn: 3600,
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
@@ -103,6 +107,78 @@ test("validateImportToken falls back to social auth when no cached creds exist",
|
||||
assert.equal(result.accessToken, "access-social");
|
||||
});
|
||||
|
||||
// LEDGER-6 (/review-reviews v3.8.14): the Builder ID validation refresh must
|
||||
// forward the requested region to the OIDC endpoint, not default to us-east-1.
|
||||
test("validateImportToken forwards the region to the OIDC endpoint (LEDGER-6)", async () => {
|
||||
makeFakeSsoCache(tmpHome, { clientId: "cid", clientSecret: "secret" });
|
||||
const calledEndpoints: string[] = [];
|
||||
globalThis.fetch = (async (url: string | URL | Request) => {
|
||||
const u = String(url);
|
||||
calledEndpoints.push(u);
|
||||
if (u.includes("oidc.") && u.endsWith("/token")) {
|
||||
return new Response(
|
||||
JSON.stringify({ accessToken: "a", refreshToken: "r", expiresIn: 3600 }),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
throw new Error(`unexpected fetch to ${u}`);
|
||||
}) as typeof fetch;
|
||||
|
||||
const svc = new KiroService();
|
||||
await svc.validateImportToken("aorAAAAAGtoken", "eu-west-1");
|
||||
assert.ok(
|
||||
calledEndpoints.some((u) => u.includes("oidc.eu-west-1.amazonaws.com/token")),
|
||||
`expected the eu-west-1 OIDC endpoint, got: ${calledEndpoints.join(", ")}`
|
||||
);
|
||||
assert.ok(
|
||||
!calledEndpoints.some((u) => u.includes("oidc.us-east-1.amazonaws.com/token")),
|
||||
"must not fall back to us-east-1 when a region was requested"
|
||||
);
|
||||
});
|
||||
|
||||
// LEDGER-8 (/review-reviews v3.8.14): with multiple cached SSO client
|
||||
// registrations, the one whose region matches the import must be chosen rather
|
||||
// than whichever readdir returns first.
|
||||
test("validateImportToken prefers the region-matching cached client (LEDGER-8)", async () => {
|
||||
const cacheDir = path.join(tmpHome, ".aws", "sso", "cache");
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
// "a-" sorts first so naive first-match would pick the wrong (us-east-1) pair.
|
||||
fs.writeFileSync(
|
||||
path.join(cacheDir, "a-useast.json"),
|
||||
JSON.stringify({
|
||||
clientId: "cid-useast",
|
||||
clientSecret: "secret-useast",
|
||||
region: "us-east-1",
|
||||
expiresAt: "2099-01-01T00:00:00Z",
|
||||
})
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(cacheDir, "z-euwest.json"),
|
||||
JSON.stringify({
|
||||
clientId: "cid-euwest",
|
||||
clientSecret: "secret-euwest",
|
||||
region: "eu-west-1",
|
||||
expiresAt: "2099-01-01T00:00:00Z",
|
||||
})
|
||||
);
|
||||
|
||||
globalThis.fetch = (async (url: string | URL | Request) => {
|
||||
const u = String(url);
|
||||
if (u.includes("oidc.") && u.endsWith("/token")) {
|
||||
return new Response(
|
||||
JSON.stringify({ accessToken: "a", refreshToken: "r", expiresIn: 3600 }),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
throw new Error(`unexpected fetch to ${u}`);
|
||||
}) as typeof fetch;
|
||||
|
||||
const svc = new KiroService();
|
||||
const result = await svc.validateImportToken("aorAAAAAGtoken", "eu-west-1");
|
||||
assert.equal(result.clientId, "cid-euwest", "must pick the eu-west-1 registration");
|
||||
assert.equal(result.clientSecret, "secret-euwest");
|
||||
});
|
||||
|
||||
test("validateImportToken rejects malformed refresh tokens before touching the cache", async () => {
|
||||
let fetched = false;
|
||||
globalThis.fetch = (async () => {
|
||||
|
||||
@@ -75,12 +75,10 @@ describe("MiniMax M3 model registration (#3110)", () => {
|
||||
assert.equal(m3.contextLength, 1_048_576);
|
||||
});
|
||||
|
||||
it("nvidia provider has minimaxai/minimax-m3 with 1M context", () => {
|
||||
it("nvidia provider does NOT list minimaxai/minimax-m3 (removed in #3329 — 404 upstream)", () => {
|
||||
const entry = REGISTRY.nvidia;
|
||||
assert.ok(entry, "nvidia registry entry must exist");
|
||||
const m3 = entry.models.find((m) => m.id === "minimaxai/minimax-m3");
|
||||
assert.ok(m3, "minimaxai/minimax-m3 must be in nvidia models");
|
||||
assert.equal(m3.name, "MiniMax M3");
|
||||
assert.equal(m3.contextLength, 1_048_576);
|
||||
assert.equal(m3, undefined, "NVIDIA NIM does not host minimaxai/minimax-m3 (see #3329)");
|
||||
});
|
||||
});
|
||||
|
||||
234
tests/unit/review-reviews-v3814-fixes.test.ts
Normal file
234
tests/unit/review-reviews-v3814-fixes.test.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* Regression guards for the /review-reviews battery findings (v3.8.14).
|
||||
* Each test maps to a LEDGER-* item from the consolidated review.
|
||||
*/
|
||||
|
||||
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";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-review-reviews-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const providersDb = await import("../../src/lib/db/providers.ts");
|
||||
const { createProviderNodeSchema } = await import("../../src/shared/validation/schemas.ts");
|
||||
const { DefaultExecutor } = await import("../../open-sse/executors/default.ts");
|
||||
const { REGISTRY } = await import("../../open-sse/config/providerRegistry.ts");
|
||||
|
||||
test.beforeEach(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ── LEDGER-1: updateProviderNode must preserve custom headers on partial update ──
|
||||
test("LEDGER-1: updateProviderNode preserves customHeaders when the field is omitted", async () => {
|
||||
const created = await providersDb.createProviderNode({
|
||||
type: "openai-compatible",
|
||||
name: "node-a",
|
||||
prefix: "nodea",
|
||||
apiType: "chat",
|
||||
baseUrl: "https://proxy.example.com/v1",
|
||||
customHeaders: { "X-Tenant": "acme", "X-Env": "prod" },
|
||||
});
|
||||
// Partial update that does NOT resend customHeaders (only renames the node).
|
||||
const updated = await providersDb.updateProviderNode(created.id as string, { name: "node-a2" });
|
||||
assert.deepEqual(
|
||||
updated?.customHeaders,
|
||||
{ "X-Tenant": "acme", "X-Env": "prod" },
|
||||
"omitting customHeaders on update must NOT wipe stored headers"
|
||||
);
|
||||
// And reading it back confirms persistence.
|
||||
const fetched = await providersDb.getProviderNodeById(created.id as string);
|
||||
assert.deepEqual(fetched?.customHeaders, { "X-Tenant": "acme", "X-Env": "prod" });
|
||||
});
|
||||
|
||||
test("LEDGER-1: updateProviderNode still clears headers when explicitly set to null", async () => {
|
||||
const created = await providersDb.createProviderNode({
|
||||
type: "openai-compatible",
|
||||
name: "node-b",
|
||||
prefix: "nodeb",
|
||||
apiType: "chat",
|
||||
baseUrl: "https://proxy.example.com/v1",
|
||||
customHeaders: { "X-Tenant": "acme" },
|
||||
});
|
||||
const updated = await providersDb.updateProviderNode(created.id as string, {
|
||||
customHeaders: null,
|
||||
});
|
||||
assert.equal(updated?.customHeaders, null, "explicit null must clear headers");
|
||||
});
|
||||
|
||||
// ── LEDGER-2 / LEDGER-3: schema reuses canonical guards + rejects auth headers ──
|
||||
test("LEDGER-2: customHeaders rejects CRLF / control-char values", () => {
|
||||
const bad = createProviderNodeSchema.safeParse({
|
||||
type: "openai-compatible",
|
||||
name: "n",
|
||||
prefix: "n",
|
||||
apiType: "chat",
|
||||
baseUrl: "https://x/v1",
|
||||
customHeaders: { "X-Inject": "foo\r\nX-Evil: bar" },
|
||||
});
|
||||
assert.equal(bad.success, false, "CRLF in a header value must be rejected at the schema");
|
||||
});
|
||||
|
||||
test("LEDGER-2: customHeaders rejects control-char / whitespace / ':' in names", () => {
|
||||
for (const badName of ["X Bad", "X:Bad", "X\r\nBad"]) {
|
||||
const res = createProviderNodeSchema.safeParse({
|
||||
type: "openai-compatible",
|
||||
name: "n",
|
||||
prefix: "n",
|
||||
apiType: "chat",
|
||||
baseUrl: "https://x/v1",
|
||||
customHeaders: { [badName]: "v" },
|
||||
});
|
||||
assert.equal(res.success, false, `header name "${badName}" must be rejected`);
|
||||
}
|
||||
});
|
||||
|
||||
test("LEDGER-2: customHeaders rejects more than 16 entries", () => {
|
||||
const many: Record<string, string> = {};
|
||||
for (let i = 0; i < 17; i++) many[`X-H${i}`] = "v";
|
||||
const res = createProviderNodeSchema.safeParse({
|
||||
type: "openai-compatible",
|
||||
name: "n",
|
||||
prefix: "n",
|
||||
apiType: "chat",
|
||||
baseUrl: "https://x/v1",
|
||||
customHeaders: many,
|
||||
});
|
||||
assert.equal(res.success, false, "more than 16 custom headers must be rejected");
|
||||
});
|
||||
|
||||
test("LEDGER-3: customHeaders rejects auth header names at the schema boundary", () => {
|
||||
for (const authName of ["Authorization", "x-api-key", "X-Goog-Api-Key", "api-key"]) {
|
||||
const res = createProviderNodeSchema.safeParse({
|
||||
type: "openai-compatible",
|
||||
name: "n",
|
||||
prefix: "n",
|
||||
apiType: "chat",
|
||||
baseUrl: "https://x/v1",
|
||||
customHeaders: { [authName]: "Bearer x" },
|
||||
});
|
||||
assert.equal(res.success, false, `auth header "${authName}" must be rejected (no silent drop)`);
|
||||
}
|
||||
});
|
||||
|
||||
test("LEDGER-2: valid custom headers still pass", () => {
|
||||
const res = createProviderNodeSchema.safeParse({
|
||||
type: "openai-compatible",
|
||||
name: "n",
|
||||
prefix: "n",
|
||||
apiType: "chat",
|
||||
baseUrl: "https://x/v1",
|
||||
customHeaders: { "X-Tenant": "acme", "X-Trace-Id": "abc-123" },
|
||||
});
|
||||
assert.equal(res.success, true, res.success ? "" : JSON.stringify(res.error?.issues));
|
||||
});
|
||||
|
||||
// ── LEDGER-4: every minimax-m3 registry entry is flagged multimodal ──
|
||||
test("LEDGER-4: all minimax-m3 registry entries set supportsVision (matches lite.ts)", () => {
|
||||
const entries: { id: string; supportsVision?: boolean }[] = [];
|
||||
for (const provider of Object.values(
|
||||
REGISTRY as Record<string, { models?: { id: string; supportsVision?: boolean }[] }>
|
||||
)) {
|
||||
for (const m of provider.models || []) {
|
||||
if (/minimax-m3/i.test(m.id)) entries.push(m);
|
||||
}
|
||||
}
|
||||
assert.ok(entries.length >= 6, `expected several minimax-m3 entries, got ${entries.length}`);
|
||||
const unflagged = entries.filter((m) => m.supportsVision !== true).map((m) => m.id);
|
||||
assert.deepEqual(
|
||||
unflagged,
|
||||
[],
|
||||
`these minimax-m3 entries miss supportsVision: ${unflagged.join(", ")}`
|
||||
);
|
||||
});
|
||||
|
||||
// ── LEDGER-5: anthropic-compatible-cc-* nodes honor custom headers ──
|
||||
test("LEDGER-5: custom headers reach the wire for anthropic-compatible-cc-* nodes", () => {
|
||||
const executor = new DefaultExecutor("anthropic-compatible-cc-test");
|
||||
const headers = executor.buildHeaders(
|
||||
{
|
||||
accessToken: "tok",
|
||||
providerSpecificData: { customHeaders: { "X-CC-Custom": "yes" } },
|
||||
},
|
||||
false
|
||||
) as Record<string, string>;
|
||||
assert.equal(headers["X-CC-Custom"], "yes", "CC node must apply operator custom headers");
|
||||
});
|
||||
|
||||
// ── LEDGER-10: case-insensitive override — no duplicate Content-Type/Accept ──
|
||||
test("LEDGER-10: a custom content-type overrides (not duplicates) the executor's Content-Type", () => {
|
||||
const executor = new DefaultExecutor("openai-compatible-test");
|
||||
const headers = executor.buildHeaders(
|
||||
{
|
||||
apiKey: "k",
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://x/v1",
|
||||
customHeaders: { "content-type": "application/custom" },
|
||||
},
|
||||
},
|
||||
false
|
||||
) as Record<string, string>;
|
||||
const ctKeys = Object.keys(headers).filter((k) => k.toLowerCase() === "content-type");
|
||||
assert.equal(
|
||||
ctKeys.length,
|
||||
1,
|
||||
`exactly one content-type header expected, got ${ctKeys.join(", ")}`
|
||||
);
|
||||
assert.equal(headers[ctKeys[0]], "application/custom");
|
||||
});
|
||||
|
||||
test("LEDGER-3: executor still drops auth/forbidden custom headers (defense-in-depth)", () => {
|
||||
const executor = new DefaultExecutor("openai-compatible-test");
|
||||
const headers = executor.buildHeaders(
|
||||
{
|
||||
apiKey: "real-key",
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://x/v1",
|
||||
customHeaders: { Authorization: "Bearer evil", Host: "evil.example", "X-Ok": "ok" },
|
||||
},
|
||||
},
|
||||
false
|
||||
) as Record<string, string>;
|
||||
assert.equal(headers["X-Ok"], "ok");
|
||||
assert.notEqual(
|
||||
headers["Authorization"],
|
||||
"Bearer evil",
|
||||
"auth must not be overridden by a custom header"
|
||||
);
|
||||
assert.ok(!Object.keys(headers).some((k) => k.toLowerCase() === "host"));
|
||||
});
|
||||
|
||||
// ── LEDGER-9: rowToCamel normalizes a NULL _json column to the base key ──
|
||||
test("LEDGER-9: rowToCamel surfaces a NULL _json column under the base key as null", () => {
|
||||
const row = { id: "x", custom_headers_json: null, name: "n" };
|
||||
const camel = core.rowToCamel(row);
|
||||
assert.equal(camel?.customHeaders, null, "NULL _json column should be customHeaders: null");
|
||||
assert.ok(
|
||||
!("customHeadersJson" in (camel as object)),
|
||||
"the suffixed key must not leak on the null path"
|
||||
);
|
||||
});
|
||||
|
||||
// ── LEDGER-11: a node created on a fresh DB round-trips the custom_headers_json column ──
|
||||
test("LEDGER-11: fresh-DB migration leaves provider_nodes.custom_headers_json usable", async () => {
|
||||
const created = await providersDb.createProviderNode({
|
||||
type: "openai-compatible",
|
||||
name: "fresh",
|
||||
prefix: "fresh",
|
||||
apiType: "chat",
|
||||
baseUrl: "https://x/v1",
|
||||
customHeaders: { "X-A": "1" },
|
||||
});
|
||||
const fetched = await providersDb.getProviderNodeById(created.id as string);
|
||||
assert.deepEqual(fetched?.customHeaders, { "X-A": "1" });
|
||||
});
|
||||
Reference in New Issue
Block a user