diff --git a/CHANGELOG.md b/CHANGELOG.md index bf834cac8f..d9dcc2a5fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 4d69c063a5..91b3e959c4 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -1035,7 +1035,12 @@ const _REGISTRY_EAGER: Record = { { 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 = { { 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 = { { 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 = { // ── 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 = { 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 = { 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 = { { 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 = { { 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 = { 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, }, }; diff --git a/open-sse/executors/default.ts b/open-sse/executors/default.ts index 905fab1659..1058b6054a 100644 --- a/open-sse/executors/default.ts +++ b/open-sse/executors/default.ts @@ -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, rawCustomHeaders: unknown): void { + let customHeaders: Record | null = null; + if ( + rawCustomHeaders && + typeof rawCustomHeaders === "object" && + !Array.isArray(rawCustomHeaders) + ) { + customHeaders = rawCustomHeaders as Record; + } else if (typeof rawCustomHeaders === "string") { + try { + const parsed = JSON.parse(rawCustomHeaders); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + customHeaders = parsed as Record; + } + } 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 | null = null; - if (rawCustomHeaders && typeof rawCustomHeaders === "object" && !Array.isArray(rawCustomHeaders)) { - customHeaders = rawCustomHeaders as Record; - } 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) diff --git a/src/lib/db/core.ts b/src/lib/db/core.ts index e844614b97..944513267a 100644 --- a/src/lib/db/core.ts +++ b/src/lib/db/core.ts @@ -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. diff --git a/src/lib/db/migrationRunner.ts b/src/lib/db/migrationRunner.ts index 4aa8e1d345..f5e874f8a7 100644 --- a/src/lib/db/migrationRunner.ts +++ b/src/lib/db/migrationRunner.ts @@ -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.` diff --git a/src/lib/db/providers.ts b/src/lib/db/providers.ts index f55f8e6dc8..41fe4183cf 100644 --- a/src/lib/db/providers.ts +++ b/src/lib/db/providers.ts @@ -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( diff --git a/src/lib/oauth/services/kiro.ts b/src/lib/oauth/services/kiro.ts index 95e2ab755f..e3ca235de3 100644 --- a/src/lib/oauth/services/kiro.ts +++ b/src/lib/oauth/services/kiro.ts @@ -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 } diff --git a/src/shared/constants/upstreamHeaders.ts b/src/shared/constants/upstreamHeaders.ts index b57f34f398..ba990dfd87 100644 --- a/src/shared/constants/upstreamHeaders.ts +++ b/src/shared/constants/upstreamHeaders.ts @@ -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); +} diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index 5576ce6a98..df33ca90c2 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -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({ diff --git a/tests/unit/cli-runtime-detection.test.ts b/tests/unit/cli-runtime-detection.test.ts index 55a8fa7279..4306ebdf34 100644 --- a/tests/unit/cli-runtime-detection.test.ts +++ b/tests/unit/cli-runtime-detection.test.ts @@ -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")); }); }); diff --git a/tests/unit/custom-headers-provider-nodes.test.ts b/tests/unit/custom-headers-provider-nodes.test.ts index d98d844796..17646b37f9 100644 --- a/tests/unit/custom-headers-provider-nodes.test.ts +++ b/tests/unit/custom-headers-provider-nodes.test.ts @@ -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); -}); \ No newline at end of file +}); diff --git a/tests/unit/feature-flags-settings.test.ts b/tests/unit/feature-flags-settings.test.ts index 8511980d95..cc4cc4f563 100644 --- a/tests/unit/feature-flags-settings.test.ts +++ b/tests/unit/feature-flags-settings.test.ts @@ -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'", () => { diff --git a/tests/unit/kiro-builder-id-import-3333.test.ts b/tests/unit/kiro-builder-id-import-3333.test.ts index da45ba4c16..f9ba09772f 100644 --- a/tests/unit/kiro-builder-id-import-3333.test.ts +++ b/tests/unit/kiro-builder-id-import-3333.test.ts @@ -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 () => { diff --git a/tests/unit/minimax-m3-model-registry.test.ts b/tests/unit/minimax-m3-model-registry.test.ts index ddc49fdd62..820cbc77ca 100644 --- a/tests/unit/minimax-m3-model-registry.test.ts +++ b/tests/unit/minimax-m3-model-registry.test.ts @@ -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)"); }); }); diff --git a/tests/unit/review-reviews-v3814-fixes.test.ts b/tests/unit/review-reviews-v3814-fixes.test.ts new file mode 100644 index 0000000000..9e701e9eae --- /dev/null +++ b/tests/unit/review-reviews-v3814-fixes.test.ts @@ -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 = {}; + 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 + )) { + 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; + 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; + 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; + 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" }); +});