From 5d755c33381754647338fa7975b37057b7d73782 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 19 Jul 2026 02:31:12 -0300 Subject: [PATCH 01/22] fix(providers): correct Chutes registry baseUrl (#7621) (#7708) The built-in "Chutes" provider preset hardcoded the non-resolving domain api.chutesai.com (confirmed live: DNS NXDOMAIN). Every request using the built-in preset failed with getaddrinfo ENOTFOUND, independent of API key validity. The correct, resolving host is llm.chutes.ai, already used elsewhere in the codebase for model discovery (providerModelsConfig.ts:184-187). Regression test: tests/unit/chutes-registry-baseurl-7621.test.ts (RED before the fix, GREEN after). The provider.ts translate-path golden snapshot is updated to reflect the corrected URL only for the chutes entry. --- changelog.d/fixes/7621-chutes-baseurl.md | 1 + open-sse/config/providers/registry/chutes/index.ts | 2 +- tests/snapshots/provider/translate-path.json | 4 ++-- tests/unit/chutes-registry-baseurl-7621.test.ts | 12 ++++++++++++ 4 files changed, 16 insertions(+), 3 deletions(-) create mode 100644 changelog.d/fixes/7621-chutes-baseurl.md create mode 100644 tests/unit/chutes-registry-baseurl-7621.test.ts diff --git a/changelog.d/fixes/7621-chutes-baseurl.md b/changelog.d/fixes/7621-chutes-baseurl.md new file mode 100644 index 0000000000..98683834fc --- /dev/null +++ b/changelog.d/fixes/7621-chutes-baseurl.md @@ -0,0 +1 @@ +- fix(providers): correct Chutes registry baseUrl from api.chutesai.com to llm.chutes.ai (#7621) diff --git a/open-sse/config/providers/registry/chutes/index.ts b/open-sse/config/providers/registry/chutes/index.ts index c3ee02528c..0b6e368621 100644 --- a/open-sse/config/providers/registry/chutes/index.ts +++ b/open-sse/config/providers/registry/chutes/index.ts @@ -5,7 +5,7 @@ export const chutesProvider: RegistryEntry = { alias: "chutes", format: "openai", executor: "default", - baseUrl: "https://api.chutesai.com/v1/chat/completions", + baseUrl: "https://llm.chutes.ai/v1/chat/completions", authType: "apikey", authHeader: "bearer", models: [{ id: "Qwen2.5-72B-Instruct", name: "Qwen2.5 72B" }], diff --git a/tests/snapshots/provider/translate-path.json b/tests/snapshots/provider/translate-path.json index 668ba3998a..6d401fc86d 100644 --- a/tests/snapshots/provider/translate-path.json +++ b/tests/snapshots/provider/translate-path.json @@ -746,8 +746,8 @@ } }, "url": { - "nonStream": "https://api.chutesai.com/v1/chat/completions", - "stream": "https://api.chutesai.com/v1/chat/completions" + "nonStream": "https://llm.chutes.ai/v1/chat/completions", + "stream": "https://llm.chutes.ai/v1/chat/completions" } }, "claude": { diff --git a/tests/unit/chutes-registry-baseurl-7621.test.ts b/tests/unit/chutes-registry-baseurl-7621.test.ts new file mode 100644 index 0000000000..4a6fca5dae --- /dev/null +++ b/tests/unit/chutes-registry-baseurl-7621.test.ts @@ -0,0 +1,12 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { chutesProvider } from "../../open-sse/config/providers/registry/chutes/index.ts"; + +test("#7621: chutes registry baseUrl must use the resolvable llm.chutes.ai domain", () => { + assert.equal( + chutesProvider.baseUrl, + "https://llm.chutes.ai/v1/chat/completions", + "chutesProvider.baseUrl must point at the resolvable llm.chutes.ai host, not the " + + "non-resolving api.chutesai.com" + ); +}); From 9e535e5ca1cc193402e55c42f079ec92ddfe4b59 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 19 Jul 2026 02:31:15 -0300 Subject: [PATCH 02/22] fix(routing): strip prompt_cache_key for NVIDIA NIM (#7617) (#7709) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex CLI injects prompt_cache_key natively for its own prompt caching. injectPromptCacheKey() only guards against the router injecting a NEW key for nvidia/codex/xai — it never strips a key that arrived already present in the inbound body. NVIDIA NIM's OpenAI-compatible wrapper rejects the field with a 400, and NIM has no documented support for prompt caching (providerSupportsCaching already treats nvidia as non-cache-capable). Adds a provider-wide STRIP_RULES entry in paramSupport.ts (match-all, since prompt_cache_key rejection isn't model-specific) so stripUnsupportedParams() drops it for every nvidia target before the request reaches DefaultExecutor. --- .../fixes/7617-nvidia-strip-cache-key.md | 1 + open-sse/translator/paramSupport.ts | 5 +++ .../nvidia-strip-prompt-cache-key.test.ts | 41 +++++++++++++++++++ 3 files changed, 47 insertions(+) create mode 100644 changelog.d/fixes/7617-nvidia-strip-cache-key.md create mode 100644 tests/unit/nvidia-strip-prompt-cache-key.test.ts diff --git a/changelog.d/fixes/7617-nvidia-strip-cache-key.md b/changelog.d/fixes/7617-nvidia-strip-cache-key.md new file mode 100644 index 0000000000..d3909c9256 --- /dev/null +++ b/changelog.d/fixes/7617-nvidia-strip-cache-key.md @@ -0,0 +1 @@ +- fix(routing): strip `prompt_cache_key` for NVIDIA NIM — Codex CLI injects it, NIM's OpenAI-compatible wrapper 400s on it (#7617) diff --git a/open-sse/translator/paramSupport.ts b/open-sse/translator/paramSupport.ts index ca10d1a223..caad2ab5a3 100644 --- a/open-sse/translator/paramSupport.ts +++ b/open-sse/translator/paramSupport.ts @@ -50,6 +50,11 @@ const STRIP_RULES: StripRule[] = [ // (format:"openai") does not accept the Claude-style `thinking` body field // and returns 400 "Unsupported parameter(s): thinking". Upstream #2268. { provider: "nvidia", match: /minimax-m2\.7/i, drop: ["thinking"] }, + // NVIDIA NIM: OpenAI-compatible wrapper 400s on `prompt_cache_key` (Codex CLI + // injects it natively for its own prompt caching). NIM has no documented + // support for this field (providerSupportsCaching already treats nvidia as + // non-cache-capable) — safe to drop provider-wide, not model-specific. #7617. + { provider: "nvidia", match: /.*/, drop: ["prompt_cache_key"] }, // VolcEngine Ark caps the Kimi coding-plan endpoint at max_tokens <= 32768 // server-side ("integer above maximum value, expected a value <= 32768"), // independent of the model's own catalog ceiling. Confirmed against two diff --git a/tests/unit/nvidia-strip-prompt-cache-key.test.ts b/tests/unit/nvidia-strip-prompt-cache-key.test.ts new file mode 100644 index 0000000000..dd1276a815 --- /dev/null +++ b/tests/unit/nvidia-strip-prompt-cache-key.test.ts @@ -0,0 +1,41 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { stripUnsupportedParams } from "../../open-sse/translator/paramSupport.ts"; + +test("#7617: stripUnsupportedParams strips prompt_cache_key for nvidia when present", () => { + const body: Record = { + model: "some-nvidia-model", + prompt_cache_key: "codex-cli-session-abc123", + max_tokens: 512, + }; + stripUnsupportedParams("nvidia", "some-nvidia-model", body); + assert.equal( + body.prompt_cache_key, + undefined, + "prompt_cache_key must be stripped for nvidia" + ); + assert.equal(body.max_tokens, 512, "unrelated params must be preserved"); +}); + +test("#7617: stripUnsupportedParams preserves prompt_cache_key for non-nvidia providers (e.g. openai)", () => { + const body: Record = { + model: "gpt-5.4", + prompt_cache_key: "some-cache-key", + }; + stripUnsupportedParams("openai", "gpt-5.4", body); + assert.equal( + body.prompt_cache_key, + "some-cache-key", + "prompt_cache_key must be preserved for non-nvidia providers" + ); +}); + +test("#7617: stripUnsupportedParams is a no-op for nvidia when prompt_cache_key is absent", () => { + const body: Record = { + model: "some-nvidia-model", + max_tokens: 256, + }; + stripUnsupportedParams("nvidia", "some-nvidia-model", body); + assert.equal("prompt_cache_key" in body, false); + assert.equal(body.max_tokens, 256); +}); From ebd6afd59a4caa9d09e6289217295c44bffd1821 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 19 Jul 2026 02:31:19 -0300 Subject: [PATCH 03/22] fix(providers): degrade Arena (lmarena) cookie validation redirect to unsupported (#7542) (#7710) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - validateWebCookieProvider's /models probe against lmarena's registered baseUrl (a POST-only streaming endpoint from #6280) triggers a 307 REDIRECT_BLOCKED from safeOutboundFetch, which was surfaced as a raw "Redirect blocked" error (unsupported:false) instead of the honest "unsupported" signal — the dashboard rendered a hard Invalid state for a perfectly valid cookie. - Add toWebCookieValidationErrorResult() in validation/transport.ts: for providers whose /models probe is known-unreliable (lmarena for now), REDIRECT_BLOCKED now degrades to {valid:false, unsupported:true}, mirroring the same REDIRECT_BLOCKED degrade already applied on the discovery path by #6267. Deliberately scoped to lmarena only (see code comment) — other web-cookie providers with a similarly-shaped baseUrl need their own proven repro before joining the allowlist. - Remove the now-stale comment at validation.ts claiming lmarena has no providerRegistry entry (false since #6280 registered one). - Regression test: tests/unit/arena-cookie-validation-redirect-7542.test.ts (RED confirmed against unfixed code, GREEN after the fix). --- .../fixes/7542-arena-cookie-redirect.md | 1 + src/lib/providers/validation.ts | 11 ++-- src/lib/providers/validation/transport.ts | 29 +++++++++++ ...na-cookie-validation-redirect-7542.test.ts | 52 +++++++++++++++++++ 4 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 changelog.d/fixes/7542-arena-cookie-redirect.md create mode 100644 tests/unit/arena-cookie-validation-redirect-7542.test.ts diff --git a/changelog.d/fixes/7542-arena-cookie-redirect.md b/changelog.d/fixes/7542-arena-cookie-redirect.md new file mode 100644 index 0000000000..02513f6a50 --- /dev/null +++ b/changelog.d/fixes/7542-arena-cookie-redirect.md @@ -0,0 +1 @@ +- fix(providers): stop reporting Arena (lmarena) cookie validation as Invalid when the `/models` probe hits a 307 redirect — degrade to `unsupported` instead, and drop the stale "no registry entry" comment for lmarena (#7542) diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index d2734ea883..2a34969086 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -31,7 +31,12 @@ import { resolveBaseUrl, } from "./validation/urlHelpers"; import { STANDARD_USER_AGENT, directHttpsRequest, buildBearerHeaders } from "./validation/headers"; -import { validationRead, validationWrite, toValidationErrorResult } from "./validation/transport"; +import { + validationRead, + validationWrite, + toValidationErrorResult, + toWebCookieValidationErrorResult, +} from "./validation/transport"; import { validateDeepSeekWebProvider, validateQwenWebProvider, @@ -137,7 +142,7 @@ export async function validateWebCookieProvider({ if (!entry) { // Providers listed in WEB_COOKIE_PROVIDERS without a providerRegistry entry (e.g. - // lmarena, gemini-business, poe-web, venice-web, v0-vercel-web) only expose a + // gemini-business, poe-web, venice-web, v0-vercel-web) only expose a // marketing website URL, not a real API host. Probing `${website}/models` // does not reliably signal session validity for these — // live verification showed most return redirects or SPA 200s regardless of @@ -183,7 +188,7 @@ export async function validateWebCookieProvider({ // for web-cookie auth, so a non-auth status is treated as a valid session. return { valid: true, error: null, unsupported: false }; } catch (error: unknown) { - return toValidationErrorResult(error); + return toWebCookieValidationErrorResult(provider, error); } } diff --git a/src/lib/providers/validation/transport.ts b/src/lib/providers/validation/transport.ts index 6321ab4629..08a1d97600 100644 --- a/src/lib/providers/validation/transport.ts +++ b/src/lib/providers/validation/transport.ts @@ -92,6 +92,35 @@ export function isSecurityBlockError(error: unknown): boolean { return false; } +// #7542 — web-cookie providers whose registry `baseUrl` is a POST-only streaming/completion +// endpoint (no real `/models` listing API), so the generic `/models` probe in +// validateWebCookieProvider() gets a redirect instead of a definitive 200/401/403. A blocked +// redirect there is not a session-expiry signal — the endpoint just isn't shaped for the probe +// — so it should degrade to "unsupported" the same way the discovery path already does for +// REDIRECT_BLOCKED (#6267's buildDiscoveryErrorFallbackResponse). +// +// Scoped to `lmarena` only (root-caused and regression-tested for #7542): the other web-cookie +// providers sharing a POST-only baseUrl shape (doubao-web, huggingchat, yuanbao-web, +// zenmux-free, zai-web) have not been individually verified to actually redirect on this probe +// rather than 404/405 — do not add them here without a proven repro per provider (see +// #7542 plan-file, "Risks"). +const WEB_COOKIE_PROVIDERS_WITH_UNRELIABLE_MODELS_PROBE = new Set(["lmarena"]); + +export function toWebCookieValidationErrorResult(provider: string, error: unknown) { + if ( + error instanceof SafeOutboundFetchError && + error.code === "REDIRECT_BLOCKED" && + WEB_COOKIE_PROVIDERS_WITH_UNRELIABLE_MODELS_PROBE.has(provider) + ) { + return { + valid: false, + error: "Provider validation not supported", + unsupported: true as const, + }; + } + return toValidationErrorResult(error); +} + export function toValidationErrorResult(error: unknown) { const message = error instanceof Error ? error.message : String(error || "Validation failed"); const statusCode = getSafeOutboundFetchErrorStatus(error); diff --git a/tests/unit/arena-cookie-validation-redirect-7542.test.ts b/tests/unit/arena-cookie-validation-redirect-7542.test.ts new file mode 100644 index 0000000000..fd30aa9882 --- /dev/null +++ b/tests/unit/arena-cookie-validation-redirect-7542.test.ts @@ -0,0 +1,52 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Import BEFORE mocking global.fetch — open-sse/utils/proxyFetch.ts overwrites +// globalThis.fetch as a module-load side effect, so a mock installed before the +// import gets clobbered (same pattern as tests/unit/provider-models-qwen-web-redirect-6267.test.ts). +const { validateWebCookieProvider } = await import("../../src/lib/providers/validation.ts"); + +const originalFetch = globalThis.fetch; +test.after(() => { + globalThis.fetch = originalFetch; +}); + +test("should_not_report_Invalid_when_lmarena_models_probe_307_redirects", async () => { + const fetchCalls: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input.toString(); + fetchCalls.push(url); + return new Response(null, { status: 307, headers: { location: "https://arena.ai/" } }); + }) as typeof fetch; + + const result = await validateWebCookieProvider({ + provider: "lmarena", + apiKey: "arena_session=abc123", + providerSpecificData: {}, + }); + + assert.equal(fetchCalls.length, 1); + assert.equal(fetchCalls[0], "https://arena.ai/nextjs-api/stream/create-evaluation/models"); + assert.equal(result.valid, false); + assert.equal( + result.unsupported, + true, + "BUG #7542: current code returns unsupported:false — dashboard renders hard Invalid" + ); +}); + +test("should_still_report_SESSION_EXPIRED_for_lmarena_401", async () => { + globalThis.fetch = (async () => { + return new Response(null, { status: 401 }); + }) as typeof fetch; + + const result = await validateWebCookieProvider({ + provider: "lmarena", + apiKey: "arena_session=abc123", + providerSpecificData: {}, + }); + + assert.equal(result.valid, false); + assert.equal(result.unsupported, false); + assert.equal((result as { error?: string }).error, "SESSION_EXPIRED"); +}); From b4ee34fa02aba52c8815dcba7ecf65878d7aaaf9 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 19 Jul 2026 02:31:23 -0300 Subject: [PATCH 04/22] fix(sse): authenticate CLIProxyAPI fallback/passthrough legs with a dedicated credential (#7645) (#7712) CLIProxyAPI requires its own separately-configured api-keys credential and rejects any other token with 401. Both the direct mode:"cliproxyapi" passthrough leg and the mode:"fallback" retry leg reused the resolved connection's own credentials (the native provider's key) unchanged, making the fallback path a permanent no-op for every provider configured this way. Adds a dedicated cliproxyapi_api_key setting (settingsSchemas.ts) and a new credential-resolution module (cliproxyapiCredentials.ts) that substitutes it in at the executorProxy.ts choke point for both CLIProxyAPI-bound legs, so CliproxyapiExecutor itself stays credential-source-agnostic. Falls back to the connection's own credential when no dedicated key is configured, preserving prior (workaround) behavior. --- .../fixes/7645-cliproxyapi-credential.md | 1 + .../chatCore/cliproxyapiCredentials.ts | 76 +++++++ open-sse/handlers/chatCore/executorProxy.ts | 72 ++++--- src/shared/validation/settingsSchemas.ts | 5 + ...proxyapi-dedicated-credential-7645.test.ts | 196 ++++++++++++++++++ 5 files changed, 326 insertions(+), 24 deletions(-) create mode 100644 changelog.d/fixes/7645-cliproxyapi-credential.md create mode 100644 open-sse/handlers/chatCore/cliproxyapiCredentials.ts create mode 100644 tests/unit/cliproxyapi-dedicated-credential-7645.test.ts diff --git a/changelog.d/fixes/7645-cliproxyapi-credential.md b/changelog.d/fixes/7645-cliproxyapi-credential.md new file mode 100644 index 0000000000..ec1ad8ecfa --- /dev/null +++ b/changelog.d/fixes/7645-cliproxyapi-credential.md @@ -0,0 +1 @@ +- fix(sse): route CLIProxyAPI fallback/passthrough legs through a dedicated `cliproxyapi_api_key` credential instead of the failed native provider's own key (#7645) diff --git a/open-sse/handlers/chatCore/cliproxyapiCredentials.ts b/open-sse/handlers/chatCore/cliproxyapiCredentials.ts new file mode 100644 index 0000000000..4789afb62c --- /dev/null +++ b/open-sse/handlers/chatCore/cliproxyapiCredentials.ts @@ -0,0 +1,76 @@ +/** + * CLIProxyAPI dedicated-credential resolution (#7645). + * + * CLIProxyAPI requires its own separately-configured `api-keys:` credential + * and rejects any other token with 401. Before this fix, both the direct + * `mode: "cliproxyapi"` passthrough leg and the `mode: "fallback"` retry leg + * (`open-sse/handlers/chatCore/executorProxy.ts::resolveExecutorWithProxy`) + * reused the resolved connection's own credentials — the native provider's + * key — as the Authorization header sent to CLIProxyAPI, making the fallback + * path a permanent no-op for every provider configured this way. + * + * This module resolves and applies the dedicated `cliproxyapi_api_key` + * setting at the executor boundary, so `CliproxyapiExecutor` itself stays + * credential-source-agnostic (it just uses whatever `credentials` it's + * handed — see `buildHeaders()`). + */ + +import type { ProviderCredentials } from "../../executors/base.ts"; + +type ExecutorInput = { + credentials: ProviderCredentials; + [key: string]: unknown; +}; + +type ExecutorLike = { + execute: (input: ExecutorInput) => Promise; + [key: string]: unknown; +}; + +/** + * Reads the dedicated CLIProxyAPI key out of a settings blob (as returned by + * `getCachedSettings()`), trimmed and normalized to `null` when absent/blank. + */ +export function resolveDedicatedCliproxyapiApiKey( + settings: Record | null | undefined +): string | null { + const raw = settings?.cliproxyapi_api_key; + return typeof raw === "string" && raw.trim() ? raw.trim() : null; +} + +/** + * Builds the credentials to use for a CLIProxyAPI-bound request. When a + * dedicated key is configured it always wins — CLIProxyAPI is a single + * shared instance serving every provider, so the resolved connection's own + * (provider-specific, and possibly already-failed) credential is never the + * right token for it. Falls back to the connection's own credentials only + * when no dedicated key is configured, preserving the pre-existing behavior + * for operators who previously worked around this by pasting a valid + * CLIProxyAPI key into the connection's own `apiKey` field. + */ +export function resolveCliproxyapiCredentials( + connectionCredentials: ProviderCredentials, + dedicatedApiKey: string | null +): ProviderCredentials { + if (!dedicatedApiKey) return connectionCredentials; + return { ...connectionCredentials, apiKey: dedicatedApiKey, accessToken: undefined }; +} + +/** + * Wraps an executor so every `execute()` call is routed with the dedicated + * CLIProxyAPI credential substituted in when one is configured. No-op + * wrapper when no dedicated key is set (returns the executor unchanged). + */ +export function wrapExecutorWithCliproxyapiCredentials( + executor: T, + dedicatedApiKey: string | null +): T { + if (!dedicatedApiKey) return executor; + const wrapped = Object.create(executor) as T; + wrapped.execute = (input: ExecutorInput) => + executor.execute({ + ...input, + credentials: resolveCliproxyapiCredentials(input.credentials, dedicatedApiKey), + }); + return wrapped; +} diff --git a/open-sse/handlers/chatCore/executorProxy.ts b/open-sse/handlers/chatCore/executorProxy.ts index 870c1bf00d..c791e9c5a4 100644 --- a/open-sse/handlers/chatCore/executorProxy.ts +++ b/open-sse/handlers/chatCore/executorProxy.ts @@ -14,6 +14,10 @@ import { isCliproxyapiDeepModeEnabled } from "../../executors/cliproxyapi.ts"; import { getCachedSettings } from "@/lib/db/readCache"; import { getUpstreamProxyConfigCached } from "./comboContextCache.ts"; import { wrapExecutorWithCliproxyapiModelMapping } from "./cliproxyModelMapping.ts"; +import { + resolveDedicatedCliproxyapiApiKey, + wrapExecutorWithCliproxyapiCredentials, +} from "./cliproxyapiCredentials.ts"; type LoggerLike = | { @@ -24,6 +28,40 @@ type LoggerLike = | null | undefined; +const DEFAULT_FALLBACK_CODES = [429, 500, 502, 503, 504]; + +function parseFallbackCodes(raw: unknown): number[] | null { + if (typeof raw !== "string" || !raw.trim()) return null; + const parsed = raw + .split(",") + .map((s) => Number.parseInt(s.trim(), 10)) + .filter((n) => !Number.isNaN(n)); + return parsed.length > 0 ? parsed : null; +} + +/** + * Reads the CLIProxyAPI-related settings shared by both the direct + * `mode: "cliproxyapi"` passthrough leg and the `mode: "fallback"` retry leg: + * the custom fallback status codes and the dedicated credential (#7645). + * Falls back to defaults / no dedicated key on any read failure. + */ +async function loadCliproxyapiSettings(): Promise<{ + fallbackCodes: number[]; + dedicatedApiKey: string | null; +}> { + try { + const allSettings = await getCachedSettings(); + return { + fallbackCodes: parseFallbackCodes(allSettings.cliproxyapi_fallback_codes) ?? [ + ...DEFAULT_FALLBACK_CODES, + ], + dedicatedApiKey: resolveDedicatedCliproxyapiApiKey(allSettings), + }; + } catch { + return { fallbackCodes: [...DEFAULT_FALLBACK_CODES], dedicatedApiKey: null }; + } +} + export async function resolveExecutorWithProxy( prov: string, log?: LoggerLike, @@ -48,9 +86,10 @@ export async function resolveExecutorWithProxy( if (cfg.mode === "cliproxyapi") { log?.info?.("UPSTREAM_PROXY", `${prov} routed through CLIProxyAPI (passthrough)`); - return wrapExecutorWithCliproxyapiModelMapping( - getExecutor("cliproxyapi"), - cfg.cliproxyapiModelMapping + const { dedicatedApiKey } = await loadCliproxyapiSettings(); + return wrapExecutorWithCliproxyapiCredentials( + wrapExecutorWithCliproxyapiModelMapping(getExecutor("cliproxyapi"), cfg.cliproxyapiModelMapping), + dedicatedApiKey ); } @@ -58,28 +97,13 @@ export async function resolveExecutorWithProxy( // The model mapping applies only to the CLIProxyAPI retry leg (proxyExec) — the // native leg must keep seeing the original, unmapped model. const nativeExec = getExecutor(prov); - const proxyExec = wrapExecutorWithCliproxyapiModelMapping( - getExecutor("cliproxyapi"), - cfg.cliproxyapiModelMapping + const { fallbackCodes, dedicatedApiKey } = await loadCliproxyapiSettings(); + // #7645: the CLIProxyAPI retry leg must authenticate with the dedicated + // key, never the native provider's own (already-failed) credential. + const proxyExec = wrapExecutorWithCliproxyapiCredentials( + wrapExecutorWithCliproxyapiModelMapping(getExecutor("cliproxyapi"), cfg.cliproxyapiModelMapping), + dedicatedApiKey ); - - // Read custom fallback codes from settings. Default: 5xx + 429 + network errors. - let fallbackCodes: number[] = [429, 500, 502, 503, 504]; - try { - const allSettings = await getCachedSettings(); - if ( - typeof allSettings.cliproxyapi_fallback_codes === "string" && - allSettings.cliproxyapi_fallback_codes.trim() - ) { - const parsed = allSettings.cliproxyapi_fallback_codes - .split(",") - .map((s: string) => Number.parseInt(s.trim(), 10)) - .filter((n: number) => !Number.isNaN(n)); - if (parsed.length > 0) fallbackCodes = parsed; - } - } catch { - /* use defaults */ - } const isRetryableStatus = (s: number) => fallbackCodes.includes(s) || s === 0; const wrapper = Object.create(nativeExec); diff --git a/src/shared/validation/settingsSchemas.ts b/src/shared/validation/settingsSchemas.ts index 542188c776..ef5fda94cf 100644 --- a/src/shared/validation/settingsSchemas.ts +++ b/src/shared/validation/settingsSchemas.ts @@ -322,6 +322,11 @@ export const updateSettingsSchema = z.object({ cliproxyapi_fallback_enabled: z.boolean().optional(), cliproxyapi_url: z.string().url().max(500).optional(), cliproxyapi_fallback_codes: z.string().max(200).optional(), + // #7645: dedicated CLIProxyAPI credential. CLIProxyAPI requires its own + // separately-configured `api-keys:` credential and rejects any other token + // with 401 — without this field, the fallback/passthrough legs had no way + // to authenticate except by reusing the (incompatible) native provider key. + cliproxyapi_api_key: z.string().max(500).optional(), // CLIProxyAPI model mapping (Record) cliproxyapi_model_mapping: z.record(z.string(), z.string()).optional(), // Model lockout settings diff --git a/tests/unit/cliproxyapi-dedicated-credential-7645.test.ts b/tests/unit/cliproxyapi-dedicated-credential-7645.test.ts new file mode 100644 index 0000000000..50c09efea2 --- /dev/null +++ b/tests/unit/cliproxyapi-dedicated-credential-7645.test.ts @@ -0,0 +1,196 @@ +/** + * Regression tests for #7645 — CLIProxyAPI fallback/passthrough legs reused + * the failed native provider's own credential as the Authorization header + * sent to CLIProxyAPI, which requires its own dedicated `api-keys:` + * credential and rejects any other token with 401 — a permanent no-op for + * every provider configured with `mode: "fallback"` or `mode: "cliproxyapi"`. + * + * All tests exercise REAL production functions end-to-end: + * - updateSettings / getSettings (src/lib/db/settings.ts) + * - upsertUpstreamProxyConfig (src/lib/db/upstreamProxy.ts) + * - resolveExecutorWithProxy (open-sse/handlers/chatCore/executorProxy.ts) + * - CliproxyapiExecutor.execute (open-sse/executors/cliproxyapi.ts) + * `globalThis.fetch` is stubbed only to capture the outbound wire headers, + * distinguishing the native-provider host from the CLIProxyAPI host + * (127.0.0.1:8317). + */ + +import { describe, it, before, after, afterEach } 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 testDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-7645-cpa-cred-")); +process.env.DATA_DIR = testDataDir; + +const coreDb = await import("../../src/lib/db/core.ts"); +const settingsDb = await import("../../src/lib/db/settings.ts"); +const upstreamProxyDb = await import("../../src/lib/db/upstreamProxy.ts"); +const { resolveExecutorWithProxy } = await import( + "../../open-sse/handlers/chatCore/executorProxy.ts" +); +const { clearUpstreamProxyConfigCache } = await import( + "../../open-sse/handlers/chatCore/comboContextCache.ts" +); +const { updateSettingsSchema } = await import("../../src/shared/validation/settingsSchemas.ts"); + +const NATIVE_KEY = "sk-native-provider-key-cliproxyapi-must-not-see"; +const DEDICATED_KEY = "cpa-dedicated-key-configured-by-operator"; + +before(async () => { + await coreDb.ensureDbInitialized(); +}); + +afterEach(async () => { + clearUpstreamProxyConfigCache(); + const { dbCache } = await import("../../src/lib/db/readCache.ts"); + dbCache?.invalidate?.("settings"); +}); + +after(() => { + coreDb.resetDbInstance(); + if (fs.existsSync(testDataDir)) fs.rmSync(testDataDir, { recursive: true, force: true }); +}); + +type ExecuteInput = { + model: string; + body: unknown; + stream: boolean; + credentials: unknown; +}; + +type ExecutorLike = { execute: (input: ExecuteInput) => Promise }; + +/** + * Stubs fetch so calls to CLIProxyAPI's host (127.0.0.1:8317) are captured + * (headers + succeed with 200), while calls to any other host throw a + * simulated native-provider network failure — driving the "fallback" retry + * leg for real. + */ +async function withCapturedCliproxyapiRequest( + fn: () => Promise +): Promise<{ headers: Record; called: boolean }> { + let capturedHeaders: Record | null = null; + const originalFetch = globalThis.fetch; + // @ts-expect-error test stub + globalThis.fetch = async (url: string, init: RequestInit) => { + if (String(url).includes("8317")) { + capturedHeaders = init.headers as Record; + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + throw new Error("simulated native provider network failure"); + }; + try { + await fn(); + } finally { + globalThis.fetch = originalFetch; + } + return { headers: capturedHeaders ?? {}, called: capturedHeaders !== null }; +} + +describe("#7645 — settingsSchemas has a dedicated cliproxyapi_api_key field", () => { + it("updateSettingsSchema accepts cliproxyapi_api_key", () => { + const shape = (updateSettingsSchema as unknown as { shape: Record }).shape; + assert.equal( + Object.prototype.hasOwnProperty.call(shape, "cliproxyapi_api_key"), + true, + "settingsSchemas.ts must define a dedicated cliproxyapi_api_key field" + ); + }); +}); + +describe("#7645 — CLIProxyAPI fallback leg authenticates with the dedicated key", () => { + it("uses the dedicated cliproxyapi_api_key, not the failed native provider's own credential", async () => { + await settingsDb.updateSettings({ cliproxyapi_api_key: DEDICATED_KEY }); + await upstreamProxyDb.upsertUpstreamProxyConfig({ + providerId: "openai-7645-fallback", + mode: "fallback", + enabled: true, + }); + + const executor = await resolveExecutorWithProxy("openai-7645-fallback", undefined, null); + + const { headers, called } = await withCapturedCliproxyapiRequest(() => + (executor as ExecutorLike).execute({ + model: "gpt-4", + body: { model: "gpt-4", messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: NATIVE_KEY }, + }) + ); + + assert.equal(called, true, "the CLIProxyAPI retry leg must have been invoked"); + assert.equal( + headers.Authorization, + `Bearer ${DEDICATED_KEY}`, + "CLIProxyAPI fallback leg must authenticate with the dedicated key" + ); + assert.notEqual( + headers.Authorization, + `Bearer ${NATIVE_KEY}`, + "CLIProxyAPI fallback leg must not reuse the failed native provider's own credential" + ); + }); + + it("direct cliproxyapi passthrough mode also uses the dedicated key", async () => { + await settingsDb.updateSettings({ cliproxyapi_api_key: DEDICATED_KEY }); + await upstreamProxyDb.upsertUpstreamProxyConfig({ + providerId: "anthropic-7645-passthrough", + mode: "cliproxyapi", + enabled: true, + }); + + const executor = await resolveExecutorWithProxy("anthropic-7645-passthrough", undefined, null); + + const { headers, called } = await withCapturedCliproxyapiRequest(() => + (executor as ExecutorLike).execute({ + model: "claude-3-opus", + body: { model: "claude-3-opus", messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: NATIVE_KEY }, + }) + ); + + assert.equal(called, true, "the CLIProxyAPI passthrough leg must have been invoked"); + assert.equal( + headers.Authorization, + `Bearer ${DEDICATED_KEY}`, + "CLIProxyAPI passthrough mode must authenticate with the dedicated key" + ); + }); + + it("falls back to the connection's own credential when no dedicated key is configured (no regression)", async () => { + await settingsDb.updateSettings({ cliproxyapi_api_key: "" }); + await upstreamProxyDb.upsertUpstreamProxyConfig({ + providerId: "anthropic-7645-no-dedicated-key", + mode: "cliproxyapi", + enabled: true, + }); + + const executor = await resolveExecutorWithProxy( + "anthropic-7645-no-dedicated-key", + undefined, + null + ); + + const { headers, called } = await withCapturedCliproxyapiRequest(() => + (executor as ExecutorLike).execute({ + model: "claude-3-opus", + body: { model: "claude-3-opus", messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: NATIVE_KEY }, + }) + ); + + assert.equal(called, true); + assert.equal( + headers.Authorization, + `Bearer ${NATIVE_KEY}`, + "with no dedicated key configured, the pre-existing (workaround) behavior must be preserved" + ); + }); +}); From a9eb25b93c987ef07a1f728d7322b97f050b0cff Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 19 Jul 2026 02:31:26 -0300 Subject: [PATCH 05/22] fix(claude-web): unify Turnstile/executor/fast-path User-Agents behind one fingerprint (#7548) (#7711) --- changelog.d/fixes/7548-claude-web-ua.md | 1 + open-sse/config/claudeWebFingerprint.ts | 30 ++++++++ open-sse/executors/claude-web.ts | 8 +-- open-sse/services/browserBackedChat.ts | 10 +-- open-sse/services/claudeTurnstileSolver.ts | 11 +-- .../services/httpBackedChatFingerprint.ts | 29 ++++++++ ...ude-web-turnstile-ua-mismatch-7548.test.ts | 70 +++++++++++++++++++ 7 files changed, 146 insertions(+), 13 deletions(-) create mode 100644 changelog.d/fixes/7548-claude-web-ua.md create mode 100644 open-sse/config/claudeWebFingerprint.ts create mode 100644 open-sse/services/httpBackedChatFingerprint.ts create mode 100644 tests/unit/claude-web-turnstile-ua-mismatch-7548.test.ts diff --git a/changelog.d/fixes/7548-claude-web-ua.md b/changelog.d/fixes/7548-claude-web-ua.md new file mode 100644 index 0000000000..c5f2d5f623 --- /dev/null +++ b/changelog.d/fixes/7548-claude-web-ua.md @@ -0,0 +1 @@ +- fix(claude-web): unify Turnstile solver, executor and httpBackedChat fast-path User-Agents behind one shared fingerprint module so `cf_clearance` is never solved under a different UA than the one that replays it (#7548) diff --git a/open-sse/config/claudeWebFingerprint.ts b/open-sse/config/claudeWebFingerprint.ts new file mode 100644 index 0000000000..1e8d996d8f --- /dev/null +++ b/open-sse/config/claudeWebFingerprint.ts @@ -0,0 +1,30 @@ +/** + * Claude Web — shared browser fingerprint source of truth + * + * Cloudflare binds the `cf_clearance` cookie minted by the Turnstile solver + * to the User-Agent (+ TLS/JA3 fingerprint + IP) that solved the challenge. + * If the completion request later replays that cookie under a *different* + * User-Agent, Cloudflare rejects it and the executor surfaces a persistent + * 429 (see #7548). + * + * Every part of the claude-web pipeline that talks to claude.ai — the + * Turnstile solver, the direct-fetch executor, and the httpBackedChat + * fast path — MUST derive its User-Agent / Client-Hints headers from this + * single constant so they can never drift apart again. + * + * Platform choice: Linux, matching the `chrome_146` TLS/JA3 profile used by + * `claudeTlsClient.ts` and the browser-pool default (`browserPool.ts`). + */ +export const CLAUDE_WEB_FINGERPRINT = { + userAgent: + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36", + secChUa: '"Chromium";v="149", "Not-A.Brand";v="24", "Google Chrome";v="149"', + secChUaPlatform: '"Linux"', +} as const; + +/** + * Bump this whenever `CLAUDE_WEB_FINGERPRINT` changes so any previously + * cached `cf_clearance` token (minted under the old fingerprint) is treated + * as stale rather than replayed under the new one. + */ +export const CLAUDE_WEB_FINGERPRINT_VERSION = "v2-linux-unified"; diff --git a/open-sse/executors/claude-web.ts b/open-sse/executors/claude-web.ts index 7e26777339..b28f585078 100644 --- a/open-sse/executors/claude-web.ts +++ b/open-sse/executors/claude-web.ts @@ -23,6 +23,7 @@ import { BaseExecutor, mergeAbortSignals, type ExecuteInput } from "./base.ts"; import { FETCH_TIMEOUT_MS } from "../config/constants.ts"; import { tlsFetchClaude } from "../services/claudeTlsClient.ts"; import { getCfClearanceToken } from "../services/claudeTurnstileSolver.ts"; +import { CLAUDE_WEB_FINGERPRINT } from "../config/claudeWebFingerprint.ts"; import { normalizeSessionCookieHeader } from "@/lib/providers/webCookieAuth"; import { randomUUID } from "crypto"; import { sanitizeErrorMessage } from "../utils/error.ts"; @@ -37,8 +38,7 @@ import { const CLAUDE_WEB_API_BASE = "https://claude.ai/api"; const CLAUDE_WEB_ORGS_URL = `${CLAUDE_WEB_API_BASE}/organizations`; -const CLAUDE_USER_AGENT = - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; +const CLAUDE_USER_AGENT = CLAUDE_WEB_FINGERPRINT.userAgent; // Session cookie constants const CLAUDE_SESSION_COOKIE_NAME = "sessionKey"; @@ -108,9 +108,9 @@ function getBrowserHeaders(deviceId?: string): Record { Pragma: "no-cache", Priority: "u=1, i", Referer: "https://claude.ai/new", - "Sec-Ch-Ua": '"Chromium";v="149", "Not-A.Brand";v="24", "Google Chrome";v="149"', + "Sec-Ch-Ua": CLAUDE_WEB_FINGERPRINT.secChUa, "Sec-Ch-Ua-Mobile": "?0", - "Sec-Ch-Ua-Platform": '"Linux"', + "Sec-Ch-Ua-Platform": CLAUDE_WEB_FINGERPRINT.secChUaPlatform, "Sec-Fetch-Dest": "empty", "Sec-Fetch-Mode": "cors", "Sec-Fetch-Site": "same-origin", diff --git a/open-sse/services/browserBackedChat.ts b/open-sse/services/browserBackedChat.ts index 71e63b46d3..567e22338a 100644 --- a/open-sse/services/browserBackedChat.ts +++ b/open-sse/services/browserBackedChat.ts @@ -26,6 +26,7 @@ import { } from "./browserPool.ts"; import tlsClient from "../utils/tlsClient.ts"; import { sanitizeErrorMessage } from "../utils/error.ts"; +import { resolveHttpBackedChatFingerprint } from "./httpBackedChatFingerprint.ts"; // Safety constants const MAX_RESPONSE_BYTES = 10 * 1024 * 1024; // 10 MB @@ -441,11 +442,10 @@ export async function httpBackedChat( const t0 = Date.now(); const { chatUrl, userMessage, cookieString, cookieDomain, chatUrlMatchDomain, signal } = req; - + const fingerprint = resolveHttpBackedChatFingerprint(chatUrlMatchDomain); // #7548 // Build browser-emulated headers const headers: Record = { - "User-Agent": - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36", + "User-Agent": fingerprint.userAgent, Accept: "text/event-stream, application/json, text/plain, */*", "Accept-Language": "en-US,en;q=0.9", "Content-Type": "application/json", @@ -460,9 +460,9 @@ export async function httpBackedChat( "Sec-Fetch-Dest": "empty", "Sec-Fetch-Mode": "cors", "Sec-Fetch-Site": "same-origin", - "Sec-Ch-Ua": '"Chromium";v="149", "Google Chrome";v="149", "Not-A.Brand";v="99"', + "Sec-Ch-Ua": fingerprint.secChUa, "Sec-Ch-Ua-Mobile": "?0", - "Sec-Ch-Ua-Platform": '"macOS"', + "Sec-Ch-Ua-Platform": fingerprint.secChUaPlatform, Priority: "u=1, i", }; diff --git a/open-sse/services/claudeTurnstileSolver.ts b/open-sse/services/claudeTurnstileSolver.ts index 2f3af01fa3..a9164839a3 100644 --- a/open-sse/services/claudeTurnstileSolver.ts +++ b/open-sse/services/claudeTurnstileSolver.ts @@ -11,6 +11,10 @@ */ import type { Browser, Page } from "playwright"; +import { + CLAUDE_WEB_FINGERPRINT, + CLAUDE_WEB_FINGERPRINT_VERSION, +} from "../config/claudeWebFingerprint.ts"; const CLAUDE_WEB_URL = "https://claude.ai"; const CHALLENGE_TIMEOUT = 60000; // 60s to solve challenge @@ -85,8 +89,7 @@ export async function solveTurnstile(options?: { const { chromium } = await import("playwright"); browser = await chromium.launch({ headless }); const context = await browser.newContext({ - userAgent: - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36", + userAgent: CLAUDE_WEB_FINGERPRINT.userAgent, viewport: { width: 1280, height: 720 }, ignoreHTTPSErrors: process.env.OMNIROUTE_TURNSTILE_IGNORE_TLS_ERRORS === "true", }); @@ -149,7 +152,7 @@ export async function getCfClearanceToken(options?: { force?: boolean; headless?: boolean; }): Promise { - const cacheKey = "claude-cf-clearance"; + const cacheKey = `claude-cf-clearance-${CLAUDE_WEB_FINGERPRINT_VERSION}`; const cached = tokenCache.get(cacheKey); if (cfClearanceTokenOverride) { @@ -193,7 +196,7 @@ export function getCacheStatus(): { hasCached: boolean; expiresIn?: number; } { - const cacheKey = "claude-cf-clearance"; + const cacheKey = `claude-cf-clearance-${CLAUDE_WEB_FINGERPRINT_VERSION}`; const cached = tokenCache.get(cacheKey); if (!cached) { diff --git a/open-sse/services/httpBackedChatFingerprint.ts b/open-sse/services/httpBackedChatFingerprint.ts new file mode 100644 index 0000000000..ea2cb408b3 --- /dev/null +++ b/open-sse/services/httpBackedChatFingerprint.ts @@ -0,0 +1,29 @@ +/** + * Header fingerprint resolution for `httpBackedChat()`. + * + * claude.ai MUST reuse the exact fingerprint the Turnstile solver used to + * mint `cf_clearance` (see `open-sse/config/claudeWebFingerprint.ts`) — + * otherwise Cloudflare rejects the replayed cookie and every request 429s + * (#7548). Other `httpBackedChat` callers (e.g. duckduckgo-web) keep their + * own independent fingerprint, which never needs to match a solved cookie. + */ +import { CLAUDE_WEB_FINGERPRINT } from "../config/claudeWebFingerprint.ts"; + +export interface HttpBackedChatFingerprint { + userAgent: string; + secChUa: string; + secChUaPlatform: string; +} + +const DUCKDUCKGO_FALLBACK_FINGERPRINT: HttpBackedChatFingerprint = { + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36", + secChUa: '"Chromium";v="149", "Google Chrome";v="149", "Not-A.Brand";v="99"', + secChUaPlatform: '"macOS"', +}; + +export function resolveHttpBackedChatFingerprint( + chatUrlMatchDomain: string +): HttpBackedChatFingerprint { + return chatUrlMatchDomain === "claude.ai" ? CLAUDE_WEB_FINGERPRINT : DUCKDUCKGO_FALLBACK_FINGERPRINT; +} diff --git a/tests/unit/claude-web-turnstile-ua-mismatch-7548.test.ts b/tests/unit/claude-web-turnstile-ua-mismatch-7548.test.ts new file mode 100644 index 0000000000..ad732ec507 --- /dev/null +++ b/tests/unit/claude-web-turnstile-ua-mismatch-7548.test.ts @@ -0,0 +1,70 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { CLAUDE_WEB_FINGERPRINT } from "../../open-sse/config/claudeWebFingerprint.ts"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(__dirname, "..", ".."); +const readSource = (rel: string) => readFileSync(join(REPO_ROOT, rel), "utf8"); + +function detectPlatform(ua: string): string { + if (/Windows/i.test(ua)) return "Windows"; + if (/Macintosh|Mac OS X/i.test(ua)) return "macOS"; + if (/X11; Linux/i.test(ua)) return "Linux"; + return "unknown"; +} + +const IMPORT_PATTERN = /import\s*\{\s*CLAUDE_WEB_FINGERPRINT[^}]*\}\s*from\s*"[^"]*config\/claudeWebFingerprint\.ts"/; + +test("claude-web: Turnstile solver derives its UA from the shared fingerprint module (#7548)", () => { + const solverSrc = readSource("open-sse/services/claudeTurnstileSolver.ts"); + + assert.ok( + IMPORT_PATTERN.test(solverSrc), + "claudeTurnstileSolver.ts must import CLAUDE_WEB_FINGERPRINT from the shared module" + ); + assert.match(solverSrc, /userAgent:\s*CLAUDE_WEB_FINGERPRINT\.userAgent/); + // No stray hardcoded UA literal left behind (that's exactly how #7548 regressed). + assert.ok( + !/Mozilla\/5\.0[^"]*"/.test(solverSrc), + "solver must not hardcode a UA literal anymore — it must come from CLAUDE_WEB_FINGERPRINT" + ); +}); + +test("claude-web: executor CLAUDE_USER_AGENT and Sec-Ch-Ua-Platform derive from the shared fingerprint (#7548)", () => { + const executorSrc = readSource("open-sse/executors/claude-web.ts"); + + assert.ok( + IMPORT_PATTERN.test(executorSrc), + "claude-web.ts must import CLAUDE_WEB_FINGERPRINT from the shared module" + ); + assert.match(executorSrc, /const CLAUDE_USER_AGENT = CLAUDE_WEB_FINGERPRINT\.userAgent;/); + assert.match(executorSrc, /"Sec-Ch-Ua-Platform":\s*CLAUDE_WEB_FINGERPRINT\.secChUaPlatform/); +}); + +test("claude-web: httpBackedChat fast path uses the shared fingerprint for the claude.ai branch (#7548)", () => { + const fastPathSrc = readSource("open-sse/services/browserBackedChat.ts"); + const resolverSrc = readSource("open-sse/services/httpBackedChatFingerprint.ts"); + + assert.match( + fastPathSrc, + /resolveHttpBackedChatFingerprint/, + "browserBackedChat.ts must resolve headers via resolveHttpBackedChatFingerprint()" + ); + assert.ok( + IMPORT_PATTERN.test(resolverSrc), + "httpBackedChatFingerprint.ts must import CLAUDE_WEB_FINGERPRINT from the shared module" + ); + assert.match(resolverSrc, /chatUrlMatchDomain === "claude\.ai" \? CLAUDE_WEB_FINGERPRINT/); +}); + +test("claude-web: solver, executor and fast-path UAs are all the same value at runtime, platform Linux (#7548)", () => { + // This is the actual regression: before the fix these three literals were + // "Windows", "Linux" and "macOS" respectively — a cf_clearance token minted + // under one UA got replayed under a different one and Cloudflare rejected + // it, surfacing as a persistent 429 on every claude-web request. + assert.equal(detectPlatform(CLAUDE_WEB_FINGERPRINT.userAgent), "Linux"); + assert.equal(CLAUDE_WEB_FINGERPRINT.secChUaPlatform, '"Linux"'); +}); From 69bbcafcb40d18d3e48d9f36d905291c7e84d5fc Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 19 Jul 2026 02:31:36 -0300 Subject: [PATCH 06/22] fix(providers): classify ambiguous Mistral 401 instead of hard auth error (#7638) (#7718) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mistral's quota-exhausted response is a bare 401 with a contentless {"detail":"Unauthorized"} body — byte-identical to a genuinely revoked key. classifyFailure() in the connection-test route always resolved this to upstream_auth_error ("Invalid API key"), hiding the real quota-exhaustion cause and misleading operators into rotating a still- valid key. classifyFailure() now accepts an optional `provider` and, for a bare Mistral 401 with no explicit auth signal in the message (no "invalid api key" / "token invalid" / "revoked" / "access denied" text), returns `upstream_ambiguous_auth_or_quota` instead. A Mistral 401 that DOES carry an explicit auth signal, and any non-Mistral 401, are unaffected and still classify as upstream_auth_error (baseline preserved). The new branching logic lives in a new module (mistralAmbiguousAuth.ts) rather than inline in route.ts, keeping that frozen file's line count within its file-size-baseline.json budget. TDD: tests/unit/provider-test-mistral-401-classify.test.ts reproduces the bug (RED against unfixed classifyFailure), proves the fix (GREEN), and pins the baseline non-Mistral-401 behavior per the owner's explicit requirement. --- .../fixes/7638-mistral-401-classify.md | 1 + .../[id]/test/mistralAmbiguousAuth.ts | 57 +++++++++++++++++++ src/app/api/providers/[id]/test/route.ts | 17 +++--- ...provider-test-mistral-401-classify.test.ts | 27 +++++++++ 4 files changed, 92 insertions(+), 10 deletions(-) create mode 100644 changelog.d/fixes/7638-mistral-401-classify.md create mode 100644 src/app/api/providers/[id]/test/mistralAmbiguousAuth.ts create mode 100644 tests/unit/provider-test-mistral-401-classify.test.ts diff --git a/changelog.d/fixes/7638-mistral-401-classify.md b/changelog.d/fixes/7638-mistral-401-classify.md new file mode 100644 index 0000000000..a725fc6bbf --- /dev/null +++ b/changelog.d/fixes/7638-mistral-401-classify.md @@ -0,0 +1 @@ +- fix(providers): classify Mistral ambiguous 401 (quota vs revoked key) instead of asserting hard auth failure (#7638) diff --git a/src/app/api/providers/[id]/test/mistralAmbiguousAuth.ts b/src/app/api/providers/[id]/test/mistralAmbiguousAuth.ts new file mode 100644 index 0000000000..8f47be63a5 --- /dev/null +++ b/src/app/api/providers/[id]/test/mistralAmbiguousAuth.ts @@ -0,0 +1,57 @@ +/** + * #7638: Mistral's quota-exhausted response is `401 {"detail":"Unauthorized"}` — byte-identical + * to a genuinely revoked key. Unlike other providers, a bare Mistral 401 with no auth-specific + * signal in the body cannot be trusted as a hard auth failure; the generic 401/403 branch in + * classifyFailure() delegates here so it can return an ambiguous diagnosis instead of asserting + * "Invalid API key" outright. + * A message that DOES carry an explicit auth signal (e.g. "Invalid API key") still falls + * through to the normal `upstream_auth_error` result — only the contentless case is ambiguous. + */ +export interface AuthOr401Diagnosis { + type: string; + source: string; + message: string | null; + code: string | null; +} + +/** Param type for classifyFailure() in route.ts — extracted here to keep that frozen file's LOC flat. */ +export interface ClassifyFailureArgs { + error: string; + statusCode?: number | null; + refreshFailed?: boolean; + unsupported?: boolean; + provider?: string; +} + +function isMistralAmbiguous401(provider: string | undefined, normalized: string): boolean { + if (provider !== "mistral") return false; + const hasAuthSignal = + normalized.includes("invalid api key") || + normalized.includes("token invalid") || + normalized.includes("revoked") || + normalized.includes("access denied"); + return !hasAuthSignal; +} + +/** Decides the diagnosis for a 401/403 status: ambiguous (Mistral-only) or the generic auth error. */ +export function classifyAmbiguousOrAuthError( + provider: string | undefined, + normalized: string, + message: string, + numericStatus: number +): AuthOr401Diagnosis { + if (numericStatus === 401 && isMistralAmbiguous401(provider, normalized)) { + return { + type: "upstream_ambiguous_auth_or_quota", + source: "upstream", + message: message || null, + code: String(numericStatus), + }; + } + return { + type: "upstream_auth_error", + source: "upstream", + message: message || null, + code: String(numericStatus), + }; +} diff --git a/src/app/api/providers/[id]/test/route.ts b/src/app/api/providers/[id]/test/route.ts index 65e570a10c..c7d78fb2d8 100644 --- a/src/app/api/providers/[id]/test/route.ts +++ b/src/app/api/providers/[id]/test/route.ts @@ -24,6 +24,7 @@ import { } from "@/lib/oauth/gitlab"; import { providerAllowsOptionalApiKey } from "@/shared/constants/providers"; import { removeConnectionHealth } from "@omniroute/open-sse/services/apiKeyRotator.ts"; +import { classifyAmbiguousOrAuthError, type ClassifyFailureArgs } from "./mistralAmbiguousAuth"; // Bound the OAuth probe so a hung upstream can't block the connection-test queue // forever (#1449). Mirrors the 30s timeout the API-key path uses via validateProviderApiKey. @@ -188,12 +189,8 @@ export function classifyFailure({ statusCode = null, refreshFailed = false, unsupported = false, -}: { - error: string; - statusCode?: number | null; - refreshFailed?: boolean; - unsupported?: boolean; -}) { + provider, +}: ClassifyFailureArgs) { const message = toSafeMessage(error, "Connection test failed"); const normalized = message.toLowerCase(); const numericStatus = Number.isFinite(statusCode) ? Number(statusCode) : null; @@ -214,7 +211,7 @@ export function classifyFailure({ } if (numericStatus === 401 || numericStatus === 403) { - return makeDiagnosis("upstream_auth_error", "upstream", message, String(numericStatus)); + return classifyAmbiguousOrAuthError(provider, normalized, message, numericStatus); } if (numericStatus === 429) { @@ -722,14 +719,14 @@ async function testApiKeyConnection(connection: any) { return { valid: false, error, - diagnosis: classifyFailure({ error, unsupported: true }), + diagnosis: classifyFailure({ error, unsupported: true, provider: connection.provider }), }; } const error = result.valid ? null : result.error || "Invalid API key"; const diagnosis = result.valid ? makeDiagnosis("ok", "upstream", null, null) - : classifyFailure({ error }); + : classifyFailure({ error, statusCode: result.statusCode, provider: connection.provider }); return { valid: !!result.valid, @@ -813,7 +810,7 @@ export async function testSingleConnection(connectionId: string, validationModel result.diagnosis || (result.valid ? makeDiagnosis("ok", "local", null, null) - : classifyFailure({ error: result.error, statusCode: result.statusCode })); + : classifyFailure({ error: result.error, statusCode: result.statusCode, provider })); const updateData: Record = { testStatus: result.valid ? "active" : "error", diff --git a/tests/unit/provider-test-mistral-401-classify.test.ts b/tests/unit/provider-test-mistral-401-classify.test.ts new file mode 100644 index 0000000000..73a627dc92 --- /dev/null +++ b/tests/unit/provider-test-mistral-401-classify.test.ts @@ -0,0 +1,27 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { classifyFailure } = await import("../../src/app/api/providers/[id]/test/route.ts"); + +test("#7638: a Mistral ambiguous 401 (contentless body) should not be asserted as a hard auth_failed", () => { + const d = classifyFailure({ + error: '{"detail":"Unauthorized"}', + statusCode: 401, + provider: "mistral", + }); + assert.notEqual(d.type, "upstream_auth_error"); +}); + +test("#7638 baseline: a non-Mistral bare 401 still correctly classifies as upstream_auth_error", () => { + const d = classifyFailure({ error: '{"detail":"Unauthorized"}', statusCode: 401 }); + assert.equal(d.type, "upstream_auth_error"); +}); + +test("#7638: a Mistral 401 with an explicit auth-signal message still classifies as upstream_auth_error", () => { + const d = classifyFailure({ + error: "Invalid API key", + statusCode: 401, + provider: "mistral", + }); + assert.equal(d.type, "upstream_auth_error"); +}); From 313cbefda46f46ce5cfaa50c802650c9ca2e6a0c Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 19 Jul 2026 02:34:49 -0300 Subject: [PATCH 07/22] fix(sse): proactively refresh Grok Build OAuth token before dispatch (#7610) (#7715) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GrokCliExecutor.execute() dispatches via raw https.request (nativePost) instead of the shared fetch path, so it never inherited (nor delegated to) BaseExecutor.execute()'s proactive-refresh gate the way codex.ts does via super.execute(). The only refresh that ever fired was the reactive one on a 401/403 from upstream — the rotating xAI refresh_token idled until real expiry, matching the "unusable within minutes, must delete/re-add" report. Wires in the same needsRefresh()/refreshCredentials() gate, using runWithOnPersist + isUnrecoverableRefreshError to keep the [refresh + persist] atomic under the same per-connection mutex Codex/Claude rely on for rotating refresh tokens (base.ts:592-644). Also fixes the smaller, separate bug #2 from the same report: grok-cli was absent from OAUTH_TEST_CONFIG in the connection-test route, so "Test Connection" always reported "Provider test not supported" regardless of token health. Added a checkExpiry entry (same pattern as qwen/cline/ kilocode — Grok Build's proxy doesn't expose a lightweight probe endpoint with the cli-specific headers this shared prober sends). Extracted OAUTH_TEST_CONFIG into its own module (oauthTestConfig.ts) so the new entry doesn't grow the frozen route.ts past its file-size cap. Bug #3 (no browser/device-code login for Grok Build) and bug #4 (quota display) from the same issue are feature gaps, not regressions — left as follow-ups per the triage plan-file. Refs #7610 --- .../fixes/7610-grok-proactive-refresh.md | 1 + open-sse/executors/grok-cli.ts | 70 +++++++++- .../providers/[id]/test/oauthTestConfig.ts | 129 ++++++++++++++++++ src/app/api/providers/[id]/test/route.ts | 122 +---------------- ...grok-cli-oauth-test-supported-7610.test.ts | 26 ++++ .../grok-cli-proactive-refresh-7610.test.ts | 64 +++++++++ 6 files changed, 288 insertions(+), 124 deletions(-) create mode 100644 changelog.d/fixes/7610-grok-proactive-refresh.md create mode 100644 src/app/api/providers/[id]/test/oauthTestConfig.ts create mode 100644 tests/unit/grok-cli-oauth-test-supported-7610.test.ts create mode 100644 tests/unit/grok-cli-proactive-refresh-7610.test.ts diff --git a/changelog.d/fixes/7610-grok-proactive-refresh.md b/changelog.d/fixes/7610-grok-proactive-refresh.md new file mode 100644 index 0000000000..c6525e0aeb --- /dev/null +++ b/changelog.d/fixes/7610-grok-proactive-refresh.md @@ -0,0 +1 @@ +- fix(sse): proactively refresh Grok Build's expiring OAuth token before dispatch, and add it to the connection-test config so "Test Connection" no longer reports "unsupported" (#7610) diff --git a/open-sse/executors/grok-cli.ts b/open-sse/executors/grok-cli.ts index 15f2e6e920..c28813c069 100644 --- a/open-sse/executors/grok-cli.ts +++ b/open-sse/executors/grok-cli.ts @@ -16,6 +16,7 @@ import { import { PROVIDERS } from "../config/constants.ts"; import { resolvePublicCred } from "../utils/publicCreds.ts"; import { resolveProxyForRequest } from "../utils/proxyFetch.ts"; +import { runWithOnPersist, isUnrecoverableRefreshError } from "../services/tokenRefresh.ts"; import https from "node:https"; import { HttpsProxyAgent } from "https-proxy-agent"; @@ -76,17 +77,78 @@ export class GrokCliExecutor extends BaseExecutor { } async execute(input: ExecuteInput) { - const { model, body, stream, credentials, signal } = input; + const { model, body, stream, credentials, signal, log, onCredentialsRefreshed } = input; - const url = this.buildUrl(model, stream, 0, credentials); - const headers = this.buildHeaders(credentials, stream); - const transformedBody = this.transformRequest(model, body, stream, credentials); + // #7610: unlike BaseExecutor.execute() (which most executors inherit or + // delegate to via super.execute()), this executor talks upstream via raw + // https.request() (nativePost) instead of the shared fetch path, so it + // never picked up the base class's proactive refresh gate. Without it, + // xAI's rotating refresh_token idled until real expiry — the only refresh + // that fired was the reactive one on a 401/403 from upstream — matching + // the "unusable within minutes" report. Apply the same gate here. + const activeCredentials = await this.applyProactiveRefresh( + credentials, + log, + onCredentialsRefreshed + ); + + const url = this.buildUrl(model, stream, 0, activeCredentials); + const headers = this.buildHeaders(activeCredentials, stream); + const transformedBody = this.transformRequest(model, body, stream, activeCredentials); const bodyStr = JSON.stringify(transformedBody); const response = await this.nativePost(url, headers, bodyStr, signal); return { response, url, headers, transformedBody }; } + /** + * Proactive-refresh gate mirroring BaseExecutor.execute()'s (base.ts:599-685), + * scoped to grok-cli's single-URL nativePost dispatch (no fallback-URL retry + * loop to thread through). xAI uses rotating refresh tokens (same family as + * Codex/Claude) — `runWithOnPersist` keeps the [refresh + persist] atomic + * under the same per-connection mutex `getAccessToken` uses, and + * `isUnrecoverableRefreshError` keeps a reused/invalid sentinel from being + * spread into the outgoing credentials — see base.ts:622-673 for the full + * regression history this mirrors. + */ + private async applyProactiveRefresh( + credentials: ProviderCredentials, + log?: ExecutorLog | null, + onCredentialsRefreshed?: ExecuteInput["onCredentialsRefreshed"] + ): Promise { + if (!this.needsRefresh(credentials)) return credentials; + + try { + let persistRan = false; + const onPersist = onCredentialsRefreshed + ? async (refreshResult: Record) => { + persistRan = true; + await onCredentialsRefreshed(refreshResult as Partial); + } + : null; + + const refreshed = await runWithOnPersist(onPersist, () => + this.refreshCredentials(credentials, log || null) + ); + + if (!refreshed || isUnrecoverableRefreshError(refreshed)) { + return credentials; + } + + const merged = { ...credentials, ...refreshed }; + if (onCredentialsRefreshed && !persistRan) { + await onCredentialsRefreshed(refreshed); + } + return merged; + } catch (error) { + log?.error?.( + "TOKEN", + `Credential refresh failed for ${this.provider}: ${error instanceof Error ? error.message : String(error)}` + ); + return credentials; + } + } + async refreshCredentials( credentials: ProviderCredentials, log?: ExecutorLog | null diff --git a/src/app/api/providers/[id]/test/oauthTestConfig.ts b/src/app/api/providers/[id]/test/oauthTestConfig.ts new file mode 100644 index 0000000000..22c4bdc601 --- /dev/null +++ b/src/app/api/providers/[id]/test/oauthTestConfig.ts @@ -0,0 +1,129 @@ +import { buildGitLabOAuthEndpoints, resolveGitLabOAuthBaseUrl } from "@/lib/oauth/gitlab"; + +// OAuth provider test endpoints. Extracted from route.ts (#7610) so adding a +// provider entry doesn't grow the frozen route.ts file past its check-file-size +// cap — this module carries no logic of its own beyond the GitLab URL builder. +export const OAUTH_TEST_CONFIG = { + claude: { + // Claude doesn't have userinfo, we verify token exists and not expired + checkExpiry: true, + refreshable: true, + }, + codex: { + // Port of decolua/9router#347: probe the real Codex /responses endpoint instead + // of relying on `checkExpiry`. Codex OAuth tokens are ChatGPT session tokens + // (not OpenAI API keys) — api.openai.com/v1/models rejects them with 403. + // Hitting the actual endpoint with a minimal invalid body returns 400 when + // auth is accepted (the body is the reason for the failure) and 401/403 when + // the token is bad. That is a real auth signal — checkExpiry alone could not + // distinguish a revoked-but-not-yet-expired token from a working one. + url: "https://chatgpt.com/backend-api/codex/responses", + method: "POST", + authHeader: "Authorization", + authPrefix: "Bearer ", + extraHeaders: { + "Content-Type": "application/json", + originator: "codex-cli", + "User-Agent": "codex-cli/1.0.18 (macOS; arm64)", + }, + // Minimal invalid body — triggers a fast 400 without consuming quota. + // #7521: probe with a ChatGPT-account-supported model. "gpt-5.3-codex" is a + // codex-only id that ChatGPT accounts reject with a 400 for the WRONG reason + // (unsupported model, not "auth ok, body invalid") — collapsing the auth signal + // so a bad token looks the same as a good one. "gpt-5.5" is served for + // ChatGPT sessions; `input: []` still yields the intended 400. + body: JSON.stringify({ model: "gpt-5.5", input: [], stream: false, store: false }), + // 400 = bad request, but auth was accepted; only 401/403 means the token is bad. + acceptStatuses: [400], + refreshable: true, + }, + antigravity: { + url: "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", + method: "GET", + authHeader: "Authorization", + authPrefix: "Bearer ", + refreshable: true, + }, + xai: { + url: "https://api.x.ai/v1/chat/completions", + method: "POST", + authHeader: "Authorization", + authPrefix: "Bearer ", + extraHeaders: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "grok-4.3", + messages: [{ role: "user", content: "ping" }], + max_tokens: 1, + stream: false, + reasoning: { effort: "high" }, + }), + refreshable: true, + }, + github: { + url: "https://api.github.com/user", + method: "GET", + authHeader: "Authorization", + authPrefix: "Bearer ", + extraHeaders: { "User-Agent": "OmniRoute", Accept: "application/vnd.github+json" }, + }, + "gitlab-duo": { + getUrl: (connection: any) => + buildGitLabOAuthEndpoints(resolveGitLabOAuthBaseUrl(connection?.providerSpecificData)) + .directAccessUrl, + method: "POST", + authHeader: "Authorization", + authPrefix: "Bearer ", + refreshable: true, + }, + qwen: { + // DashScope (previously portal.qwen.ai) /v1/models might return 404 or auth issues. + // Use checkExpiry instead — actual connectivity is validated via real requests. + checkExpiry: true, + refreshable: true, + }, + cursor: { + checkExpiry: true, + }, + "kimi-coding": { + checkExpiry: true, + refreshable: true, + }, + kilocode: { + // Kilo OAuth does not expose a stable user-info endpoint in all environments. + // Validate using token presence/expiry as a lightweight auth check. + checkExpiry: true, + }, + cline: { + // Cline's /api/v1/models endpoint frequently returns stale auth errors even + // with fresh tokens. Use checkExpiry instead — actual connectivity is validated + // via real requests. + checkExpiry: true, + refreshable: true, + }, + kiro: { + checkExpiry: true, + refreshable: true, + }, + "amazon-q": { + checkExpiry: true, + refreshable: true, + }, + "codebuddy-cn": { + // Upstream test endpoint mirrors "tokenExists: true" from the CodeBuddy port — + // validate auth via token presence + refresh path. Live connectivity is + // verified through real /v2/chat/completions traffic. + checkExpiry: true, + refreshable: true, + }, + "grok-cli": { + // #7610: was entirely absent from OAUTH_TEST_CONFIG, so "Test Connection" + // always fell through to the generic "Provider test not supported" branch + // below. Grok Build's cli-chat-proxy endpoint doesn't expose a lightweight + // userinfo probe, and it enforces cli-specific headers (see + // GrokCliExecutor.buildHeaders) that this shared prober doesn't send — so + // mirror qwen/cline/kilocode's checkExpiry pattern instead of a live probe. + // Real connectivity is still validated on every chat/completions request. + checkExpiry: true, + refreshable: true, + }, +}; diff --git a/src/app/api/providers/[id]/test/route.ts b/src/app/api/providers/[id]/test/route.ts index c7d78fb2d8..39066bc4fe 100644 --- a/src/app/api/providers/[id]/test/route.ts +++ b/src/app/api/providers/[id]/test/route.ts @@ -17,134 +17,16 @@ import { rotationGroupFor } from "@omniroute/open-sse/services/refreshSerializer import { saveCallLog } from "@/lib/usageDb"; import { logProxyEvent } from "@/lib/proxyLogger"; import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts"; -import { - buildGitLabOAuthEndpoints, - isGitLabDirectAccessDisabled, - resolveGitLabOAuthBaseUrl, -} from "@/lib/oauth/gitlab"; +import { isGitLabDirectAccessDisabled } from "@/lib/oauth/gitlab"; import { providerAllowsOptionalApiKey } from "@/shared/constants/providers"; import { removeConnectionHealth } from "@omniroute/open-sse/services/apiKeyRotator.ts"; import { classifyAmbiguousOrAuthError, type ClassifyFailureArgs } from "./mistralAmbiguousAuth"; +import { OAUTH_TEST_CONFIG } from "./oauthTestConfig"; // Bound the OAuth probe so a hung upstream can't block the connection-test queue // forever (#1449). Mirrors the 30s timeout the API-key path uses via validateProviderApiKey. const OAUTH_TEST_TIMEOUT_MS = 30_000; -// OAuth provider test endpoints -const OAUTH_TEST_CONFIG = { - claude: { - // Claude doesn't have userinfo, we verify token exists and not expired - checkExpiry: true, - refreshable: true, - }, - codex: { - // Port of decolua/9router#347: probe the real Codex /responses endpoint instead - // of relying on `checkExpiry`. Codex OAuth tokens are ChatGPT session tokens - // (not OpenAI API keys) — api.openai.com/v1/models rejects them with 403. - // Hitting the actual endpoint with a minimal invalid body returns 400 when - // auth is accepted (the body is the reason for the failure) and 401/403 when - // the token is bad. That is a real auth signal — checkExpiry alone could not - // distinguish a revoked-but-not-yet-expired token from a working one. - url: "https://chatgpt.com/backend-api/codex/responses", - method: "POST", - authHeader: "Authorization", - authPrefix: "Bearer ", - extraHeaders: { - "Content-Type": "application/json", - originator: "codex-cli", - "User-Agent": "codex-cli/1.0.18 (macOS; arm64)", - }, - // Minimal invalid body — triggers a fast 400 without consuming quota. - // #7521: probe with a ChatGPT-account-supported model. "gpt-5.3-codex" is a - // codex-only id that ChatGPT accounts reject with a 400 for the WRONG reason - // (unsupported model, not "auth ok, body invalid") — collapsing the auth signal - // so a bad token looks the same as a good one. "gpt-5.5" is served for - // ChatGPT sessions; `input: []` still yields the intended 400. - body: JSON.stringify({ model: "gpt-5.5", input: [], stream: false, store: false }), - // 400 = bad request, but auth was accepted; only 401/403 means the token is bad. - acceptStatuses: [400], - refreshable: true, - }, - antigravity: { - url: "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", - method: "GET", - authHeader: "Authorization", - authPrefix: "Bearer ", - refreshable: true, - }, - xai: { - url: "https://api.x.ai/v1/chat/completions", - method: "POST", - authHeader: "Authorization", - authPrefix: "Bearer ", - extraHeaders: { "Content-Type": "application/json" }, - body: JSON.stringify({ - model: "grok-4.3", - messages: [{ role: "user", content: "ping" }], - max_tokens: 1, - stream: false, - reasoning: { effort: "high" }, - }), - refreshable: true, - }, - github: { - url: "https://api.github.com/user", - method: "GET", - authHeader: "Authorization", - authPrefix: "Bearer ", - extraHeaders: { "User-Agent": "OmniRoute", Accept: "application/vnd.github+json" }, - }, - "gitlab-duo": { - getUrl: (connection: any) => - buildGitLabOAuthEndpoints(resolveGitLabOAuthBaseUrl(connection?.providerSpecificData)) - .directAccessUrl, - method: "POST", - authHeader: "Authorization", - authPrefix: "Bearer ", - refreshable: true, - }, - qwen: { - // DashScope (previously portal.qwen.ai) /v1/models might return 404 or auth issues. - // Use checkExpiry instead — actual connectivity is validated via real requests. - checkExpiry: true, - refreshable: true, - }, - cursor: { - checkExpiry: true, - }, - "kimi-coding": { - checkExpiry: true, - refreshable: true, - }, - kilocode: { - // Kilo OAuth does not expose a stable user-info endpoint in all environments. - // Validate using token presence/expiry as a lightweight auth check. - checkExpiry: true, - }, - cline: { - // Cline's /api/v1/models endpoint frequently returns stale auth errors even - // with fresh tokens. Use checkExpiry instead — actual connectivity is validated - // via real requests. - checkExpiry: true, - refreshable: true, - }, - kiro: { - checkExpiry: true, - refreshable: true, - }, - "amazon-q": { - checkExpiry: true, - refreshable: true, - }, - "codebuddy-cn": { - // Upstream test endpoint mirrors "tokenExists: true" from the CodeBuddy port — - // validate auth via token presence + refresh path. Live connectivity is - // verified through real /v2/chat/completions traffic. - checkExpiry: true, - refreshable: true, - }, -}; - import { CLI_RUNTIME_PROVIDER_MAP } from "./cliRuntimeProviderMap"; /** POST body is optional; when present, only known fields are validated. */ diff --git a/tests/unit/grok-cli-oauth-test-supported-7610.test.ts b/tests/unit/grok-cli-oauth-test-supported-7610.test.ts new file mode 100644 index 0000000000..b57d2a0a92 --- /dev/null +++ b/tests/unit/grok-cli-oauth-test-supported-7610.test.ts @@ -0,0 +1,26 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// #7610 bug #2: `grok-cli` was absent from OAUTH_TEST_CONFIG in +// src/app/api/providers/[id]/test/route.ts, so "Test Connection" for a Grok +// Build (OAuth) connection always fell through to the generic +// "Provider test not supported" branch, regardless of whether the token was +// actually healthy. +const { testOAuthConnection } = await import("../../src/app/api/providers/[id]/test/route.ts"); + +test("#7610: grok-cli OAuth connection test is no longer 'unsupported'", async () => { + const connection = { + provider: "grok-cli", + accessToken: "healthy-access-token", + refreshToken: "healthy-refresh-token", + // Far in the future — not expired, so this exercises the checkExpiry + // "still valid" branch rather than the refresh path. + tokenExpiresAt: new Date(Date.now() + 3600_000).toISOString(), + }; + + const result = await testOAuthConnection(connection); + + assert.notEqual(result.diagnosis?.type, "unsupported"); + assert.notEqual(result.error, "Provider test not supported"); + assert.equal(result.valid, true); +}); diff --git a/tests/unit/grok-cli-proactive-refresh-7610.test.ts b/tests/unit/grok-cli-proactive-refresh-7610.test.ts new file mode 100644 index 0000000000..fd6b4750fa --- /dev/null +++ b/tests/unit/grok-cli-proactive-refresh-7610.test.ts @@ -0,0 +1,64 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { GrokCliExecutor } from "../../open-sse/executors/grok-cli.ts"; +import type { ExecuteInput, ExecutorLog, ProviderCredentials } from "../../open-sse/executors/base.ts"; + +type TestableGrokCliExecutor = { + execute: (input: ExecuteInput) => Promise<{ response: Response }>; + refreshCredentials: ( + credentials: ProviderCredentials, + log?: ExecutorLog | null + ) => Promise | null>; + nativePost: ( + url: string, + headers: Record, + bodyStr: string, + signal?: AbortSignal | null + ) => Promise; +}; + +test("GrokCliExecutor.execute() proactively refreshes an expired access token (#7610)", async () => { + const executor = new GrokCliExecutor() as unknown as TestableGrokCliExecutor; + + // Stub the real network call (nativeHttpsPost → auth.x.ai) so the test never + // touches the network — only the wiring (does execute() call + // refreshCredentials() at all, and does the refreshed token reach the + // outgoing Authorization header) is under test here. + let refreshCalled = false; + executor.refreshCredentials = async () => { + refreshCalled = true; + return { + accessToken: "FRESH_ACCESS_TOKEN", + refreshToken: "rotated-refresh-token", + expiresAt: new Date(Date.now() + 3600_000).toISOString(), + }; + }; + + let capturedHeaders: Record | null = null; + executor.nativePost = async (_url, headers) => { + capturedHeaders = headers; + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + }; + + const expiredAt = new Date(Date.now() - 60_000).toISOString(); + const credentials: ProviderCredentials = { + accessToken: "STALE_ACCESS_TOKEN", + refreshToken: "valid-refresh-token", + expiresAt: expiredAt, + }; + + await executor.execute({ + model: "grok-composer-2.5-fast", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials, + } as ExecuteInput); + + assert.equal( + refreshCalled, + true, + "expected GrokCliExecutor.execute() to proactively call refreshCredentials()" + ); + assert.notEqual(capturedHeaders?.["Authorization"], "Bearer STALE_ACCESS_TOKEN"); + assert.equal(capturedHeaders?.["Authorization"], "Bearer FRESH_ACCESS_TOKEN"); +}); From fbbc695efa1e1652bb66c9ee3444b2d5fc2b32f4 Mon Sep 17 00:00:00 2001 From: danscMax <153344025+danscMax@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:20:28 +0300 Subject: [PATCH 08/22] fix(sse): start credential-health sweep at boot so stale web sessions recover (#7689) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The credential-health scheduler (src/lib/credentialHealth/scheduler.ts) auto-inits on import, but nothing imported it at startup — only the on-demand credentialGate (open-sse/services/credentialGate.ts) does, lazily on the first gated request. So the boot-time sweep never ran, and web-session connections whose cookies expired overnight stayed red/unavailable until a real request re-tripped the failure (the "*-web providers go red on restart" complaint). Wire initCredentialHealthCheck() into src/instrumentation-node.ts (the real Next.js instrumentation startup) right after the runtime-settings restore, in its own try/catch with a [STARTUP] log line. Idempotent and self-disabling via OMNIROUTE_DISABLE_CREDENTIAL_HEALTH_CHECK; cadence tunable via CREDENTIAL_HEALTH_CHECK_INTERVAL. The wiring MUST live in instrumentation-node.ts, NOT the unused src/server-init.ts — the latter never runs in production, which is why the earlier attempt (closed PR #7432) was a no-op. Test: tests/unit/credential-health-boot-wiring.test.ts asserts the boot wiring is present in instrumentation-node.ts and absent from the dead server-init.ts. Co-authored-by: Diego Rodrigues de Sa e Souza --- src/instrumentation-node.ts | 16 +++++++ .../credential-health-boot-wiring.test.ts | 45 +++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 tests/unit/credential-health-boot-wiring.test.ts diff --git a/src/instrumentation-node.ts b/src/instrumentation-node.ts index d971716f1e..0e3fdac35b 100755 --- a/src/instrumentation-node.ts +++ b/src/instrumentation-node.ts @@ -379,6 +379,22 @@ export async function registerNodejs(): Promise { console.warn("[STARTUP] Could not restore runtime settings:", msg); } + // Proactively start the credential-health sweep at boot so stale web-session + // connections (cookies that expired overnight) get re-probed and recovered on + // startup — instead of staying red until the first real request lazily imports + // the on-demand credentialGate. Idempotent; self-disables via + // OMNIROUTE_DISABLE_CREDENTIAL_HEALTH_CHECK and its cadence is tunable via + // CREDENTIAL_HEALTH_CHECK_INTERVAL. NOTE: this MUST live here (the real Next.js + // instrumentation startup), NOT in the unused src/server-init.ts. + try { + const { initCredentialHealthCheck } = await import("@/lib/credentialHealth/scheduler"); + initCredentialHealthCheck(); + console.log("[STARTUP] Credential health scheduler started"); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + console.warn("[STARTUP] Could not start credential health scheduler:", msg); + } + try { const { initAuditLog, cleanupExpiredLogs } = await import("@/lib/compliance/index"); initAuditLog(); diff --git a/tests/unit/credential-health-boot-wiring.test.ts b/tests/unit/credential-health-boot-wiring.test.ts new file mode 100644 index 0000000000..b2b884731a --- /dev/null +++ b/tests/unit/credential-health-boot-wiring.test.ts @@ -0,0 +1,45 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync, existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +// The credential-health sweep re-probes web-session connections and recovers ones +// whose cookies expired (the "*-web providers go red on restart" bug). scheduler.ts +// only auto-inits when something imports it; nothing did at boot, so the sweep never +// ran proactively at startup. This guards that the wiring lives in the REAL startup +// (src/instrumentation-node.ts) and NOT in the unused src/server-init.ts — the exact +// mistake that made the earlier attempt (closed PR #7432) a no-op. + +const read = (rel: string) => + readFileSync(fileURLToPath(new URL(rel, import.meta.url)), "utf8"); + +const instrumentation = read("../../src/instrumentation-node.ts"); + +test("credential health scheduler is started from the real Next.js instrumentation startup", () => { + assert.match( + instrumentation, + /import\(["']@\/lib\/credentialHealth\/scheduler["']\)/, + "instrumentation-node.ts must import the scheduler at boot" + ); + assert.match( + instrumentation, + /initCredentialHealthCheck\(\)/, + "instrumentation-node.ts must call initCredentialHealthCheck() at boot" + ); + assert.match( + instrumentation, + /\[STARTUP\] Credential health scheduler started/, + "a [STARTUP] log line proves the boot wiring ran (grep-able in app.log)" + ); +}); + +test("the wiring is NOT placed in the dead src/server-init.ts (the #7432 no-op)", () => { + const url = new URL("../../src/server-init.ts", import.meta.url); + const p = fileURLToPath(url); + if (!existsSync(p)) return; // file removed upstream → nothing to guard + assert.doesNotMatch( + readFileSync(p, "utf8"), + /initCredentialHealthCheck/, + "server-init.ts is unused in production; wiring there never runs" + ); +}); From 390e88ca1e90111244ad71f2e18eedf9a02a7a95 Mon Sep 17 00:00:00 2001 From: Makcim Ivanov Date: Sun, 19 Jul 2026 10:20:39 +0300 Subject: [PATCH 09/22] fix(cursor): discover models via official CLI command (#7692) Co-authored-by: Makcim Ivanov Co-authored-by: Diego Rodrigues de Sa e Souza --- src/lib/providerModels/cursorAgent.ts | 59 +++++++++++++++++++++----- tests/unit/cursor-agent-models.test.ts | 20 ++++++++- 2 files changed, 68 insertions(+), 11 deletions(-) diff --git a/src/lib/providerModels/cursorAgent.ts b/src/lib/providerModels/cursorAgent.ts index b30fb690f8..49749dcb22 100644 --- a/src/lib/providerModels/cursorAgent.ts +++ b/src/lib/providerModels/cursorAgent.ts @@ -107,11 +107,36 @@ export function humanizeCursorModelId(id: string): string { } export function parseCursorAgentModels(text: string): string[] { - const match = text.match(/Available models:\s*([^\n]+)/); - if (!match) return []; + // Older Cursor Agent releases only exposed the catalog as part of the + // invalid-model error produced by `--model --help`. + const legacyMatch = text.match(/Available models:\s*([^\n]+)/); + if (legacyMatch) { + return deduplicateCursorModelIds(legacyMatch[1].split(",")); + } + + // Current releases expose an official `models` command whose output is: + // + // Available models + // + // auto - Auto (default) + // gpt-5.3-codex - Codex 5.3 + const headerMatch = /(?:^|\n)Available models\s*(?:\n|$)/.exec(text); + if (!headerMatch) return []; + const lines = text.slice(headerMatch.index + headerMatch[0].length).split("\n"); + const ids: string[] = []; + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.startsWith("Tip:")) break; + const separator = trimmed.indexOf(" - "); + if (separator > 0) ids.push(trimmed.slice(0, separator)); + } + return deduplicateCursorModelIds(ids); +} + +function deduplicateCursorModelIds(ids: string[]): string[] { const seen = new Set(); const out: string[] = []; - for (const raw of match[1].split(",")) { + for (const raw of ids) { const id = raw.trim(); if (!id || seen.has(id)) continue; seen.add(id); @@ -138,12 +163,13 @@ export async function fetchCursorAgentModels( ); } - // cursor-agent prints "Available models: ..." to stderr and exits non-zero - // when given an unknown model id, so we intentionally pass `--help` as the - // model value to coerce it into listing. + const startedAt = Date.now(); let result: { stdout: string; stderr: string }; try { - result = await runCursorAgent(binary, ["--model", "--help"], timeoutMs); + // Modern Cursor Agent releases provide a dedicated catalog flag. Prefer the + // flag over the equivalent `models` subcommand because older releases can + // interpret an unknown positional subcommand as an agent prompt. + result = await runCursorAgent(binary, ["--list-models"], timeoutMs); } catch (err: unknown) { const e = err as NodeJS.ErrnoException; if (e?.code === "ENOENT") { @@ -151,11 +177,24 @@ export async function fetchCursorAgentModels( } throw err; } - const combined = `${result.stdout}\n${result.stderr}`; + let combined = `${result.stdout}\n${result.stderr}`; + let ids = parseCursorAgentModels(combined); + + // Backward compatibility for releases from before the dedicated catalog interface. + if (ids.length === 0 && !/Authentication required|Not logged in/i.test(combined)) { + const remainingTimeoutMs = timeoutMs - (Date.now() - startedAt); + if (remainingTimeoutMs > 0) { + result = await runCursorAgent(binary, ["--model", "--help"], remainingTimeoutMs); + combined = `${result.stdout}\n${result.stderr}`; + ids = parseCursorAgentModels(combined); + } + } - const ids = parseCursorAgentModels(combined); if (ids.length === 0) { - throw new Error("cursor-agent did not return an 'Available models:' line"); + if (/Authentication required|Not logged in/i.test(combined)) { + throw new Error("cursor-agent is not authenticated; run 'agent login' on the OmniRoute host"); + } + throw new Error("cursor-agent did not return a model catalog from 'agent --list-models'"); } return ids.map((id) => ({ diff --git a/tests/unit/cursor-agent-models.test.ts b/tests/unit/cursor-agent-models.test.ts index a59076f3ff..a75e658974 100644 --- a/tests/unit/cursor-agent-models.test.ts +++ b/tests/unit/cursor-agent-models.test.ts @@ -22,6 +22,21 @@ test("parseCursorAgentModels deduplicates and trims", () => { assert.deepEqual(parseCursorAgentModels("Available models: a, a , b"), ["a", "b"]); }); +test("parseCursorAgentModels parses the multiline output from the models command", () => { + const text = `Available models + +auto - Auto (default) +gpt-5.3-codex - Codex 5.3 +claude-opus-4-8-thinking-high-fast - Opus 4.8 1M Thinking Fast + +Tip: use --model to switch.`; + assert.deepEqual(parseCursorAgentModels(text), [ + "auto", + "gpt-5.3-codex", + "claude-opus-4-8-thinking-high-fast", + ]); +}); + test("parseCursorAgentModels returns [] when the marker is missing", () => { assert.deepEqual(parseCursorAgentModels("nothing here"), []); }); @@ -40,7 +55,10 @@ test("humanizeCursorModelId pretty-prints common patterns", () => { humanizeCursorModelId("claude-opus-4-8-thinking-high-fast"), "Claude Opus 4.8 Thinking High Fast" ); - assert.equal(humanizeCursorModelId("claude-fable-5-thinking-xhigh"), "Claude Fable 5 Thinking XHigh"); + assert.equal( + humanizeCursorModelId("claude-fable-5-thinking-xhigh"), + "Claude Fable 5 Thinking XHigh" + ); assert.equal(humanizeCursorModelId("claude-sonnet-5-max"), "Claude Sonnet 5 Max"); assert.equal(humanizeCursorModelId("kimi-k2.5"), "Kimi K2.5"); assert.equal(humanizeCursorModelId("gemini-3.1-pro"), "Gemini 3.1 Pro"); From dffff5d656c169e41c4862cb38affbd9992f24a5 Mon Sep 17 00:00:00 2001 From: NOXX - Commiter Date: Sun, 19 Jul 2026 10:26:26 +0300 Subject: [PATCH 10/22] feat(providers): notion-web live model discovery via getAvailableModels (#7696) * feat(providers): notion-web live models via getAvailableModels Cookie-auth discovery against POST /api/v3/getAvailableModels (spaceId from cookie or getSpaces) so /api/providers/{id}/models and /v1/models can surface the real Notion AI picker catalog instead of a single stub notion-ai id. Also injects a config transcript entry with the selected model codename on runInferenceTranscript, seeds an offline fallback catalog, and documents that space_id is needed for reliable discovery. * fix(providers): address notion-web review + docs provider count - Safe decodeURIComponent for malformed cookie values - Use extractSpaceIdFromNotionCookie instead of case-sensitive space_id= includes - Single trim in buildNotionTranscript - Sync STRICT docs counts to 265 providers (README/AGENTS/CLAUDE) * refactor(notion-web): extract helpers to keep parseNotionAvailableModels/pickFirstSpaceId under complexity cap Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: Diego Rodrigues de Sa e Souza Co-authored-by: artickc Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- .../features/notion-web-available-models.md | 1 + .../providers/registry/notion-web/index.ts | 13 +- open-sse/executors/notion-web.ts | 39 ++- open-sse/services/notionWebModels.ts | 319 ++++++++++++++++++ src/app/api/providers/[id]/models/route.ts | 62 ++++ src/shared/constants/providers/web-cookie.ts | 3 +- tests/unit/executor-notion-web.test.ts | 31 ++ .../unit/notion-web-models-discovery.test.ts | 182 ++++++++++ 8 files changed, 633 insertions(+), 17 deletions(-) create mode 100644 changelog.d/features/notion-web-available-models.md create mode 100644 open-sse/services/notionWebModels.ts create mode 100644 tests/unit/notion-web-models-discovery.test.ts diff --git a/changelog.d/features/notion-web-available-models.md b/changelog.d/features/notion-web-available-models.md new file mode 100644 index 0000000000..ed73e21a55 --- /dev/null +++ b/changelog.d/features/notion-web-available-models.md @@ -0,0 +1 @@ +- feat(providers): notion-web live model discovery via getAvailableModels (spaceId + token_v2 cookie) diff --git a/open-sse/config/providers/registry/notion-web/index.ts b/open-sse/config/providers/registry/notion-web/index.ts index cca2160a4c..c043137cce 100644 --- a/open-sse/config/providers/registry/notion-web/index.ts +++ b/open-sse/config/providers/registry/notion-web/index.ts @@ -1,12 +1,9 @@ import type { RegistryEntry } from "../../shared.ts"; +import { NOTION_WEB_FALLBACK_MODELS } from "../../../../services/notionWebModels.ts"; -// Notion AI Web (Unofficial/Experimental) — see open-sse/executors/notion-web.ts -// for the reverse-engineering rationale (issue #6758, closed native-provider -// request #3272). Notion AI does not expose a documented, selectable model -// catalog through its internal endpoint — the assistant response is server-side -// routed. `passthroughModels: true` lets an operator pass any model id the -// endpoint may honor in the future without a registry change; the single -// `notion-ai` entry is the default/safe fallback shown in the picker. +// Notion AI Web (Unofficial/Experimental) — see open-sse/executors/notion-web.ts. +// Live catalog comes from cookie-auth POST /api/v3/getAvailableModels (models route). +// The registry seed below is the offline fallback when discovery fails. export const notion_webProvider: RegistryEntry = { id: "notion-web", alias: "nw", @@ -16,5 +13,5 @@ export const notion_webProvider: RegistryEntry = { authType: "apikey", authHeader: "cookie", passthroughModels: true, - models: [{ id: "notion-ai", name: "Notion AI (Unofficial/Experimental)" }], + models: NOTION_WEB_FALLBACK_MODELS.map((m) => ({ id: m.id, name: m.name })), }; diff --git a/open-sse/executors/notion-web.ts b/open-sse/executors/notion-web.ts index b018b40302..c54f719504 100644 --- a/open-sse/executors/notion-web.ts +++ b/open-sse/executors/notion-web.ts @@ -127,19 +127,39 @@ export function extractSpaceIdFromCookie(cookie: string): string { /** * Build a Notion `runInferenceTranscript` transcript array from OpenAI-style - * chat messages. Each entry uses Notion's rich-text tuple value shape - * (`[[text]]`) and a role tag Notion's own web client sends. + * chat messages. When `notionModel` is set (and not the synthetic `notion-ai` + * default), a leading `config` entry carries `value.model` so Notion routes the + * request to the selected codename from getAvailableModels. */ export function buildNotionTranscript( - messages: NotionMessage[] + messages: NotionMessage[], + notionModel?: string ): Array> { - return messages - .filter((m) => typeof m?.content === "string" && m.content.length > 0) - .map((m) => ({ + const entries: Array> = []; + const trimmedModel = typeof notionModel === "string" ? notionModel.trim() : ""; + const model = trimmedModel && trimmedModel !== "notion-ai" ? trimmedModel : ""; + if (model) { + entries.push({ + id: randomUUID(), + type: "config", + value: { + type: "workflow", + model, + modelFromUser: true, + useWebSearch: false, + searchScopes: [{ type: "everything" }], + }, + }); + } + for (const m of messages) { + if (typeof m?.content !== "string" || m.content.length === 0) continue; + entries.push({ id: randomUUID(), type: m.role === "assistant" ? "ai" : m.role === "system" ? "context" : "human", value: [[m.content]], - })); + }); + } + return entries; } /** Extract plain text from Notion's rich-text tuple value: `[[text, marks?]]`. */ @@ -250,8 +270,11 @@ export class NotionWebExecutor extends BaseExecutor { const modelId = model || "notion-ai"; const reqBody: Record = { traceId: randomUUID(), - transcript: buildNotionTranscript(messages), + transcript: buildNotionTranscript(messages, modelId), createThread: false, + asPatchResponse: true, + threadType: "workflow", + createdSource: "ai_module", }; if (spaceId) reqBody.spaceId = spaceId; diff --git a/open-sse/services/notionWebModels.ts b/open-sse/services/notionWebModels.ts new file mode 100644 index 0000000000..2e135a80c1 --- /dev/null +++ b/open-sse/services/notionWebModels.ts @@ -0,0 +1,319 @@ +/** + * Notion AI Web model discovery helpers. + * + * Notion has no public model catalog API. The browser AI surface loads models via + * cookie-auth `POST /api/v3/getAvailableModels` with body `{ spaceId }` (see + * browser capture against app.notion.com). These helpers parse that response and + * build the cookie/headers/body the models-discovery route needs. + */ + +const NOTION_APP_ORIGIN = "https://www.notion.so"; +const NOTION_MODELS_URL = `${NOTION_APP_ORIGIN}/api/v3/getAvailableModels`; +const NOTION_SPACES_URL = `${NOTION_APP_ORIGIN}/api/v3/getSpaces`; +const NOTION_USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; +/** Recent Notion web client version — accepted loosely but required by some paths. */ +const NOTION_CLIENT_VERSION = "23.13.20260718.1805"; + +export type NotionDiscoveredModel = { + id: string; + name: string; + owned_by: string; + supportsReasoning?: boolean; + disabled?: boolean; +}; + +/** Offline fallback when getAvailableModels is unreachable (seeded from live picker). */ +export const NOTION_WEB_FALLBACK_MODELS: NotionDiscoveredModel[] = [ + { id: "notion-ai", name: "Notion AI (default)", owned_by: "notion" }, + { id: "orange-mousse", name: "GPT-5.6 Sol", owned_by: "openai" }, + { id: "orchid-muffin", name: "GPT-5.6 Terra", owned_by: "openai" }, + { id: "olive-jellyroll", name: "GPT-5.6 Luna", owned_by: "openai" }, + { id: "oatmeal-cookie", name: "GPT-5.2", owned_by: "openai" }, + { id: "oval-kumquat-medium", name: "GPT-5.4", owned_by: "openai" }, + { id: "opal-quince-medium", name: "GPT-5.5", owned_by: "openai" }, + { id: "oregon-grape-medium", name: "GPT-5.4 Mini", owned_by: "openai" }, + { id: "otaheite-apple-medium", name: "GPT-5.4 Nano", owned_by: "openai" }, + { id: "vertex-gemini-3.5-flash", name: "Gemini 3.5 Flash", owned_by: "gemini" }, + { id: "gingerbread", name: "Gemini 3 Flash", owned_by: "gemini" }, + { id: "galette-medium-thinking", name: "Gemini 3.1 Pro", owned_by: "gemini" }, + { id: "almond-croissant-low", name: "Sonnet 4.6", owned_by: "anthropic" }, + { id: "angel-cake-high", name: "Sonnet 5", owned_by: "anthropic" }, + { id: "avocado-froyo-medium", name: "Opus 4.6", owned_by: "anthropic" }, + { id: "apricot-sorbet-high", name: "Opus 4.7", owned_by: "anthropic" }, + { id: "ambrosia-tart-high", name: "Opus 4.8", owned_by: "anthropic" }, + { id: "anthropic-haiku-4.5", name: "Haiku 4.5", owned_by: "anthropic" }, + { id: "acai-budino-high", name: "Fable 5", owned_by: "anthropic" }, + { id: "fireworks-kimi-k2.6", name: "Kimi K2.6", owned_by: "mystery" }, + { id: "fireworks-kimi-k2.7", name: "Kimi K2.7 Code", owned_by: "mystery" }, + { id: "baseten-deepseek-v4-pro", name: "DeepSeek V4 Pro", owned_by: "mystery" }, + { id: "baseten-glm-5.2", name: "GLM 5.2", owned_by: "mystery" }, + { id: "xigua-mochi-medium", name: "Grok 4.3", owned_by: "xai" }, + { id: "strawberry-whoopiepie", name: "Grok 4.5", owned_by: "xai" }, + { id: "xinomavro-cake", name: "Grok Build 0.1", owned_by: "xai" }, +]; + +/** Normalize a pasted credential to a Cookie header string. */ +export function normalizeNotionWebCookie(raw: string): string { + const trimmed = String(raw || "").trim(); + if (!trimmed) return ""; + return trimmed.includes("=") ? trimmed : `token_v2=${trimmed}`; +} + +/** Read `name=value` from a cookie header (case-insensitive name). */ +export function readCookieValue(cookie: string, name: string): string { + if (!cookie || !name) return ""; + const re = new RegExp(`(?:^|;\\s*)${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}=([^;]*)`, "i"); + const m = cookie.match(re); + if (!m) return ""; + const raw = m[1].trim(); + // Malformed % sequences in cookie values must not throw (Gemini review). + try { + return decodeURIComponent(raw); + } catch { + return raw; + } +} + +export function extractSpaceIdFromNotionCookie(cookie: string): string { + return ( + readCookieValue(cookie, "space_id") || + readCookieValue(cookie, "spaceId") || + "" + ); +} + +export function extractNotionUserIdFromCookie(cookie: string): string { + return ( + readCookieValue(cookie, "notion_user_id") || + readCookieValue(cookie, "notion_user_id_v2") || + readCookieValue(cookie, "user_id") || + "" + ); +} + +/** Trim to a non-empty string, or fall back to `fallback`. */ +function trimmedOrFallback(value: unknown, fallback: string): string { + return typeof value === "string" && value.trim() ? value.trim() : fallback; +} + +/** True when the row's `modelConfiguration.supportedReasoningEfforts` is a non-empty array. */ +function rowSupportsReasoning(row: Record): boolean { + const efforts = (row.modelConfiguration as { supportedReasoningEfforts?: unknown } | undefined) + ?.supportedReasoningEfforts; + return Array.isArray(efforts) && efforts.length > 0; +} + +/** + * Parse one getAvailableModels list entry into a model, or `null` when the entry + * should be skipped (disabled, malformed, or a duplicate id already in `seen`). + */ +function parseNotionModelEntry( + entry: unknown, + seen: Set +): NotionDiscoveredModel | null { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null; + const row = entry as Record; + if (row.isDisabled === true) return null; + + const id = typeof row.model === "string" ? row.model.trim() : ""; + if (!id || seen.has(id)) return null; + + seen.add(id); + return { + id, + name: trimmedOrFallback(row.modelMessage, id), + owned_by: trimmedOrFallback(row.modelFamily, "notion"), + ...(rowSupportsReasoning(row) ? { supportsReasoning: true } : {}), + }; +} + +/** Ensure a stable default id always exists for clients that still request notion-ai. */ +function withDefaultNotionModel( + out: NotionDiscoveredModel[], + seen: Set +): NotionDiscoveredModel[] { + if (out.length === 0 || seen.has("notion-ai")) return out; + return [{ id: "notion-ai", name: "Notion AI (default)", owned_by: "notion" }, ...out]; +} + +/** + * Parse getAvailableModels JSON into OpenAI-style model entries. + * Skips disabled models; prefers display `modelMessage` as name and internal + * `model` codename as id (what runInferenceTranscript expects). + */ +export function parseNotionAvailableModels(data: unknown): NotionDiscoveredModel[] { + if (!data || typeof data !== "object" || Array.isArray(data)) return []; + const list = (data as { models?: unknown }).models; + if (!Array.isArray(list)) return []; + + const seen = new Set(); + const out: NotionDiscoveredModel[] = []; + for (const entry of list) { + const model = parseNotionModelEntry(entry, seen); + if (model) out.push(model); + } + + return withDefaultNotionModel(out, seen); +} + +export function buildNotionModelsDiscoveryHeaders(token: string): Record { + const cookie = normalizeNotionWebCookie(token); + const spaceId = extractSpaceIdFromNotionCookie(cookie); + const userId = extractNotionUserIdFromCookie(cookie); + const headers: Record = { + accept: "*/*", + "content-type": "application/json", + "user-agent": NOTION_USER_AGENT, + origin: NOTION_APP_ORIGIN, + referer: `${NOTION_APP_ORIGIN}/ai`, + "notion-client-version": NOTION_CLIENT_VERSION, + "notion-audit-log-platform": "web", + ...(cookie ? { cookie } : {}), + }; + if (spaceId) headers["x-notion-space-id"] = spaceId; + if (userId) headers["x-notion-active-user-header"] = userId; + return headers; +} + +export function buildNotionModelsDiscoveryBody(token: string): { spaceId?: string } { + const cookie = normalizeNotionWebCookie(token); + const spaceId = extractSpaceIdFromNotionCookie(cookie); + return spaceId ? { spaceId } : {}; +} + +export function getNotionModelsDiscoveryUrl(): string { + return NOTION_MODELS_URL; +} + +/** + * Try to resolve a workspace spaceId from getSpaces when the cookie has none. + * Returns "" on any failure (caller falls back to local catalog). + */ +export async function resolveNotionSpaceIdFromGetSpaces( + cookie: string, + fetchImpl: typeof fetch = fetch +): Promise { + const normalized = normalizeNotionWebCookie(cookie); + if (!normalized) return ""; + try { + const res = await fetchImpl(NOTION_SPACES_URL, { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json", + cookie: normalized, + origin: NOTION_APP_ORIGIN, + referer: `${NOTION_APP_ORIGIN}/`, + "user-agent": NOTION_USER_AGENT, + }, + body: "{}", + }); + if (!res.ok) return ""; + const data = (await res.json()) as unknown; + return pickFirstSpaceId(data); + } catch { + return ""; + } +} + +/** Common shape: { [userId]: { space_view: { ... }, space: { [spaceId]: ... } } } */ +function pickSpaceIdFromUserMap(root: Record): string { + for (const value of Object.values(root)) { + if (!value || typeof value !== "object" || Array.isArray(value)) continue; + const spaceMap = (value as Record).space; + if (spaceMap && typeof spaceMap === "object" && !Array.isArray(spaceMap)) { + const ids = Object.keys(spaceMap as Record); + if (ids.length > 0) return ids[0]; + } + } + return ""; +} + +/** Flat shape: { spaces: [{ id }] } */ +function pickSpaceIdFromSpacesArray(spaces: unknown): string { + if (!Array.isArray(spaces)) return ""; + for (const s of spaces) { + if (s && typeof s === "object" && typeof (s as { id?: string }).id === "string") { + return (s as { id: string }).id; + } + } + return ""; +} + +/** Flat shape: { spaceIds: [] } */ +function pickSpaceIdFromSpaceIdsArray(spaceIds: unknown): string { + return Array.isArray(spaceIds) && typeof spaceIds[0] === "string" ? spaceIds[0] : ""; +} + +/** Best-effort spaceId extraction from getSpaces response shapes. */ +export function pickFirstSpaceId(data: unknown): string { + if (!data || typeof data !== "object") return ""; + const root = data as Record; + + return ( + pickSpaceIdFromUserMap(root) || + pickSpaceIdFromSpacesArray(root.spaces) || + pickSpaceIdFromSpaceIdsArray(root.spaceIds) + ); +} + +/** + * End-to-end discovery used by the models route special-case (and unit tests). + * Resolves spaceId from cookie or getSpaces, then calls getAvailableModels. + */ +export async function discoverNotionWebModels(opts: { + token: string; + fetchImpl?: typeof fetch; + signal?: AbortSignal | null; +}): Promise<{ models: NotionDiscoveredModel[]; spaceId: string; source: "api" }> { + const fetchImpl = opts.fetchImpl ?? fetch; + const cookie = normalizeNotionWebCookie(opts.token); + if (!cookie) { + throw new Error("Missing Notion token_v2 cookie"); + } + + let spaceId = extractSpaceIdFromNotionCookie(cookie); + if (!spaceId) { + spaceId = await resolveNotionSpaceIdFromGetSpaces(cookie, fetchImpl); + } + if (!spaceId) { + throw new Error( + "Missing Notion spaceId — include space_id=… in the cookie header or re-login so getSpaces can resolve a workspace" + ); + } + + // Prefer the canonical space id extractor (case-insensitive) so we do not + // append a second space_id= when the cookie used spaceId= or mixed case. + const cookieForHeaders = extractSpaceIdFromNotionCookie(cookie) + ? cookie + : `${cookie}; space_id=${spaceId}`; + const headers = buildNotionModelsDiscoveryHeaders(cookieForHeaders); + headers["x-notion-space-id"] = spaceId; + + const res = await fetchImpl(NOTION_MODELS_URL, { + method: "POST", + headers, + body: JSON.stringify({ spaceId }), + signal: opts.signal ?? undefined, + }); + + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`getAvailableModels failed (${res.status}): ${text.slice(0, 200)}`); + } + + const data = await res.json(); + const models = parseNotionAvailableModels(data); + if (models.length === 0) { + throw new Error("getAvailableModels returned no enabled models"); + } + return { models, spaceId, source: "api" }; +} + +export { + NOTION_MODELS_URL, + NOTION_SPACES_URL, + NOTION_APP_ORIGIN, + NOTION_CLIENT_VERSION, +}; diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index bab48bb7db..8bff5e2b49 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -41,6 +41,10 @@ import { discoverBedrockNativeModels, isBedrockNativeApiError, } from "@omniroute/open-sse/services/bedrock.ts"; +import { + discoverNotionWebModels, + NOTION_WEB_FALLBACK_MODELS, +} from "@omniroute/open-sse/services/notionWebModels.ts"; import { AZURE_AI_DEFAULT_BASE_URL, buildAzureAiModelsUrl, @@ -527,6 +531,64 @@ export async function GET( if (localCatalog) return localCatalog; } + // #7600 follow-up: notion-web live catalog via cookie-auth getAvailableModels. + // Needs spaceId (from cookie or getSpaces); falls back to seeded local catalog. + if (provider === "notion-web") { + const cachedResponse = maybeReturnCachedDiscovery(); + if (cachedResponse) return cachedResponse; + + const autoFetchDisabledResponse = maybeReturnAutoFetchDisabled(); + if (autoFetchDisabledResponse) return autoFetchDisabledResponse; + + const token = apiKey || accessToken; + if (!token) { + const fallback = buildDiscoveryFallbackResponse({ + cacheWarning: "No token configured — using cached catalog", + localWarning: "No token configured — using local catalog", + }); + if (fallback) return fallback; + return buildResponse({ + provider, + connectionId, + models: NOTION_WEB_FALLBACK_MODELS, + source: "local_catalog", + intentional: true, + warning: "No token_v2 cookie — using seed Notion AI model list", + }); + } + + try { + const discovery = await discoverNotionWebModels({ + token, + fetchImpl: (url, init) => + safeOutboundFetch(url, { + ...SAFE_OUTBOUND_FETCH_PRESETS.modelsDiscovery, + guard: getProviderOutboundGuard(), + proxyConfig: proxy, + ...init, + }), + }); + return buildApiDiscoveryResponse(discovery.models); + } catch (error) { + console.log("Error fetching models from notion-web", { + error: error instanceof Error ? error.message : String(error), + }); + const fallback = buildDiscoveryFallbackResponse({ + cacheWarning: "Notion getAvailableModels failed — using cached catalog", + localWarning: "Notion getAvailableModels failed — using seed catalog", + }); + if (fallback) return fallback; + return buildResponse({ + provider, + connectionId, + models: NOTION_WEB_FALLBACK_MODELS, + source: "local_catalog", + intentional: true, + warning: "API unavailable — using seed Notion AI model list", + }); + } + } + if (provider === "bedrock") { const cachedResponse = maybeReturnCachedDiscovery(); if (cachedResponse) return cachedResponse; diff --git a/src/shared/constants/providers/web-cookie.ts b/src/shared/constants/providers/web-cookie.ts index 1c3c5ce1db..ba46837004 100644 --- a/src/shared/constants/providers/web-cookie.ts +++ b/src/shared/constants/providers/web-cookie.ts @@ -372,7 +372,8 @@ export const WEB_COOKIE_PROVIDERS = { riskNoticeVariant: "webCookie", authHint: "Paste your token_v2 cookie value from notion.so (DevTools → Application → Cookies). " + - "Optionally append `; space_id=...` and/or `; notion_browser_id=...` if your workspace requires them.", + "Include `; space_id=` so live model discovery (getAvailableModels) can list GPT/Claude/Gemini/etc. " + + "Optionally also `; notion_browser_id=...` / `; notion_user_id=...`.", }, }; diff --git a/tests/unit/executor-notion-web.test.ts b/tests/unit/executor-notion-web.test.ts index ab873f3167..bb71acc4c4 100644 --- a/tests/unit/executor-notion-web.test.ts +++ b/tests/unit/executor-notion-web.test.ts @@ -25,6 +25,8 @@ describe("NotionWebExecutor — registry consistency", () => { const models = getModelsByProviderId("notion-web"); assert.ok(models.length >= 1); assert.ok(models.some((m) => m.id === "notion-ai")); + // Seed catalog includes real Notion codenames (live discovery still preferred). + assert.ok(models.some((m) => m.id === "ambrosia-tart-high" || m.id === "orange-mousse")); }); }); @@ -93,6 +95,7 @@ describe("NotionWebExecutor — upstream translation (mocked fetch)", () => { assert.equal(capturedUrl, "https://www.notion.so/api/v3/runInferenceTranscript"); assert.equal(capturedHeaders.Cookie, "token_v2=abc123"); assert.ok(capturedBody); + // notion-ai default does not inject a config entry (server-side default model). assert.equal(capturedBody.transcript[0].type, "human"); assert.deepEqual(capturedBody.transcript[0].value, [["hi"]]); @@ -110,6 +113,34 @@ describe("NotionWebExecutor — upstream translation (mocked fetch)", () => { } }); + it("injects a config transcript entry with the selected Notion model codename", async () => { + const executor = new mod.NotionWebExecutor(); + let capturedBody: { transcript: Array<{ type: string; value?: { model?: string } }> } | null = + null; + const originalFetch = globalThis.fetch; + try { + globalThis.fetch = (async (_url: string | URL, opts: RequestInit) => { + capturedBody = JSON.parse(String(opts.body)); + return new Response(JSON.stringify({ value: [["ok"]] }), { status: 200 }); + }) as typeof fetch; + + await executor.execute({ + model: "orange-mousse", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: "token_v2=xyz; space_id=space-1" }, + signal: null, + } as never); + + assert.ok(capturedBody); + assert.equal(capturedBody.transcript[0].type, "config"); + assert.equal(capturedBody.transcript[0].value?.model, "orange-mousse"); + assert.equal(capturedBody.transcript[1].type, "human"); + } finally { + globalThis.fetch = originalFetch; + } + }); + it("accepts a full cookie header verbatim (already containing token_v2=)", async () => { const executor = new mod.NotionWebExecutor(); let capturedHeaders: Record = {}; diff --git a/tests/unit/notion-web-models-discovery.test.ts b/tests/unit/notion-web-models-discovery.test.ts new file mode 100644 index 0000000000..7df79fe568 --- /dev/null +++ b/tests/unit/notion-web-models-discovery.test.ts @@ -0,0 +1,182 @@ +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-notion-web-models-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const notionModels = await import("../../open-sse/services/notionWebModels.ts"); +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const modelsRoute = await import("../../src/app/api/providers/[id]/models/route.ts"); + +async function resetStorage() { + 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 }); +}); + +const SAMPLE_RESPONSE = { + models: [ + { + model: "orange-mousse", + modelMessage: "GPT-5.6 Sol", + modelFamily: "openai", + isDisabled: false, + modelConfiguration: { supportedReasoningEfforts: ["medium", "high"] }, + }, + { + model: "ambrosia-tart-high", + modelMessage: "Opus 4.8", + modelFamily: "anthropic", + isDisabled: false, + }, + { + model: "disabled-model", + modelMessage: "Hidden", + modelFamily: "openai", + isDisabled: true, + }, + ], +}; + +test("parseNotionAvailableModels maps enabled models and skips disabled", () => { + const models = notionModels.parseNotionAvailableModels(SAMPLE_RESPONSE); + assert.equal( + models.some((m) => m.id === "disabled-model"), + false + ); + assert.ok(models.some((m) => m.id === "orange-mousse" && m.name === "GPT-5.6 Sol")); + assert.ok(models.some((m) => m.id === "ambrosia-tart-high" && m.name === "Opus 4.8")); + assert.ok(models.some((m) => m.id === "notion-ai")); + const sol = models.find((m) => m.id === "orange-mousse"); + assert.equal(sol?.supportsReasoning, true); + assert.equal(sol?.owned_by, "openai"); +}); + +test("parseNotionAvailableModels returns empty for invalid payloads", () => { + assert.deepEqual(notionModels.parseNotionAvailableModels(null), []); + assert.deepEqual(notionModels.parseNotionAvailableModels({}), []); + assert.deepEqual(notionModels.parseNotionAvailableModels({ models: "nope" }), []); +}); + +test("cookie helpers extract space_id and user id", () => { + const cookie = + "token_v2=abc; space_id=5e43fbd2-c09b-815a-8045-000311a1f620; notion_user_id=28bd872b-594c-81cb-9638-0002a411fd83"; + assert.equal( + notionModels.extractSpaceIdFromNotionCookie(cookie), + "5e43fbd2-c09b-815a-8045-000311a1f620" + ); + assert.equal( + notionModels.extractNotionUserIdFromCookie(cookie), + "28bd872b-594c-81cb-9638-0002a411fd83" + ); + assert.equal(notionModels.normalizeNotionWebCookie("baretoken"), "token_v2=baretoken"); + // CamelCase spaceId= must still resolve (case-insensitive name match). + assert.equal( + notionModels.extractSpaceIdFromNotionCookie("token_v2=x; spaceId=space-camel"), + "space-camel" + ); + // Malformed % sequences must not throw. + assert.equal(notionModels.readCookieValue("token_v2=%E0%A4%A", "token_v2"), "%E0%A4%A"); +}); + +test("pickFirstSpaceId reads nested getSpaces shape", () => { + const data = { + "user-1": { + space: { + "space-aaa": { name: "Work" }, + "space-bbb": { name: "Personal" }, + }, + }, + }; + assert.equal(notionModels.pickFirstSpaceId(data), "space-aaa"); +}); + +test("discoverNotionWebModels posts getAvailableModels with spaceId from cookie", async () => { + const calls: Array<{ url: string; body: string }> = []; + const fetchImpl = (async (url: string | URL, init?: RequestInit) => { + calls.push({ url: String(url), body: String(init?.body || "") }); + return Response.json(SAMPLE_RESPONSE); + }) as typeof fetch; + + const result = await notionModels.discoverNotionWebModels({ + token: "token_v2=xyz; space_id=space-from-cookie", + fetchImpl, + }); + + assert.equal(calls.length, 1); + assert.equal(calls[0].url, notionModels.NOTION_MODELS_URL); + assert.equal(JSON.parse(calls[0].body).spaceId, "space-from-cookie"); + assert.ok(result.models.some((m) => m.id === "orange-mousse")); + assert.equal(result.source, "api"); +}); + +test("discoverNotionWebModels falls back to getSpaces when space_id missing", async () => { + const calls: string[] = []; + const fetchImpl = (async (url: string | URL) => { + calls.push(String(url)); + if (String(url).includes("getSpaces")) { + return Response.json({ + u1: { space: { "resolved-space": { name: "WS" } } }, + }); + } + return Response.json(SAMPLE_RESPONSE); + }) as typeof fetch; + + const result = await notionModels.discoverNotionWebModels({ + token: "token_v2=xyz", + fetchImpl, + }); + + assert.deepEqual(calls, [notionModels.NOTION_SPACES_URL, notionModels.NOTION_MODELS_URL]); + assert.equal(result.spaceId, "resolved-space"); + assert.ok(result.models.length >= 2); +}); + +test("notion-web models route returns live getAvailableModels catalog", async () => { + await resetStorage(); + const connection = await providersDb.createProviderConnection({ + provider: "notion-web", + authType: "apikey", + name: "notion-web-discovery", + apiKey: "token_v2=sess; space_id=space-live-1", + }); + + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => { + const u = String(url); + if (u.includes("getAvailableModels")) { + assert.equal(init?.method, "POST"); + const body = JSON.parse(String(init?.body || "{}")); + assert.equal(body.spaceId, "space-live-1"); + const headers = init?.headers as Record; + assert.match(String(headers.cookie || headers.Cookie || ""), /token_v2=sess/); + return Response.json(SAMPLE_RESPONSE); + } + return new Response("unexpected", { status: 500 }); + }) as typeof globalThis.fetch; + + try { + const response = await modelsRoute.GET( + new Request(`http://localhost/api/providers/${connection.id}/models?refresh=true`), + { params: { id: connection.id } } + ); + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.source, "api"); + const ids = body.models.map((m: { id: string }) => m.id); + assert.ok(ids.includes("orange-mousse")); + assert.ok(ids.includes("ambrosia-tart-high")); + assert.equal(ids.includes("disabled-model"), false); + } finally { + globalThis.fetch = originalFetch; + } +}); From 45698736e3e9c30eb8af3caa04f062f73d62f3b4 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:38:57 -0300 Subject: [PATCH 11/22] fix(docs): heal release-green docs drift + eslint any-suppression drift (#7253) (#7755) - docs/routing/REASONING_ROUTING.md: migration renumbered 125->126 - docs/INCIDENT_RESPONSE.md, docs/PERF_BUDGETS.md: /api/version renamed to /api/system/version - config/quality/eslint-suppressions.json: rebaseline no-explicit-any counts for tests/unit/combo-routing-engine.test.ts (261->269) and tests/unit/base-executor-sanitize-effort.test.ts (45->48), drifted by the prior base-red full-suite realignment commits (dbc9f6081, 764a4aee0) whose sibling test-file-size ratchet was already rebaselined in 00b853969 but this gate was missed - tests/unit/call-log-provider-display.test.ts, tests/unit/m365-web-token-extraction-7078.test.ts: removed the never-baselined explicit any usages (typed via inference instead) --- changelog.d/fixes/7253-release-green-drift.md | 1 + config/quality/eslint-suppressions.json | 4 +- docs/INCIDENT_RESPONSE.md | 2 +- docs/PERF_BUDGETS.md | 2 +- docs/routing/REASONING_ROUTING.md | 2 +- tests/unit/call-log-provider-display.test.ts | 4 +- .../m365-web-token-extraction-7078.test.ts | 3 +- .../release-green-docs-drift-7253.test.ts | 39 +++++++++++++++++++ 8 files changed, 48 insertions(+), 9 deletions(-) create mode 100644 changelog.d/fixes/7253-release-green-drift.md create mode 100644 tests/unit/release-green-docs-drift-7253.test.ts diff --git a/changelog.d/fixes/7253-release-green-drift.md b/changelog.d/fixes/7253-release-green-drift.md new file mode 100644 index 0000000000..3ab36730a8 --- /dev/null +++ b/changelog.d/fixes/7253-release-green-drift.md @@ -0,0 +1 @@ +- fix(docs): correct stale `/api/version` and migration-125 references + realign `no-explicit-any` suppression counts drifted by base-red realignment commits (#7253) diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index 73daf5a475..af68f5c97a 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -599,7 +599,7 @@ }, "tests/unit/base-executor-sanitize-effort.test.ts": { "@typescript-eslint/no-explicit-any": { - "count": 45 + "count": 48 } }, "tests/unit/batch_api.test.ts": { @@ -1024,7 +1024,7 @@ }, "tests/unit/combo-routing-engine.test.ts": { "@typescript-eslint/no-explicit-any": { - "count": 261 + "count": 269 } }, "tests/unit/combo-same-provider-cascade.test.ts": { diff --git a/docs/INCIDENT_RESPONSE.md b/docs/INCIDENT_RESPONSE.md index 95cada8e84..e7990516c7 100644 --- a/docs/INCIDENT_RESPONSE.md +++ b/docs/INCIDENT_RESPONSE.md @@ -99,7 +99,7 @@ not** skip steps; each is timed. ### 4.2 Cluster-wide latency regression -1. Check the most recent deploy (`/api/version` returns the SHA). +1. Check the most recent deploy (`/api/system/version` returns the SHA). 2. If p95 doubled vs the 7-day baseline, **roll back** to the prior SHA via `bin/rollback.sh`. 3. If the regression is provider-side, see § 4.1. diff --git a/docs/PERF_BUDGETS.md b/docs/PERF_BUDGETS.md index ca7af81f12..64c04623fb 100644 --- a/docs/PERF_BUDGETS.md +++ b/docs/PERF_BUDGETS.md @@ -125,7 +125,7 @@ flag in the weekly perf review. | Endpoint | Method | p50 | p95 | p99 | |---|---|---|---|---| | `/api/health/ping` | GET | 5 ms | 20 ms | 50 ms | -| `/api/version` | GET | 5 ms | 20 ms | 50 ms | +| `/api/system/version` | GET | 5 ms | 20 ms | 50 ms | | `/api/docs` | GET | 20 ms | 80 ms | 200 ms (HTML shell, no provider call) | --- diff --git a/docs/routing/REASONING_ROUTING.md b/docs/routing/REASONING_ROUTING.md index c8ffaed693..c81d69e3ac 100644 --- a/docs/routing/REASONING_ROUTING.md +++ b/docs/routing/REASONING_ROUTING.md @@ -64,7 +64,7 @@ executed there. The rule decision is stored in the existing route trace without ## Persistence -The migration `src/lib/db/migrations/125_reasoning_routing_rules.sql` creates the +The migration `src/lib/db/migrations/126_reasoning_routing_rules.sql` creates the `reasoning_routing_rules` table. Rules reference stored API keys, combos, and provider connections. Deletes clean up related rules. The database access layer in `src/lib/db/reasoningRoutingRules.ts` maintains an invalidatable cache for the request path. diff --git a/tests/unit/call-log-provider-display.test.ts b/tests/unit/call-log-provider-display.test.ts index 4688343051..4b1e9ee4ce 100644 --- a/tests/unit/call-log-provider-display.test.ts +++ b/tests/unit/call-log-provider-display.test.ts @@ -107,8 +107,8 @@ test("buildCallLogListRows adds providerDisplay to active and completed in-memor ], }); - const pending = rows.find((row: any) => row.id === "pending-1"); - const completed = rows.find((row: any) => row.id === "completed-1"); + const pending = rows.find((row) => row.id === "pending-1"); + const completed = rows.find((row) => row.id === "completed-1"); assert.equal(pending?.providerDisplay, "Bynara"); assert.equal(completed?.providerDisplay, "Bynara"); diff --git a/tests/unit/m365-web-token-extraction-7078.test.ts b/tests/unit/m365-web-token-extraction-7078.test.ts index 68ed08b7fc..eb76703877 100644 --- a/tests/unit/m365-web-token-extraction-7078.test.ts +++ b/tests/unit/m365-web-token-extraction-7078.test.ts @@ -5,8 +5,7 @@ import test from "node:test"; import assert from "node:assert/strict"; const B = await import("../../src/lib/providers/validation/webProvidersB.ts"); -const extract = (raw: string) => - (B as Record).extractM365CredentialParts(raw, {}); +const extract = (raw: string) => B.extractM365CredentialParts(raw, {}); test("#7078 m365.cloud.microsoft wss URL extracts access_token + chathubPath", () => { const raw = diff --git a/tests/unit/release-green-docs-drift-7253.test.ts b/tests/unit/release-green-docs-drift-7253.test.ts new file mode 100644 index 0000000000..edbc3f6620 --- /dev/null +++ b/tests/unit/release-green-docs-drift-7253.test.ts @@ -0,0 +1,39 @@ +// Issue #7253 — the "release branch not green" bot tracker for release/v3.8.49 +// found the branch genuinely red for `check:fabricated-docs --strict`: two doc +// files referenced a migration file / API route that no longer exist under +// those names (docs went stale after src/ moved on): +// - docs/routing/REASONING_ROUTING.md:67 -> migration renumbered 125 -> 126 +// - docs/INCIDENT_RESPONSE.md / docs/PERF_BUDGETS.md -> `/api/version` route +// was renamed to `/api/system/version` +// +// This runs the real doc-accuracy checker against the live repo tree (no +// fixture root override) so it keeps guarding against future doc drift, not +// just the two specific lines fixed here. +import test from "node:test"; +import assert from "node:assert/strict"; + +import { runFabricatedDocsCheck, formatHumanReport } from "../../scripts/check/check-fabricated-docs.mjs"; + +test("#7253 release-green: docs contain zero fabricated API/file-ref drift", () => { + const result = runFabricatedDocsCheck(); + if (result.totalFindings > 0) { + assert.fail(`fabricated-docs drift found:\n${formatHumanReport(result)}`); + } + assert.equal(result.totalFindings, 0); +}); + +test("#7253: REASONING_ROUTING.md references the current migration filename (126, not 125)", () => { + const result = runFabricatedDocsCheck(); + const hit = result.files + .flatMap((f) => f.findings.map((finding) => ({ file: f.rel, ...finding }))) + .find((f) => f.value === "src/lib/db/migrations/125_reasoning_routing_rules.sql"); + assert.equal(hit, undefined, "stale migration-125 reference must not resurface"); +}); + +test("#7253: INCIDENT_RESPONSE.md / PERF_BUDGETS.md reference /api/system/version, not the removed /api/version", () => { + const result = runFabricatedDocsCheck(); + const hit = result.files + .flatMap((f) => f.findings.map((finding) => ({ file: f.rel, ...finding }))) + .find((f) => f.value === "/api/version"); + assert.equal(hit, undefined, "stale /api/version reference must not resurface"); +}); From c95a161709ae95bf315c46fd97fb74fbf4c185d5 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:39:02 -0300 Subject: [PATCH 12/22] fix(sse): persist rotated Gemini web-session cookies via onCredentialsRefreshed (#7676) (#7751) --- .../fixes/7676-gemini-persist-cookies.md | 1 + open-sse/executors/gemini-web.ts | 68 +++++++++++++- .../gemini-web-cookie-rotation-7676.test.ts | 93 +++++++++++++++++++ 3 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 changelog.d/fixes/7676-gemini-persist-cookies.md create mode 100644 tests/unit/gemini-web-cookie-rotation-7676.test.ts diff --git a/changelog.d/fixes/7676-gemini-persist-cookies.md b/changelog.d/fixes/7676-gemini-persist-cookies.md new file mode 100644 index 0000000000..9e320d7ef1 --- /dev/null +++ b/changelog.d/fixes/7676-gemini-persist-cookies.md @@ -0,0 +1 @@ +- fix(sse): persist rotated Gemini web-session cookies via onCredentialsRefreshed (#7676) diff --git a/open-sse/executors/gemini-web.ts b/open-sse/executors/gemini-web.ts index 0286cc8202..c60df8eb35 100644 --- a/open-sse/executors/gemini-web.ts +++ b/open-sse/executors/gemini-web.ts @@ -169,6 +169,40 @@ function readProviderSpecificString( return ""; } +/** + * Merge rotated __Secure-1PSID* cookies read back from the live Playwright + * cookie jar into the original cookie string. Only the three long-lived + * Gemini auth cookies are considered — pulling in the entire jar would risk + * treating short-lived Google analytics/consent cookies as credentials + * (#7676). Cookies the jar didn't return, or that are unchanged, are left + * untouched in the original string. + */ +export function mergeRotatedGeminiCookies( + originalCookie: string, + jarCookies: Array<{ name: string; value: string }> +): string { + const ROTATABLE_NAMES = ["__Secure-1PSID", "__Secure-1PSIDTS", "__Secure-1PSIDCC"]; + const jarByName = new Map(jarCookies.map((c) => [c.name, c.value])); + + const pairs = parseCookies(originalCookie); + const seen = new Set(); + const merged = pairs.map(({ name, value }) => { + seen.add(name); + if (ROTATABLE_NAMES.includes(name) && jarByName.has(name)) { + return { name, value: jarByName.get(name) as string }; + } + return { name, value }; + }); + + for (const name of ROTATABLE_NAMES) { + if (!seen.has(name) && jarByName.has(name)) { + merged.push({ name, value: jarByName.get(name) as string }); + } + } + + return merged.map(({ name, value }) => `${name}=${value}`).join("; "); +} + function normalizeGeminiCookieInput(raw: string, cookieName = "__Secure-1PSID"): string { const trimmed = raw.trim(); if (!trimmed) return ""; @@ -202,8 +236,38 @@ export class GeminiWebExecutor extends BaseExecutor { super("gemini-web", { id: "gemini-web", baseUrl: GEMINI_URL }); } + /** + * Read the live Playwright cookie jar back after a successful run and, if + * Google rotated any of the __Secure-1PSID* cookies, forward the merged + * cookie string through onCredentialsRefreshed so it gets persisted to the + * encrypted provider_connections.api_key field. Mirrors the rotate-and- + * persist pattern already shipped in chatgpt-web.ts. A persistence failure + * must never fail the user-facing response (#7676). + */ + private async persistRotatedCookies( + context: import("playwright").BrowserContext, + cookie: string, + credentials: ExecuteInput["credentials"], + onCredentialsRefreshed: ExecuteInput["onCredentialsRefreshed"], + log: ExecuteInput["log"] + ): Promise { + if (!onCredentialsRefreshed) return; + try { + const jarCookies = await context.cookies(); + const mergedCookie = mergeRotatedGeminiCookies(cookie, jarCookies); + if (mergedCookie && mergedCookie !== cookie) { + await onCredentialsRefreshed({ ...credentials, apiKey: mergedCookie }); + } + } catch (err) { + log?.warn?.( + "GEMINI-WEB", + `Failed to persist rotated cookie: ${err instanceof Error ? err.message : String(err)}` + ); + } + } + async execute(input: ExecuteInput) { - const { model, body, stream, credentials, signal } = input; + const { model, body, stream, credentials, signal, log, onCredentialsRefreshed } = input; const requestBody = body as GeminiRequestBody; const cookie = resolveGeminiWebCookie(credentials); @@ -314,6 +378,8 @@ export class GeminiWebExecutor extends BaseExecutor { }; } + await this.persistRotatedCookies(context, cookie, credentials, onCredentialsRefreshed, log); + const modelId = model || "gemini-2.5-pro"; if (stream) { diff --git a/tests/unit/gemini-web-cookie-rotation-7676.test.ts b/tests/unit/gemini-web-cookie-rotation-7676.test.ts new file mode 100644 index 0000000000..edbe5b7f1d --- /dev/null +++ b/tests/unit/gemini-web-cookie-rotation-7676.test.ts @@ -0,0 +1,93 @@ +// Repro probe for issue #7676: +// gemini-web executor never reads back the live Playwright cookie jar after a +// successful run, so rotated __Secure-1PSIDTS / __Secure-1PSIDCC values are +// never persisted via onCredentialsRefreshed — unlike chatgpt-web.ts, which +// already forwards its rotated cookie through the same callback +// (open-sse/executors/chatgpt-web.ts:2843). +import test from "node:test"; +import assert from "node:assert/strict"; + +const { GeminiWebExecutor } = await import("../../open-sse/executors/gemini-web.ts"); + +test("#7676: GeminiWebExecutor persists rotated __Secure-1PSIDTS/__Secure-1PSIDCC via onCredentialsRefreshed after a successful run", async () => { + const playwright = await import("playwright"); + const originalLaunch = playwright.chromium.launch; + + const staleCookie = + "__Secure-1PSID=abc123; __Secure-1PSIDTS=OLD_TS_VALUE; __Secure-1PSIDCC=OLD_CC_VALUE"; + + const rotatedJarCookies = [ + { name: "__Secure-1PSID", value: "abc123", domain: ".google.com", path: "/" }, + { name: "__Secure-1PSIDTS", value: "ROTATED_TS_VALUE", domain: ".google.com", path: "/" }, + { name: "__Secure-1PSIDCC", value: "ROTATED_CC_VALUE", domain: ".google.com", path: "/" }, + ]; + + playwright.chromium.launch = async () => + ({ + newContext: async () => ({ + addCookies: async () => {}, + cookies: async () => rotatedJarCookies, + newPage: async () => ({ + on: (event: string, handler: (resp: { url: () => string; text: () => Promise }) => void) => { + if (event === "response") { + const body = + ")]}'\n" + + "30\n" + + JSON.stringify([ + [ + "wrb.fr", + null, + JSON.stringify([null, null, null, null, [[null, ["hello back"]]]]), + ], + ]) + + "\n"; + handler({ + url: () => + "https://gemini.google.com/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate", + text: async () => body, + }); + } + }, + goto: async () => {}, + waitForTimeout: async () => {}, + waitForSelector: async () => ({ click: async () => {} }), + keyboard: { type: async () => {}, press: async () => {} }, + }), + }), + close: async () => {}, + }) as unknown as typeof originalLaunch; + + let persistedCredentials: Record | null = null; + + try { + const executor = new GeminiWebExecutor(); + const result = await executor.execute({ + model: "gemini-3.1-pro", + body: { messages: [{ role: "user", content: "hello" }], stream: false }, + stream: false, + credentials: { apiKey: staleCookie }, + signal: AbortSignal.timeout(5000), + log: null, + onCredentialsRefreshed: async (newCreds: Record) => { + persistedCredentials = newCreds; + }, + } as unknown as Parameters["execute"]>[0]); + + assert.equal(result.response.status, 200, "run should succeed with a Gemini response"); + assert.ok( + persistedCredentials, + "onCredentialsRefreshed must be called so the rotated cookie jar is persisted to provider_connections (#7676)" + ); + assert.ok( + typeof persistedCredentials.apiKey === "string" && + persistedCredentials.apiKey.includes("ROTATED_TS_VALUE"), + `persisted apiKey must contain the rotated __Secure-1PSIDTS value, got: ${persistedCredentials?.apiKey}` + ); + assert.ok( + persistedCredentials.apiKey.includes("ROTATED_CC_VALUE"), + `persisted apiKey must contain the rotated __Secure-1PSIDCC value, got: ${persistedCredentials?.apiKey}` + ); + } finally { + playwright.chromium.launch = originalLaunch; + } +}); From ded4ac830e2d273292c0935e5a7ed5dc140a0a22 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:39:08 -0300 Subject: [PATCH 13/22] fix(routing): honor eye-icon hidden models for no-auth providers in auto-combo (#7620) (#7750) getNoAuthCandidates() in open-sse/services/autoCombo/virtualFactory.ts built the candidate pool for no-auth providers (opencode/mimocode/etc.) without ever consulting getHiddenModelsByProvider(), unlike the credentialed-connection loop a few lines above it. A model hidden via the dashboard eye icon stayed in every auto/* candidate pool forever and could still be selected. Wire hiddenModelsMap into getNoAuthCandidates() the same way #7622 wired noAuthProviderSpecificData in, mirroring the existing credentialed-connection check. --- .../fixes/7620-noauth-hidden-filter.md | 1 + open-sse/services/autoCombo/virtualFactory.ts | 15 +++- .../unit/noauth-autocombo-hidden-7620.test.ts | 73 +++++++++++++++++++ 3 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 changelog.d/fixes/7620-noauth-hidden-filter.md create mode 100644 tests/unit/noauth-autocombo-hidden-7620.test.ts diff --git a/changelog.d/fixes/7620-noauth-hidden-filter.md b/changelog.d/fixes/7620-noauth-hidden-filter.md new file mode 100644 index 0000000000..0c106c6931 --- /dev/null +++ b/changelog.d/fixes/7620-noauth-hidden-filter.md @@ -0,0 +1 @@ +- fix(routing): honor eye-icon hidden models for no-auth providers in auto-combo candidate pools (#7620) diff --git a/open-sse/services/autoCombo/virtualFactory.ts b/open-sse/services/autoCombo/virtualFactory.ts index e5abe5bed9..ac87698a39 100644 --- a/open-sse/services/autoCombo/virtualFactory.ts +++ b/open-sse/services/autoCombo/virtualFactory.ts @@ -140,7 +140,8 @@ function getNoAuthCandidates( excludedProviders: Set, blockedProviders: Set, disabledNoAuthProviders: Set, - noAuthProviderSpecificData: Map | null | undefined> + noAuthProviderSpecificData: Map | null | undefined>, + hiddenModelsMap: Map> ): VirtualAutoComboCandidate[] { const registry = getProviderRegistry(); const candidates: VirtualAutoComboCandidate[] = []; @@ -190,10 +191,19 @@ function getNoAuthCandidates( ? noAuthProviderSpecificData.get(providerDef.alias) : undefined); + // #7620: honor the eye-icon "hidden" flag (isHidden, from the + // modelCompatOverrides/customModels key_value namespaces) the same way the + // credentialed-connection loop below does, so a hidden no-auth model never + // enters the auto-combo/fusion candidate pool either. + const hiddenModels = + hiddenModelsMap.get(providerId) ?? + (typeof providerDef.alias === "string" ? hiddenModelsMap.get(providerDef.alias) : undefined); + for (const model of registryModels) { const modelId = typeof model?.id === "string" && model.id.trim().length > 0 ? model.id : null; if (!modelId) continue; if (isModelExcludedByConnection(modelId, providerSpecificData)) continue; + if (hiddenModels?.has(modelId)) continue; candidates.push({ provider: providerId, connectionId: SYNTHETIC_NOAUTH_CONNECTION_ID, @@ -323,7 +333,8 @@ export async function createVirtualAutoCombo( new Set(validConnections.map((conn) => conn.provider)), blockedProviders, disabledNoAuthProviders, - noAuthProviderSpecificData + noAuthProviderSpecificData, + hiddenModelsMap ) ); diff --git a/tests/unit/noauth-autocombo-hidden-7620.test.ts b/tests/unit/noauth-autocombo-hidden-7620.test.ts new file mode 100644 index 0000000000..79bda0a20f --- /dev/null +++ b/tests/unit/noauth-autocombo-hidden-7620.test.ts @@ -0,0 +1,73 @@ +/** + * #7620 — hiding a no-auth-provider model with the EYE icon (Dashboard → Models, + * `isHidden: true` written via setModelIsHidden()/mergeModelCompatOverride()) does + * remove it from `/v1/models`, but `getNoAuthCandidates()` in + * `open-sse/services/autoCombo/virtualFactory.ts` never consults + * `getHiddenModelsByProvider()` at all (unlike the credentialed-connection loop a + * few lines above it, which does). A hidden no-auth model therefore stays in the + * `auto/*` candidate pool and can still be selected, causing a 401 when the + * upstream account for that hidden model is no longer valid/allowed. + */ +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-7620-noauth-hidden-")); +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; + +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const modelsDb = await import("../../src/lib/db/models.ts"); +const virtualFactory = await import("../../open-sse/services/autoCombo/virtualFactory.ts"); + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(async () => { + await resetStorage(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + if (ORIGINAL_DATA_DIR === undefined) { + delete process.env.DATA_DIR; + } else { + process.env.DATA_DIR = ORIGINAL_DATA_DIR; + } +}); + +test("#7620: a no-auth model hidden via the eye icon (isHidden:true) must be ABSENT from the auto-combo candidate pool", async () => { + modelsDb.setModelIsHidden("opencode", "mimo-v2.5-free", true); + + const hiddenMap = modelsDb.getHiddenModelsByProvider(); + assert.equal( + hiddenMap.get("opencode")?.has("mimo-v2.5-free"), + true, + "sanity: getHiddenModelsByProvider() must report opencode/mimo-v2.5-free as hidden" + ); + + const combo = await virtualFactory.createVirtualAutoCombo(undefined); + + const modelStrings = combo.models.map((m: { model: string }) => m.model); + assert.ok( + !modelStrings.some((model: string) => model.endsWith("/mimo-v2.5-free")), + "BUG #7620: the eye-hidden model 'mimo-v2.5-free' must not appear in the auto-combo " + + `candidate pool, but it did. Pool: ${JSON.stringify(modelStrings)}` + ); +}); + +test("#7620 baseline: with nothing hidden, opencode/mimo-v2.5-free is present in the pool", async () => { + const combo = await virtualFactory.createVirtualAutoCombo(undefined); + const modelStrings = combo.models.map((m: { model: string }) => m.model); + assert.ok( + modelStrings.some((model: string) => model.endsWith("/mimo-v2.5-free")), + `baseline: with nothing hidden, mimo-v2.5-free must be present. Pool: ${JSON.stringify(modelStrings)}` + ); +}); From a19f86b8ca5f8a02562518854812b97ae559e9f6 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:39:13 -0300 Subject: [PATCH 14/22] fix(authz): classify forge/jcode CLI settings routes as LOCAL_ONLY (#7263) (#7749) --- changelog.d/fixes/7263-settings-local-only.md | 1 + scripts/check/check-route-guard-membership.ts | 2 + src/server/authz/routeGuard.ts | 2 + ...rd-forge-jcode-settings-local-only.test.ts | 48 +++++++++++++++++++ 4 files changed, 53 insertions(+) create mode 100644 changelog.d/fixes/7263-settings-local-only.md create mode 100644 tests/unit/route-guard-forge-jcode-settings-local-only.test.ts diff --git a/changelog.d/fixes/7263-settings-local-only.md b/changelog.d/fixes/7263-settings-local-only.md new file mode 100644 index 0000000000..b32a211449 --- /dev/null +++ b/changelog.d/fixes/7263-settings-local-only.md @@ -0,0 +1 @@ +- fix(authz): classify /api/cli-tools/forge-settings and /api/cli-tools/jcode-settings as LOCAL_ONLY, closing an RCE-via-tunnel gap where getCliRuntimeStatus() spawns a child process without loopback enforcement (#7263) diff --git a/scripts/check/check-route-guard-membership.ts b/scripts/check/check-route-guard-membership.ts index c73d9e2667..cb5ca34c2d 100644 --- a/scripts/check/check-route-guard-membership.ts +++ b/scripts/check/check-route-guard-membership.ts @@ -50,6 +50,8 @@ export const SPAWN_CAPABLE_ROUTE_ROOTS: ReadonlyArray = [ "src/app/api/cli-tools/runtime", "src/app/api/local", // T-12: 1-click local service launchers (Redis today) — every child here spawns podman/docker (Hard Rules #15 + #17) "src/app/api/skills/collect", // Skill Collector CLI detection: GET .../detect spawns a child process per CLI_TOOL_IDS entry via getCliRuntimeStatus() (Hard Rules #15 + #17, PR #6294 review) + "src/app/api/cli-tools/forge-settings", // GET calls getCliRuntimeStatus() to detect the `forge` CLI install (Hard Rules #15 + #17, #7263) + "src/app/api/cli-tools/jcode-settings", // GET calls getCliRuntimeStatus() to detect the `jcode` CLI install (Hard Rules #15 + #17, #7263) ]; // Frozen pre-existing exceptions: spawn-capable routes NOT yet classified diff --git a/src/server/authz/routeGuard.ts b/src/server/authz/routeGuard.ts index dbb4dadf00..66ef33afa5 100644 --- a/src/server/authz/routeGuard.ts +++ b/src/server/authz/routeGuard.ts @@ -32,6 +32,8 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray = [ "/api/cli-tools/omp-settings", // spawns `which omp` to detect the CLI install (Hard Rules #15 + #17, #6318) "/api/cli-tools/letta-settings", // spawns `which letta` to detect the CLI install (Hard Rules #15 + #17, #6318) "/api/cli-tools/grok-build-settings", // GET calls getCliRuntimeStatus("grok-build"), which spawns a child process to locate + healthcheck the `grok` binary — same transitive-spawn surface that classified /api/skills/collect/ (Hard Rules #15 + #17). Writing ~/.grok/config.toml is inherently a local-machine operation, so loopback-only costs no real capability. + "/api/cli-tools/forge-settings", // spawns via getCliRuntimeStatus() to detect the `forge` CLI install (Hard Rules #15 + #17, #7263) + "/api/cli-tools/jcode-settings", // spawns via getCliRuntimeStatus() to detect the `jcode` CLI install (Hard Rules #15 + #17, #7263) "/api/services/", // T-10: embedded service lifecycle (spawn child processes) "/dashboard/providers/services/", // T-07: reverse proxy to embedded service UIs "/api/copilot/", // unauthenticated LLM driver — CLI-only by default; admins can opt-in to remote access via manage-scope bypass diff --git a/tests/unit/route-guard-forge-jcode-settings-local-only.test.ts b/tests/unit/route-guard-forge-jcode-settings-local-only.test.ts new file mode 100644 index 0000000000..aef5669d9b --- /dev/null +++ b/tests/unit/route-guard-forge-jcode-settings-local-only.test.ts @@ -0,0 +1,48 @@ +/** + * Security regression: /api/cli-tools/forge-settings and /api/cli-tools/jcode-settings + * must be classified as LOCAL_ONLY so loopback enforcement runs unconditionally before + * any auth check. + * + * GET calls getCliRuntimeStatus(TOOL_ID) (src/app/api/cli-tools/forge-settings/route.ts, + * src/app/api/cli-tools/jcode-settings/route.ts), which spawns a child process to locate + * and healthcheck the CLI binary (src/shared/services/cliRuntime.ts:332). That is the same + * transitive-spawn surface that got /api/cli-tools/grok-build-settings and + * /api/skills/collect/ classified. + * + * Classifying it LOCAL_ONLY closes the remote-RCE vector: a leaked JWT over a + * Cloudflared/Ngrok tunnel cannot trigger process spawning. + * Hard Rules #15 + #17. See docs/security/ROUTE_GUARD_TIERS.md. Issue #7263. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { isLocalOnlyPath } from "../../src/server/authz/routeGuard.ts"; + +test("/api/cli-tools/forge-settings is LOCAL_ONLY (spawns via getCliRuntimeStatus)", () => { + assert.equal(isLocalOnlyPath("/api/cli-tools/forge-settings"), true); +}); + +test("/api/cli-tools/forge-settings with trailing slash is LOCAL_ONLY", () => { + assert.equal(isLocalOnlyPath("/api/cli-tools/forge-settings/"), true); +}); + +test("/api/cli-tools/jcode-settings is LOCAL_ONLY (spawns via getCliRuntimeStatus)", () => { + assert.equal(isLocalOnlyPath("/api/cli-tools/jcode-settings"), true); +}); + +test("/api/cli-tools/jcode-settings with trailing slash is LOCAL_ONLY", () => { + assert.equal(isLocalOnlyPath("/api/cli-tools/jcode-settings/"), true); +}); + +test("sibling cli-tools spawn-capable settings routes stay LOCAL_ONLY", () => { + // Guards against a refactor dropping the established precedent this entry follows. + assert.equal(isLocalOnlyPath("/api/cli-tools/omp-settings"), true); + assert.equal(isLocalOnlyPath("/api/cli-tools/letta-settings"), true); + assert.equal(isLocalOnlyPath("/api/cli-tools/grok-build-settings"), true); +}); + +test("non-spawning cli-tools routes are NOT over-gated by this entry", () => { + // The new prefixes must not accidentally widen to the whole /api/cli-tools/ subtree, + // which remote dashboards legitimately use. + assert.equal(isLocalOnlyPath("/api/cli-tools/all-statuses"), false); + assert.equal(isLocalOnlyPath("/api/cli-tools/keys"), false); +}); From d03fc19c58f833d803e3b118a4f127ff35a84dd0 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:39:17 -0300 Subject: [PATCH 15/22] fix(sse): wire settings.wildcardAliases into model resolution (#7693) (#7748) Wildcard model aliases created via the Settings UI's "Wildcard Pattern" mode were persisted to settings.wildcardAliases but getCombinedModelAliases() never read that store, so the wildcard-matching step in getModelInfoCore() never saw the user's patterns. Every request fell through to provider inference and threw "Ambiguous model" for models multiple providers claim. Fold settings.wildcardAliases entries into the merged alias map (keyed by pattern string, folded in last so it never shadows exact aliases). --- changelog.d/fixes/7693-wildcard-alias.md | 1 + src/sse/services/model.ts | 42 +++++++++--- ...rd-alias-settings-not-applied-7693.test.ts | 65 +++++++++++++++++++ 3 files changed, 100 insertions(+), 8 deletions(-) create mode 100644 changelog.d/fixes/7693-wildcard-alias.md create mode 100644 tests/unit/wildcard-alias-settings-not-applied-7693.test.ts diff --git a/changelog.d/fixes/7693-wildcard-alias.md b/changelog.d/fixes/7693-wildcard-alias.md new file mode 100644 index 0000000000..e921dca52e --- /dev/null +++ b/changelog.d/fixes/7693-wildcard-alias.md @@ -0,0 +1 @@ +- fix(sse): wire settings.wildcardAliases into model resolution so wildcard model aliases created in Settings actually take effect (#7693) diff --git a/src/sse/services/model.ts b/src/sse/services/model.ts index bd370b8ae2..2f1f417eb0 100644 --- a/src/sse/services/model.ts +++ b/src/sse/services/model.ts @@ -35,16 +35,41 @@ function getReservedProviderPrefixes(): Set { } /** - * Build a combined model alias map that merges both alias stores: + * Fold `settings.wildcardAliases` ({pattern,target}[]) — the store the Settings + * UI's "Wildcard Pattern" mode writes to (ModelAliasesUnified.tsx::addWildcardAlias + * -> PATCH /api/settings) — into `pattern -> target` map entries so the T13 + * wildcard step in getModelInfoCore() (which treats every key of the merged alias + * map as a candidate glob pattern) can see them (#7693). Without this the + * feature persists but is never consulted at request time. + */ +function buildWildcardAliasMap(settings: Record): Record { + const wildcardEntries = Array.isArray(settings.wildcardAliases) + ? (settings.wildcardAliases as Array<{ pattern?: unknown; target?: unknown }>) + : []; + const wildcardMap: Record = {}; + for (const entry of wildcardEntries) { + if (entry && typeof entry.pattern === "string" && typeof entry.target === "string") { + wildcardMap[entry.pattern] = entry.target; + } + } + return wildcardMap; +} + +/** + * Build a combined model alias map that merges all alias stores: * 1. DB-namespace aliases (key_value WHERE namespace='modelAliases') — set via * /api/models/alias/ and seeded at startup. - * 2. Settings-based aliases (settings.modelAliases) — set via the Settings UI and + * 2. Settings-based exact aliases (settings.modelAliases) — set via the Settings UI and * /api/settings/model-aliases/ (stored as a JSON blob in namespace='settings'). + * 3. Settings-based wildcard aliases (settings.wildcardAliases) — set via the Settings + * UI's "Wildcard Pattern" mode, PATCH /api/settings (#7693). * - * Settings-based aliases take priority so that UI configuration always wins. - * Without this merge, aliases configured via the Settings UI were never consulted - * during provider routing, causing provider inference (e.g. /^gpt-/ → openai) to - * silently override them (issue #2618 / #2208). + * Settings-based exact aliases take priority over DB-namespace aliases so that UI + * configuration always wins. Without this merge, aliases configured via the Settings + * UI were never consulted during provider routing, causing provider inference (e.g. + * /^gpt-/ → openai) to silently override them (issue #2618 / #2208). Wildcard entries + * are folded in last: they are keyed by pattern string (containing `*`/`?`), which + * cannot collide with a real model id, so ordering never affects exact-alias lookups. */ async function getCombinedModelAliases(): Promise> { const [dbAliases, settings] = await Promise.all([ @@ -59,8 +84,9 @@ async function getCombinedModelAliases(): Promise> { ? (settings.modelAliases as Record) : {}; - // Settings-based aliases win over DB-namespace aliases on key collision - return { ...dbAliases, ...settingsAliases }; + const wildcardMap = buildWildcardAliasMap(settings); + + return { ...dbAliases, ...settingsAliases, ...wildcardMap }; } /** diff --git a/tests/unit/wildcard-alias-settings-not-applied-7693.test.ts b/tests/unit/wildcard-alias-settings-not-applied-7693.test.ts new file mode 100644 index 0000000000..0890d535e5 --- /dev/null +++ b/tests/unit/wildcard-alias-settings-not-applied-7693.test.ts @@ -0,0 +1,65 @@ +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-wildcard-alias-7693-")); +process.env.DATA_DIR = TEST_DATA_DIR; +const ORIGINAL_API_KEY_SECRET = process.env.API_KEY_SECRET; +process.env.API_KEY_SECRET = "test-wildcard-alias-7693-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const settingsDb = await import("../../src/lib/db/settings.ts"); +const { getModelInfo } = await import("../../src/sse/services/model.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 }); + if (ORIGINAL_API_KEY_SECRET === undefined) { + delete process.env.API_KEY_SECRET; + } else { + process.env.API_KEY_SECRET = ORIGINAL_API_KEY_SECRET; + } +}); + +test("#7693: a wildcard alias saved via settings.wildcardAliases IS applied by getModelInfo", async () => { + // Exactly what ModelAliasesUnified.tsx::addWildcardAlias() does: PATCH + // /api/settings with { wildcardAliases: [...] } -> src/lib/db/settings.ts::updateSettings(). + await settingsDb.updateSettings({ + wildcardAliases: [{ pattern: "claude-haiku-*", target: "openai/gpt-4o-mini" }], + }); + + // Same bare-model request Claude Code sends when routed through + // ~/.claude/settings.json's `model` field, exactly as reported in #7693. + const info = await getModelInfo("claude-haiku-4-5-20251001"); + + assert.equal( + info.provider, + "openai", + `expected the "claude-haiku-*" wildcard alias to route to provider "openai", got ${JSON.stringify(info)}` + ); + assert.equal(info.model, "gpt-4o-mini"); +}); + +test("#7693: an exact alias still wins over a wildcard alias on the same model id", async () => { + await settingsDb.updateSettings({ + modelAliases: { "claude-haiku-4-5-20251001": "anthropic/claude-3-5-sonnet-20241022" }, + wildcardAliases: [{ pattern: "claude-haiku-*", target: "openai/gpt-4o-mini" }], + }); + + const info = await getModelInfo("claude-haiku-4-5-20251001"); + + assert.equal( + info.provider, + "anthropic", + `expected the exact alias to win over the wildcard alias, got ${JSON.stringify(info)}` + ); + assert.equal(info.model, "claude-3-5-sonnet-20241022"); +}); From 425dbc9614e4a2d68819a28e037f33934648d765 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:55:19 -0300 Subject: [PATCH 16/22] fix(packaging): move fumadocs-mdx to devDependencies (#7661) (#7757) --- changelog.d/fixes/7661-fumadocs-devdep.md | 1 + package-lock.json | 49 ++++++++++++++- package.json | 2 +- ...npm-runtime-deps-fumadocs-mdx-7661.test.ts | 59 +++++++++++++++++++ 4 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 changelog.d/fixes/7661-fumadocs-devdep.md create mode 100644 tests/unit/npm-runtime-deps-fumadocs-mdx-7661.test.ts diff --git a/changelog.d/fixes/7661-fumadocs-devdep.md b/changelog.d/fixes/7661-fumadocs-devdep.md new file mode 100644 index 0000000000..7340d36184 --- /dev/null +++ b/changelog.d/fixes/7661-fumadocs-devdep.md @@ -0,0 +1 @@ +- fix(packaging): move fumadocs-mdx from dependencies to devDependencies to avoid pulling its build-only yuku-analyzer/yuku-ast toolchain into `npm install -g omniroute` (#7661) diff --git a/package-lock.json b/package-lock.json index d5eb7bd8fd..c5b8978f8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,7 +36,6 @@ "fetch-socks": "^1.3.3", "fflate": "^0.8.3", "fumadocs-core": "^16.10.5", - "fumadocs-mdx": "^15.0.7", "fumadocs-ui": "^16.10.5", "http-proxy-middleware": "^4.0.0", "https-proxy-agent": "^9.0.0", @@ -104,7 +103,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@types/better-sqlite3": "^7.6.13", - "@types/bun": "*", + "@types/bun": "latest", "@types/node": "^26.1.0", "@types/react": "^19.2.15", "@types/react-dom": "^19.2.3", @@ -121,6 +120,7 @@ "eslint-config-next": "16.2.10", "eslint-plugin-sonarjs": "^4.1.0", "fast-check": "^4.8.0", + "fumadocs-mdx": "^15.0.7", "glob": "^13.0.6", "httpyac": "^6.16.7", "husky": "^9.1.7", @@ -4976,6 +4976,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-3.1.1.tgz", "integrity": "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.0", @@ -12844,6 +12845,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "darwin" @@ -12856,6 +12858,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "darwin" @@ -12868,6 +12871,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "freebsd" @@ -12880,6 +12884,7 @@ "cpu": [ "arm" ], + "dev": true, "libc": [ "glibc" ], @@ -12895,6 +12900,7 @@ "cpu": [ "arm" ], + "dev": true, "libc": [ "musl" ], @@ -12910,6 +12916,7 @@ "cpu": [ "arm64" ], + "dev": true, "libc": [ "glibc" ], @@ -12925,6 +12932,7 @@ "cpu": [ "arm64" ], + "dev": true, "libc": [ "musl" ], @@ -12940,6 +12948,7 @@ "cpu": [ "x64" ], + "dev": true, "libc": [ "glibc" ], @@ -12955,6 +12964,7 @@ "cpu": [ "x64" ], + "dev": true, "libc": [ "musl" ], @@ -12970,6 +12980,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -12982,6 +12993,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -12991,6 +13003,7 @@ "version": "0.5.43", "resolved": "https://registry.npmjs.org/@yuku-toolchain/types/-/types-0.5.43.tgz", "integrity": "sha512-kSpvPntnXw5+lYjO71ffBEnQ5ycQ74KGIYknh0TS4xeyCuBkOqxyJumxZkMhLBBUCLjDAbx2+Icnr3Zh4ftjpQ==", + "dev": true, "license": "MIT" }, "node_modules/a-sync-waterfall": { @@ -13052,6 +13065,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, "license": "MIT", "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -13593,6 +13607,7 @@ "version": "1.9.0", "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", "integrity": "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==", + "dev": true, "license": "MIT", "bin": { "astring": "bin/astring" @@ -14679,6 +14694,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, "license": "MIT", "dependencies": { "readdirp": "^5.0.0" @@ -15327,6 +15343,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-2.1.0.tgz", "integrity": "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==", + "dev": true, "license": "MIT", "funding": { "type": "github", @@ -17860,6 +17877,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/esast-util-from-estree/-/esast-util-from-estree-2.0.0.tgz", "integrity": "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree-jsx": "^1.0.0", @@ -17876,6 +17894,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/esast-util-from-js/-/esast-util-from-js-2.0.1.tgz", "integrity": "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree-jsx": "^1.0.0", @@ -18616,6 +18635,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/estree-util-build-jsx/-/estree-util-build-jsx-3.0.1.tgz", "integrity": "sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree-jsx": "^1.0.0", @@ -18642,6 +18662,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/estree-util-scope/-/estree-util-scope-1.0.0.tgz", "integrity": "sha512-2CAASclonf+JFWBNJPndcOpA8EMJwa0Q8LUFJEKqXLW6+qBvbFZuF5gItbQOs/umBUkjviCSDCbBwU2cXbmrhQ==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.0", @@ -18656,6 +18677,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/estree-util-to-js/-/estree-util-to-js-2.0.0.tgz", "integrity": "sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree-jsx": "^1.0.0", @@ -18683,6 +18705,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/estree-util-visit/-/estree-util-visit-2.0.0.tgz", "integrity": "sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree-jsx": "^1.0.0", @@ -18697,6 +18720,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.0" @@ -19699,6 +19723,7 @@ "version": "15.2.0", "resolved": "https://registry.npmjs.org/fumadocs-mdx/-/fumadocs-mdx-15.2.0.tgz", "integrity": "sha512-+yBP8QYw5wA9LF5eVdMhwbP7KT1OF4B/YfC6PZoD2jz0amZi1B+6QHTI6XoRRSTmhWrI4cL5LU1DspW0itk+NA==", + "dev": true, "license": "MIT", "dependencies": { "@mdx-js/mdx": "^3.1.1", @@ -25691,6 +25716,7 @@ "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -25753,6 +25779,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/markdown-extensions/-/markdown-extensions-2.0.0.tgz", "integrity": "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=16" @@ -26491,6 +26518,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/micromark-extension-mdx-expression/-/micromark-extension-mdx-expression-3.0.1.tgz", "integrity": "sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==", + "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -26517,6 +26545,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/micromark-extension-mdx-jsx/-/micromark-extension-mdx-jsx-3.0.2.tgz", "integrity": "sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.0", @@ -26539,6 +26568,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/micromark-extension-mdx-md/-/micromark-extension-mdx-md-2.0.0.tgz", "integrity": "sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==", + "dev": true, "license": "MIT", "dependencies": { "micromark-util-types": "^2.0.0" @@ -26552,6 +26582,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/micromark-extension-mdxjs/-/micromark-extension-mdxjs-3.0.0.tgz", "integrity": "sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==", + "dev": true, "license": "MIT", "dependencies": { "acorn": "^8.0.0", @@ -26572,6 +26603,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/micromark-extension-mdxjs-esm/-/micromark-extension-mdxjs-esm-3.0.0.tgz", "integrity": "sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.0", @@ -26636,6 +26668,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/micromark-factory-mdx-expression/-/micromark-factory-mdx-expression-2.0.3.tgz", "integrity": "sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==", + "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -26864,6 +26897,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/micromark-util-events-to-acorn/-/micromark-util-events-to-acorn-2.0.3.tgz", "integrity": "sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==", + "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -32158,6 +32192,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 20.19.0" @@ -32210,6 +32245,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/recma-build-jsx/-/recma-build-jsx-1.0.0.tgz", "integrity": "sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.0", @@ -32225,6 +32261,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/recma-jsx/-/recma-jsx-1.0.1.tgz", "integrity": "sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w==", + "dev": true, "license": "MIT", "dependencies": { "acorn-jsx": "^5.0.0", @@ -32245,6 +32282,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/recma-parse/-/recma-parse-1.0.0.tgz", "integrity": "sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.0", @@ -32261,6 +32299,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/recma-stringify/-/recma-stringify-1.0.0.tgz", "integrity": "sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.0", @@ -32504,6 +32543,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/rehype-recma/-/rehype-recma-1.0.0.tgz", "integrity": "sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.0", @@ -32553,6 +32593,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/remark-mdx/-/remark-mdx-3.1.1.tgz", "integrity": "sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg==", + "dev": true, "license": "MIT", "dependencies": { "mdast-util-mdx": "^3.0.0", @@ -33961,6 +34002,7 @@ "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">= 12" @@ -35941,6 +35983,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/unist-util-position-from-estree/-/unist-util-position-from-estree-2.0.0.tgz", "integrity": "sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==", + "dev": true, "license": "MIT", "dependencies": { "@types/unist": "^3.0.0" @@ -35954,6 +35997,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==", + "dev": true, "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", @@ -37527,6 +37571,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/yuku-analyzer/-/yuku-analyzer-0.6.3.tgz", "integrity": "sha512-RQ02dPtOa5d2AA3Np45EWD3EJUwZDruCrMMulPwUT/9GK1P7aKAhjaxG4Jv/1qqzMUIt0RUe3Dn2RR6d7+qTrA==", + "dev": true, "license": "MIT", "dependencies": { "@yuku-toolchain/types": "0.5.43" diff --git a/package.json b/package.json index 0fbeaf37a6..28e75a4077 100644 --- a/package.json +++ b/package.json @@ -254,7 +254,6 @@ "fetch-socks": "^1.3.3", "fflate": "^0.8.3", "fumadocs-core": "^16.10.5", - "fumadocs-mdx": "^15.0.7", "fumadocs-ui": "^16.10.5", "http-proxy-middleware": "^4.0.0", "https-proxy-agent": "^9.0.0", @@ -345,6 +344,7 @@ "eslint-config-next": "16.2.10", "eslint-plugin-sonarjs": "^4.1.0", "fast-check": "^4.8.0", + "fumadocs-mdx": "^15.0.7", "glob": "^13.0.6", "httpyac": "^6.16.7", "husky": "^9.1.7", diff --git a/tests/unit/npm-runtime-deps-fumadocs-mdx-7661.test.ts b/tests/unit/npm-runtime-deps-fumadocs-mdx-7661.test.ts new file mode 100644 index 0000000000..ae74b161ac --- /dev/null +++ b/tests/unit/npm-runtime-deps-fumadocs-mdx-7661.test.ts @@ -0,0 +1,59 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join, resolve, extname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const REPO_ROOT = resolve(fileURLToPath(import.meta.url), "../../../"); + +function listSourceFiles(dir: string, exts: string[]): string[] { + const out: string[] = []; + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return out; + } + for (const entry of entries) { + if (entry === "node_modules" || entry === ".git" || entry === ".source") continue; + const full = join(dir, entry); + const st = statSync(full); + if (st.isDirectory()) out.push(...listSourceFiles(full, exts)); + else if (exts.includes(extname(entry))) out.push(full); + } + return out; +} + +test("#7661 — fumadocs-mdx must not be a runtime dependency (npm install -g ETARGET exposure)", () => { + const pkg = JSON.parse(readFileSync(join(REPO_ROOT, "package.json"), "utf8")); + const deps: Record = pkg.dependencies || {}; + const devDeps: Record = pkg.devDependencies || {}; + + assert.ok( + !("fumadocs-mdx" in deps), + 'fumadocs-mdx is declared under "dependencies" — npm install -g omniroute fetches it ' + + "and its transitive yuku-analyzer/yuku-ast native-binding tree even though nothing at " + + 'runtime imports it. Move it to "devDependencies".' + ); + + assert.ok( + "fumadocs-mdx" in devDeps || "fumadocs-mdx" in deps, + "fumadocs-mdx must remain declared somewhere" + ); + + const runtimeDirs = ["src", "open-sse", "bin"].map((d) => join(REPO_ROOT, d)); + const runtimeFiles = runtimeDirs.flatMap((d) => + listSourceFiles(d, [".ts", ".tsx", ".js", ".mjs", ".cjs"]) + ); + assert.ok(runtimeFiles.length > 100, "sanity: scanner should find the runtime source tree"); + + const offenders = runtimeFiles.filter((f) => { + const src = readFileSync(f, "utf8"); + return /from\s+["']fumadocs-mdx(\/|["'])|require\(\s*["']fumadocs-mdx(\/|["'])/.test(src); + }); + assert.deepEqual( + offenders, + [], + `fumadocs-mdx is imported at runtime by: ${offenders.join(", ")}` + ); +}); From 0c6041a34e6ad35d525731667ced8a9713fee0ed Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:55:25 -0300 Subject: [PATCH 17/22] fix(mcp): copy undici into dist/node_modules to prevent hollow-package shadowing crash (#7701) (#7756) --- changelog.d/fixes/7701-mcp-undici-copy.md | 1 + scripts/build/assembleStandalone.mjs | 11 ++ .../unit/mcp-server-hollow-dist-deps.test.ts | 113 ++++++++++++++++++ 3 files changed, 125 insertions(+) create mode 100644 changelog.d/fixes/7701-mcp-undici-copy.md create mode 100644 tests/unit/mcp-server-hollow-dist-deps.test.ts diff --git a/changelog.d/fixes/7701-mcp-undici-copy.md b/changelog.d/fixes/7701-mcp-undici-copy.md new file mode 100644 index 0000000000..0d05d9cf96 --- /dev/null +++ b/changelog.d/fixes/7701-mcp-undici-copy.md @@ -0,0 +1 @@ +- fix(mcp): copy undici into dist/node_modules to prevent hollow-package shadowing crash (#7701) diff --git a/scripts/build/assembleStandalone.mjs b/scripts/build/assembleStandalone.mjs index bdaed25aea..0a083d81d4 100644 --- a/scripts/build/assembleStandalone.mjs +++ b/scripts/build/assembleStandalone.mjs @@ -188,6 +188,17 @@ const EXTRA_MODULE_ENTRIES = [ src: ["node_modules", "playwright-core"], dest: ["node_modules", "playwright-core"], }, + { + // esbuild's `--packages=external` leaves `undici` as a static top-level ESM + // import in the compiled MCP server bundle (dist/open-sse/mcp-server/server.js), + // resolved at module-link time. Next.js's standalone output-file tracer (nft) + // sometimes emits a hollow dist/node_modules/undici/ (package.json only), which + // SHADOWS the fully-populated sibling node_modules/undici and crashes + // `omniroute --mcp` at startup. See #7701. + label: "undici (MCP server static import — #7701)", + src: ["node_modules", "undici"], + dest: ["node_modules", "undici"], + }, { label: "sqlite-vec wrapper (vector memory - loaded at runtime via createRequire)", src: ["node_modules", "sqlite-vec"], diff --git a/tests/unit/mcp-server-hollow-dist-deps.test.ts b/tests/unit/mcp-server-hollow-dist-deps.test.ts new file mode 100644 index 0000000000..62b284e453 --- /dev/null +++ b/tests/unit/mcp-server-hollow-dist-deps.test.ts @@ -0,0 +1,113 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { execFileSync } from "node:child_process"; + +// Regression guard for issue #7701. +// +// dist/open-sse/mcp-server/server.js is produced by: +// esbuild open-sse/mcp-server/server.ts --bundle --platform=node +// --packages=external --format=esm --outfile=dist/open-sse/mcp-server/server.js +// (scripts/build/prepublish.ts, Step 8.5) +// +// `--packages=external` means every bare-specifier `import ... from "pkg"` in the +// MCP server's source graph survives UNBUNDLED in the compiled output. Node's ESM +// loader resolves every one of those STATIC top-level imports at *module-link +// time* -- before any of the MCP server's own code (including its startup log +// lines) executes. +// +// Separately, `dist/node_modules/` is populated by Next.js's standalone output +// file tracer (nft), which walks the compiled Next.js app's require graph. nft is +// known (and separately documented in this repo -- see the #6559 comment in +// src/shared/utils/rateLimiter.ts, plus the sqlite-vec/#3066, tls-options/#5452, +// head-response-guard/#7065, and @swc/helpers precedents in assembleStandalone.mjs) +// to sometimes emit a HOLLOW `dist/node_modules//` directory containing only +// package.json, no code. Node's module resolution stops at the FIRST +// node_modules/ directory found while walking up from the importer -- so a +// hollow dist/node_modules/ SHADOWS the fully-populated sibling +// node_modules/ that npm installed for the published package, and the MCP +// server crashes with: +// Error: Cannot find package '.../dist/node_modules/undici/index.js' +// +// The project's existing mitigation for this bug class is an explicit copy +// guarantee (EXTRA_MODULE_ENTRIES / NATIVE_ASSET_ENTRIES in assembleStandalone.mjs) +// that force-overwrites whatever nft did with a full copy from the sibling +// node_modules. This test proves that `undici` -- a real, static, top-level +// external import of the actual esbuild-compiled MCP server bundle -- has NO such +// guarantee, so a hollow nft trace for it is packaging-fatal. + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const ASSEMBLE = path.join(ROOT, "scripts", "build", "assembleStandalone.mjs"); + +const NODE_BUILTINS = new Set([ + "assert", "async_hooks", "buffer", "child_process", "crypto", "dns", "events", + "fs", "http", "https", "module", "net", "os", "path", "stream", "tls", "url", + "util", "worker_threads", "zlib", +]); + +function isBuiltin(pkg: string): boolean { + return pkg.startsWith("node:") || NODE_BUILTINS.has(pkg); +} + +/** Bare-specifier packages that survive as STATIC top-level imports in the real, + * esbuild-compiled (--packages=external) MCP server bundle. */ +function mcpBundleStaticExternalImports(): string[] { + const outFile = path.join( + os.tmpdir(), + `omniroute-mcp-server-probe-${process.pid}-${Date.now()}.js` + ); + try { + execFileSync( + "npx", + [ + "esbuild", "open-sse/mcp-server/server.ts", "--bundle", "--platform=node", + "--packages=external", "--format=esm", `--outfile=${outFile}`, + ], + { cwd: ROOT, stdio: ["ignore", "ignore", "inherit"] } + ); + const src = fs.readFileSync(outFile, "utf8"); + const re = /^import\s+(?:[^;]*?\s+from\s+)?["']([^."][^"']*)["'];?$/gm; + const pkgs = new Set(); + let m: RegExpExecArray | null; + while ((m = re.exec(src))) { + const spec = m[1]; + const name = spec.startsWith("@") ? spec.split("/").slice(0, 2).join("/") : spec.split("/")[0]; + if (!isBuiltin(name)) pkgs.add(name); + } + return [...pkgs].sort(); + } finally { + fs.rmSync(outFile, { force: true }); + } +} + +function explicitlyGuaranteedPackages(): Set { + const text = fs.readFileSync(ASSEMBLE, "utf8"); + const entries = [...text.matchAll(/src:\s*\[([^\]]+)\]/gs)]; + const guaranteed = new Set(); + for (const [, segList] of entries) { + const segments = segList.split(",").map((s) => s.trim().replace(/^"|"$/g, "")).filter(Boolean); + if (segments[0] !== "node_modules") continue; + const pkg = segments[1]?.startsWith("@") ? `${segments[1]}/${segments[2]}` : segments[1]; + if (pkg) guaranteed.add(pkg); + } + return guaranteed; +} + +test("sanity: MCP server bundle probe finds real external packages (parser didn't break)", () => { + const pkgs = mcpBundleStaticExternalImports(); + assert.ok(pkgs.length > 5, `expected several external packages, got: ${pkgs.join(", ")}`); + assert.ok(pkgs.includes("better-sqlite3"), `missing better-sqlite3: ${pkgs.join(", ")}`); +}); + +test("undici (a static top-level external import of the real MCP server bundle) has an explicit dist/node_modules copy guarantee (#7701)", () => { + const staticExternals = mcpBundleStaticExternalImports(); + assert.ok( + staticExternals.includes("undici"), + `expected undici among the MCP bundle's static external imports (sanity check on the repro itself): ${staticExternals.join(", ")}` + ); + const guaranteed = explicitlyGuaranteedPackages(); + assert.ok(guaranteed.has("undici"), "undici is statically imported at module-link time by the esbuild-compiled MCP server bundle but has NO explicit copy entry in EXTRA_MODULE_ENTRIES (scripts/build/assembleStandalone.mjs) ... (issue #7701)."); +}); From d1730f5b8a700dc80aff7f465df7739043764bad Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:55:31 -0300 Subject: [PATCH 18/22] fix(ci): build API-only smoke workflows backend-only to fix dast-smoke timeouts (#7226) (#7758) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dast-smoke.yml and 3 nightly API-only smoke workflows (nightly-schemathesis, nightly-resilience, nightly-llm-security) ran "npm run build:cli" with no preceding full build or downloaded .build/next artifact. scripts/build/ prepublish.ts silently falls back to a full Next.js production build (dashboard UI + ~126 leaf pages + prerender) whenever the standalone server.js is missing, which is always the case in these jobs. That inline full build is the actual thing varying 6-29min on GitHub-hosted runners. These workflows only exercise API routes (schemathesis/promptfoo hit /api/monitoring/health, /v1/chat/completions, /v1/models, /api/auth, /api/keys) and never touch the dashboard UI, so set OMNIROUTE_BUILD_BACKEND_ONLY=1 on their "Build CLI bundle" step — an existing, previously-unused escape hatch (scripts/build/backendOnlyPages.mjs) that stubs the dashboard pages before the build and restores them after, leaving every route.ts API handler intact. npm-publish.yml is intentionally left untouched: it legitimately ships the full dashboard UI in the published npm package. Regression guard: tests/unit/build/backend-only-smoke-workflows.test.ts asserts OMNIROUTE_BUILD_BACKEND_ONLY=1/OMNIROUTE_BUILD_PROFILE=backend on all 5 "Build CLI bundle" steps across the 4 fixed workflows, and asserts npm-publish.yml's build step is NOT backend-only. --- .github/workflows/dast-smoke.yml | 2 + .github/workflows/nightly-llm-security.yml | 8 +- .github/workflows/nightly-resilience.yml | 1 + .github/workflows/nightly-schemathesis.yml | 4 +- .../fixes/7226-dast-smoke-backend-only.md | 1 + .../backend-only-smoke-workflows.test.ts | 88 +++++++++++++++++++ 6 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 changelog.d/fixes/7226-dast-smoke-backend-only.md create mode 100644 tests/unit/build/backend-only-smoke-workflows.test.ts diff --git a/.github/workflows/dast-smoke.yml b/.github/workflows/dast-smoke.yml index 08a622fdbb..2c00beae7d 100644 --- a/.github/workflows/dast-smoke.yml +++ b/.github/workflows/dast-smoke.yml @@ -27,6 +27,8 @@ jobs: cache: npm - run: npm ci - name: Build CLI bundle + env: + OMNIROUTE_BUILD_BACKEND_ONLY: "1" run: npm run build:cli - name: Start OmniRoute env: diff --git a/.github/workflows/nightly-llm-security.yml b/.github/workflows/nightly-llm-security.yml index 6a9abac294..a879258919 100644 --- a/.github/workflows/nightly-llm-security.yml +++ b/.github/workflows/nightly-llm-security.yml @@ -19,7 +19,9 @@ jobs: with: { node-version: "24", cache: npm } - run: npm ci - name: Build CLI bundle - env: { JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation } + env: + JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation + OMNIROUTE_BUILD_BACKEND_ONLY: "1" run: npm run build:cli - name: Start OmniRoute (block mode) env: @@ -72,7 +74,9 @@ jobs: if: steps.gate.outputs.run == 'true' - name: Build CLI bundle if: steps.gate.outputs.run == 'true' - env: { JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation } + env: + JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation + OMNIROUTE_BUILD_BACKEND_ONLY: "1" run: npm run build:cli - name: Start OmniRoute if: steps.gate.outputs.run == 'true' diff --git a/.github/workflows/nightly-resilience.yml b/.github/workflows/nightly-resilience.yml index f6adffeabf..c98c6df7b1 100644 --- a/.github/workflows/nightly-resilience.yml +++ b/.github/workflows/nightly-resilience.yml @@ -51,6 +51,7 @@ jobs: - name: Build CLI bundle env: JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation + OMNIROUTE_BUILD_BACKEND_ONLY: "1" run: npm run build:cli - name: Start OmniRoute (background) env: diff --git a/.github/workflows/nightly-schemathesis.yml b/.github/workflows/nightly-schemathesis.yml index 64adef8f50..2ef0cea8d0 100644 --- a/.github/workflows/nightly-schemathesis.yml +++ b/.github/workflows/nightly-schemathesis.yml @@ -20,7 +20,9 @@ jobs: with: { node-version: "24", cache: npm } - run: npm ci - name: Build CLI bundle - env: { JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation } + env: + JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation + OMNIROUTE_BUILD_BACKEND_ONLY: "1" run: npm run build:cli - name: Start OmniRoute (background) env: diff --git a/changelog.d/fixes/7226-dast-smoke-backend-only.md b/changelog.d/fixes/7226-dast-smoke-backend-only.md new file mode 100644 index 0000000000..63d2ae9ef0 --- /dev/null +++ b/changelog.d/fixes/7226-dast-smoke-backend-only.md @@ -0,0 +1 @@ +- fix(ci): build dast-smoke and nightly API-only smoke workflows with `OMNIROUTE_BUILD_BACKEND_ONLY=1` to skip the unused dashboard UI graph and stop the multi-minute build variance/timeouts (#7226) diff --git a/tests/unit/build/backend-only-smoke-workflows.test.ts b/tests/unit/build/backend-only-smoke-workflows.test.ts new file mode 100644 index 0000000000..c7975c17b6 --- /dev/null +++ b/tests/unit/build/backend-only-smoke-workflows.test.ts @@ -0,0 +1,88 @@ +// Regression guard for #7226: API-only smoke/nightly workflows must build with +// OMNIROUTE_BUILD_BACKEND_ONLY=1 so `npm run build:cli`'s fallback full build +// (scripts/build/prepublish.ts -> build-next-isolated.mjs) skips the ~126-leaf-page +// dashboard UI graph these workflows never exercise. Without this env var, the +// "Build CLI bundle" step silently runs a full Next.js production build inline, +// which is the actual source of the multi-minute variance/timeouts reported in #7226. +// +// npm-publish.yml is intentionally excluded: its "Build CLI bundle (standalone app)" +// step legitimately ships the full dashboard UI in the published npm package. +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import * as yaml from "js-yaml"; + +interface WorkflowStep { + name?: string; + run?: string; + env?: Record; + [key: string]: unknown; +} + +interface WorkflowJob { + steps: WorkflowStep[]; + [key: string]: unknown; +} + +interface WorkflowDoc { + jobs: Record; + [key: string]: unknown; +} + +const WORKFLOWS_DIR = path.join(process.cwd(), ".github", "workflows"); + +function loadWorkflow(fileName: string): WorkflowDoc { + const raw = fs.readFileSync(path.join(WORKFLOWS_DIR, fileName), "utf8"); + return yaml.load(raw) as WorkflowDoc; +} + +function isBackendOnly(step: WorkflowStep): boolean { + const env = step.env || {}; + return env.OMNIROUTE_BUILD_BACKEND_ONLY === "1" || env.OMNIROUTE_BUILD_PROFILE === "backend"; +} + +// jobName: null selector means "any job" — used when a file has exactly one +// "Build CLI bundle" step but we don't want to hardcode/duplicate the job key. +interface Target { + file: string; + jobName: string; + stepName: string; +} + +const TARGETS: Target[] = [ + { file: "dast-smoke.yml", jobName: "dast-smoke", stepName: "Build CLI bundle" }, + { file: "nightly-schemathesis.yml", jobName: "schemathesis", stepName: "Build CLI bundle" }, + { file: "nightly-resilience.yml", jobName: "k6-soak", stepName: "Build CLI bundle" }, + { file: "nightly-llm-security.yml", jobName: "promptfoo-guard", stepName: "Build CLI bundle" }, + { file: "nightly-llm-security.yml", jobName: "garak", stepName: "Build CLI bundle" }, +]; + +for (const { file, jobName, stepName } of TARGETS) { + test(`${file} :: ${jobName} '${stepName}' step sets OMNIROUTE_BUILD_BACKEND_ONLY=1 (skips dashboard UI build the API-only smoke job never exercises)`, () => { + const doc = loadWorkflow(file); + const job = doc.jobs[jobName]; + assert.ok(job, `${file} must have a '${jobName}' job`); + const step = job.steps.find((s) => s.name === stepName); + assert.ok(step, `${file}'s '${jobName}' job must have a '${stepName}' step`); + assert.equal( + isBackendOnly(step), + true, + `${file}'s '${jobName}' -> '${stepName}' step must set OMNIROUTE_BUILD_BACKEND_ONLY=1 or OMNIROUTE_BUILD_PROFILE=backend` + ); + }); +} + +test("npm-publish.yml 'Build CLI bundle (standalone app)' step must NOT be backend-only (it legitimately ships the full dashboard UI)", () => { + const doc = loadWorkflow("npm-publish.yml"); + const publishJob = Object.values(doc.jobs).find((job) => + job.steps.some((s) => s.name === "Build CLI bundle (standalone app)") + ); + assert.ok(publishJob, "npm-publish.yml must have a job with a 'Build CLI bundle (standalone app)' step"); + const step = publishJob!.steps.find((s) => s.name === "Build CLI bundle (standalone app)")!; + assert.equal( + isBackendOnly(step), + false, + "npm-publish.yml's build step must ship the full dashboard UI, not the backend-only stub" + ); +}); From f7e88f479257d933dd82a5efd4aedf7317233d57 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:55:38 -0300 Subject: [PATCH 19/22] fix(cli): split outboundUrlGuard's DB helpers so setup-opencode packages cleanly (#7682) (#7760) --- .../fixes/7682-opencode-shared-alias.md | 1 + src/app/api/provider-nodes/urlGuard.ts | 2 +- src/app/api/provider-nodes/validate/route.ts | 2 +- .../[id]/models/discovery/normalizers.ts | 2 +- src/app/api/providers/[id]/models/route.ts | 2 +- src/app/api/proxy-fallback/test/route.ts | 3 +- src/app/api/webhooks/[id]/route.ts | 2 +- src/app/api/webhooks/[id]/test/route.ts | 7 +- src/app/api/webhooks/route.ts | 2 +- src/app/api/webhooks/validate-url/route.ts | 6 +- src/lib/providers/imageValidation.ts | 2 +- src/lib/providers/validation.ts | 2 +- src/lib/providers/validation/headers.ts | 2 +- .../providers/validation/searchProviders.ts | 2 +- src/lib/providers/validation/transport.ts | 3 +- src/lib/webhookDispatcher.ts | 2 +- src/shared/network/outboundUrlGuard.ts | 116 ++--------------- src/shared/network/outboundUrlGuardPolicy.ts | 123 ++++++++++++++++++ src/shared/network/remoteImageFetch.ts | 2 +- ...i-setup-opencode-nested-alias-7682.test.ts | 42 ++++++ .../outbound-url-guard-feature-flag.test.ts | 8 +- .../provider-models-route-lan-guard.test.ts | 3 +- .../unit/webhook-metadata-guard-3269.test.ts | 5 +- tests/unit/webhook-private-optin-3269.test.ts | 5 +- 24 files changed, 207 insertions(+), 139 deletions(-) create mode 100644 changelog.d/fixes/7682-opencode-shared-alias.md create mode 100644 src/shared/network/outboundUrlGuardPolicy.ts create mode 100644 tests/unit/cli-setup-opencode-nested-alias-7682.test.ts diff --git a/changelog.d/fixes/7682-opencode-shared-alias.md b/changelog.d/fixes/7682-opencode-shared-alias.md new file mode 100644 index 0000000000..61d486efe5 --- /dev/null +++ b/changelog.d/fixes/7682-opencode-shared-alias.md @@ -0,0 +1 @@ +- fix(cli): split `outboundUrlGuard.ts`'s DB/feature-flag helpers into `outboundUrlGuardPolicy.ts` so `omniroute setup-opencode` no longer crashes with `Cannot find package '@/shared'` on a global npm install (#7682) diff --git a/src/app/api/provider-nodes/urlGuard.ts b/src/app/api/provider-nodes/urlGuard.ts index 683aab6495..e98b82f2dd 100644 --- a/src/app/api/provider-nodes/urlGuard.ts +++ b/src/app/api/provider-nodes/urlGuard.ts @@ -1,11 +1,11 @@ import { NextResponse } from "next/server"; import { OutboundUrlGuardError, - getProviderValidationGuard, parseAndValidateNonMetadataUrl, parseAndValidatePublicUrl, parseOutboundUrl, } from "@/shared/network/outboundUrlGuard"; +import { getProviderValidationGuard } from "@/shared/network/outboundUrlGuardPolicy"; function guardProviderNodeBaseUrl(baseUrl: string): void { const guard = getProviderValidationGuard(); diff --git a/src/app/api/provider-nodes/validate/route.ts b/src/app/api/provider-nodes/validate/route.ts index ffda9aa564..b864f27130 100644 --- a/src/app/api/provider-nodes/validate/route.ts +++ b/src/app/api/provider-nodes/validate/route.ts @@ -8,7 +8,7 @@ import { getSafeOutboundFetchErrorStatus, safeOutboundFetch, } from "@/shared/network/safeOutboundFetch"; -import { getProviderValidationGuard } from "@/shared/network/outboundUrlGuard"; +import { getProviderValidationGuard } from "@/shared/network/outboundUrlGuardPolicy"; import { isCcCompatibleProviderEnabled } from "@/shared/utils/featureFlags"; import { providerNodeValidateSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; diff --git a/src/app/api/providers/[id]/models/discovery/normalizers.ts b/src/app/api/providers/[id]/models/discovery/normalizers.ts index b141de5b71..a95c5d437f 100644 --- a/src/app/api/providers/[id]/models/discovery/normalizers.ts +++ b/src/app/api/providers/[id]/models/discovery/normalizers.ts @@ -1,5 +1,5 @@ import { SAFE_OUTBOUND_FETCH_PRESETS, safeOutboundFetch } from "@/shared/network/safeOutboundFetch"; -import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuard"; +import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuardPolicy"; import { getAntigravityModelsDiscoveryUrls, getAntigravityFetchAvailableModelsUrls, diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index 8bff5e2b49..728269a8fa 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -25,7 +25,7 @@ import { import { getProviderOutboundGuard, getProviderValidationGuard, -} from "@/shared/network/outboundUrlGuard"; +} from "@/shared/network/outboundUrlGuardPolicy"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; import { getStaticQoderModels } from "@omniroute/open-sse/services/qoderCli.ts"; import { deriveConfigFromRegistryModelsUrl } from "./discoveryConfig"; diff --git a/src/app/api/proxy-fallback/test/route.ts b/src/app/api/proxy-fallback/test/route.ts index bfd8d8235e..ed1bc8b66c 100644 --- a/src/app/api/proxy-fallback/test/route.ts +++ b/src/app/api/proxy-fallback/test/route.ts @@ -11,7 +11,8 @@ import { z } from "zod"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; -import { isPrivateHost, arePrivateProviderUrlsAllowed } from "@/shared/network/outboundUrlGuard"; +import { isPrivateHost } from "@/shared/network/outboundUrlGuard"; +import { arePrivateProviderUrlsAllowed } from "@/shared/network/outboundUrlGuardPolicy"; import { testProxiesAgainstTarget, getProxyCandidates, diff --git a/src/app/api/webhooks/[id]/route.ts b/src/app/api/webhooks/[id]/route.ts index b9c5935858..5490da1ed4 100644 --- a/src/app/api/webhooks/[id]/route.ts +++ b/src/app/api/webhooks/[id]/route.ts @@ -13,7 +13,7 @@ import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { encryptMetadata } from "@/lib/webhookDispatcher"; import { isEncryptionEnabled } from "@/lib/db/encryption"; -import { parseAndValidateWebhookUrl } from "@/shared/network/outboundUrlGuard"; +import { parseAndValidateWebhookUrl } from "@/shared/network/outboundUrlGuardPolicy"; const WEBHOOK_KINDS = ["slack", "telegram", "discord", "custom"] as const; const WEBHOOK_EVENT_VALUES = [ diff --git a/src/app/api/webhooks/[id]/test/route.ts b/src/app/api/webhooks/[id]/test/route.ts index 6debd9a14a..7e81f9e9c6 100644 --- a/src/app/api/webhooks/[id]/test/route.ts +++ b/src/app/api/webhooks/[id]/test/route.ts @@ -13,11 +13,8 @@ import { buildDiscordPayload } from "@/lib/webhooks/integrations/discord"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { insertDelivery } from "@/lib/db/webhookDeliveries"; import { recordWebhookDelivery } from "@/lib/localDb"; -import { - parseAndValidateWebhookUrl, - isPrivateHost, - OutboundUrlGuardError, -} from "@/shared/network/outboundUrlGuard"; +import { isPrivateHost, OutboundUrlGuardError } from "@/shared/network/outboundUrlGuard"; +import { parseAndValidateWebhookUrl } from "@/shared/network/outboundUrlGuardPolicy"; import crypto from "crypto"; const MAX_RESPONSE_BODY = 2048; diff --git a/src/app/api/webhooks/route.ts b/src/app/api/webhooks/route.ts index ab5990749e..95047f3aa0 100644 --- a/src/app/api/webhooks/route.ts +++ b/src/app/api/webhooks/route.ts @@ -12,7 +12,7 @@ import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { encryptMetadata } from "@/lib/webhookDispatcher"; import { isEncryptionEnabled } from "@/lib/db/encryption"; -import { parseAndValidateWebhookUrl } from "@/shared/network/outboundUrlGuard"; +import { parseAndValidateWebhookUrl } from "@/shared/network/outboundUrlGuardPolicy"; const WEBHOOK_KINDS = ["slack", "telegram", "discord", "custom"] as const; diff --git a/src/app/api/webhooks/validate-url/route.ts b/src/app/api/webhooks/validate-url/route.ts index a58f1f7fd6..ae8ee29d1e 100644 --- a/src/app/api/webhooks/validate-url/route.ts +++ b/src/app/api/webhooks/validate-url/route.ts @@ -6,10 +6,8 @@ import { z } from "zod"; import { NextResponse } from "next/server"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; -import { - parseAndValidateWebhookUrl, - OutboundUrlGuardError, -} from "@/shared/network/outboundUrlGuard"; +import { OutboundUrlGuardError } from "@/shared/network/outboundUrlGuard"; +import { parseAndValidateWebhookUrl } from "@/shared/network/outboundUrlGuardPolicy"; import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; const validateUrlSchema = z.object({ diff --git a/src/lib/providers/imageValidation.ts b/src/lib/providers/imageValidation.ts index de5057acda..444517a608 100644 --- a/src/lib/providers/imageValidation.ts +++ b/src/lib/providers/imageValidation.ts @@ -1,6 +1,6 @@ import { getImageProvider } from "@omniroute/open-sse/config/imageRegistry"; -import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuard"; +import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuardPolicy"; import { SAFE_OUTBOUND_FETCH_PRESETS, SafeOutboundFetchError, diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index 2a34969086..dbcd2041a2 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -11,7 +11,7 @@ import { WEB_COOKIE_PROVIDERS, } from "@/shared/constants/providers"; import { SAFE_OUTBOUND_FETCH_PRESETS, safeOutboundFetch } from "@/shared/network/safeOutboundFetch"; -import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuard"; +import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuardPolicy"; import { resolveNvidiaValidationModel } from "@/lib/providers/nvidiaValidationModel"; import { MODAL_DEFAULT_VALIDATION_MODEL_ID } from "@/shared/constants/modal"; import { validateQoderCliPat } from "@omniroute/open-sse/services/qoderCli.ts"; diff --git a/src/lib/providers/validation/headers.ts b/src/lib/providers/validation/headers.ts index aabc65339e..a5a3a13098 100644 --- a/src/lib/providers/validation/headers.ts +++ b/src/lib/providers/validation/headers.ts @@ -2,7 +2,7 @@ // from validation.ts (god-file decomposition). Pure header construction except directHttpsRequest, // which delegates to safeOutboundFetch with bypassProxyPatch. Behavior is byte-identical. import { safeOutboundFetch } from "@/shared/network/safeOutboundFetch"; -import { getProviderValidationGuard } from "@/shared/network/outboundUrlGuard"; +import { getProviderValidationGuard } from "@/shared/network/outboundUrlGuardPolicy"; // Standardized desktop Chrome UA for web-cookie/no-auth session probes (minimizes anti-bot detection). export const STANDARD_USER_AGENT = diff --git a/src/lib/providers/validation/searchProviders.ts b/src/lib/providers/validation/searchProviders.ts index b49f77ed63..ecfa79b594 100644 --- a/src/lib/providers/validation/searchProviders.ts +++ b/src/lib/providers/validation/searchProviders.ts @@ -2,7 +2,7 @@ // …). Extracted from validation.ts (god-file decomposition) — top-level functions/data with no // dispatcher-state captures; behavior is byte-identical to the original inline defs. import { SAFE_OUTBOUND_FETCH_PRESETS, safeOutboundFetch } from "@/shared/network/safeOutboundFetch"; -import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuard"; +import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuardPolicy"; import { withCustomUserAgent } from "./headers"; import { toValidationErrorResult, validationWrite } from "./transport"; diff --git a/src/lib/providers/validation/transport.ts b/src/lib/providers/validation/transport.ts index 08a1d97600..8fb49af5ce 100644 --- a/src/lib/providers/validation/transport.ts +++ b/src/lib/providers/validation/transport.ts @@ -7,7 +7,8 @@ import { getSafeOutboundFetchErrorStatus, safeOutboundFetch, } from "@/shared/network/safeOutboundFetch"; -import { getProviderValidationGuard, isPrivateHost } from "@/shared/network/outboundUrlGuard"; +import { isPrivateHost } from "@/shared/network/outboundUrlGuard"; +import { getProviderValidationGuard } from "@/shared/network/outboundUrlGuardPolicy"; import { selectProxyForValidation } from "@omniroute/open-sse/services/proxyAutoSelector.ts"; /** diff --git a/src/lib/webhookDispatcher.ts b/src/lib/webhookDispatcher.ts index 09165bcbbe..9902653c83 100644 --- a/src/lib/webhookDispatcher.ts +++ b/src/lib/webhookDispatcher.ts @@ -6,7 +6,7 @@ import crypto from "crypto"; import { encrypt, decrypt } from "./db/encryption"; -import { parseAndValidateWebhookUrl } from "@/shared/network/outboundUrlGuard"; +import { parseAndValidateWebhookUrl } from "@/shared/network/outboundUrlGuardPolicy"; import type { WebhookEvent } from "./webhooks/eventDescriptions"; export type { WebhookEvent }; diff --git a/src/shared/network/outboundUrlGuard.ts b/src/shared/network/outboundUrlGuard.ts index 1f51145c9d..8a63de5be7 100644 --- a/src/shared/network/outboundUrlGuard.ts +++ b/src/shared/network/outboundUrlGuard.ts @@ -1,16 +1,7 @@ import { isIP } from "node:net"; -import { resolveFeatureFlag } from "@/shared/utils/featureFlags"; - -const TRUE_ENV_VALUES = new Set(["1", "true", "yes", "on"]); export const PROVIDER_URL_BLOCKED_MESSAGE = "Blocked private or local provider URL"; export const CLOUD_METADATA_BLOCKED_MESSAGE = "Blocked cloud-metadata endpoint"; -export const PRIVATE_PROVIDER_URLS_ENV = "OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS"; -// #5066: scoped to provider validation/use. Allows local/private provider endpoints -// (127.0.0.1, localhost, LAN) so local-first OpenAI-compatible providers validate, while -// cloud-metadata endpoints stay blocked. Defaults ON (OmniRoute is local-first); operators -// who only use public providers can disable it to restore strict SSRF blocking. -export const LOCAL_PROVIDER_URLS_ENV = "OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS"; // "block-metadata": allow private/LAN hosts but still reject cloud-metadata / link-local // endpoints (the SSRF→IAM-credential pivot). Used by the provider-validation path under the @@ -175,102 +166,11 @@ export function parseAndValidateNonMetadataUrl(input: string | URL) { return url; } -/** - * Webhook variant of {@link parseAndValidatePublicUrl}. Webhooks legitimately point at - * internal services (n8n, Home Assistant, a LAN box) in Docker/self-hosted deployments, - * so the private-host block is gated behind the same explicit opt-in used for private - * provider URLs (`OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS`, default OFF). Protocol and - * embedded-credential checks in {@link parseOutboundUrl} remain unconditional. (#3269) - */ -export function parseAndValidateWebhookUrl(input: string | URL) { - const url = parseOutboundUrl(input); - - // Cloud-metadata / link-local endpoints are NEVER a valid webhook target — block them - // even when the private opt-in is enabled (SSRF→IAM-credential pivot). (#3269) - if (isCloudMetadataHost(url.hostname)) { - throw new OutboundUrlGuardError(PROVIDER_URL_BLOCKED_MESSAGE, { - code: "OUTBOUND_URL_GUARD_BLOCKED", - url: url.toString(), - hostname: url.hostname || null, - }); - } - - if (!arePrivateProviderUrlsAllowed() && isPrivateHost(url.hostname)) { - throw new OutboundUrlGuardError(PROVIDER_URL_BLOCKED_MESSAGE, { - code: "OUTBOUND_URL_GUARD_BLOCKED", - url: url.toString(), - hostname: url.hostname || null, - }); - } - - return url; -} - -function isTrueValue(raw: unknown): boolean { - if (typeof raw !== "string") return false; - return TRUE_ENV_VALUES.has(raw.trim().toLowerCase()); -} - -export function arePrivateProviderUrlsAllowed() { - // 1) DB override takes precedence — it represents an explicit user toggle in - // the dashboard ("Allow Private Provider URLs"). This is critical for the - // Electron build (#2575) where the server is spawned with the env value - // captured at boot, so subsequent UI toggles only land in the DB and the - // env-first ordering would otherwise mask them. - try { - const dbValue = resolveFeatureFlag(PRIVATE_PROVIDER_URLS_ENV); - if (isTrueValue(dbValue)) return true; - } catch { - // DB not initialized yet — fall through to env-only check. - } - - // 2) Explicit env opt-in (for headless/Docker users who set it before boot). - if (isTrueValue(process.env[PRIVATE_PROVIDER_URLS_ENV])) return true; - - // 3) Legacy escape hatch — disabling the outbound guard implies allowing - // private URLs. - const legacyValue = process.env["OUTBOUND_SSRF_GUARD_ENABLED"]; - if ( - typeof legacyValue === "string" && - ["false", "0", "no", "off"].includes(legacyValue.trim().toLowerCase()) - ) { - return true; - } - - return false; -} - -export function getProviderOutboundGuard(): OutboundUrlGuardMode { - return arePrivateProviderUrlsAllowed() ? "none" : "public-only"; -} - -/** - * #5066: whether provider endpoints on local/private addresses are permitted. Defaults ON - * (OmniRoute is local-first — local OpenAI-compatible providers should validate out of the - * box). Disable via the `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` flag (DB toggle or env) to - * restore strict public-only SSRF blocking. Cloud-metadata stays blocked regardless. - */ -export function areLocalProviderUrlsAllowed(): boolean { - try { - const dbValue = resolveFeatureFlag(LOCAL_PROVIDER_URLS_ENV); - if (dbValue !== undefined && dbValue !== "") return isTrueValue(dbValue); - } catch { - // DB not initialized yet — fall through to env / default. - } - const envValue = process.env[LOCAL_PROVIDER_URLS_ENV]; - if (typeof envValue === "string" && envValue !== "") return isTrueValue(envValue); - // Default ON. - return true; -} - -/** - * Guard mode for the provider VALIDATION/use path (not webhooks or remote images). Precedence: - * 1. explicit full opt-in (`arePrivateProviderUrlsAllowed`) → "none" (no checks; power users). - * 2. local-first default (`areLocalProviderUrlsAllowed`) → "block-metadata" (allow LAN, block IMDS). - * 3. otherwise → "public-only" (strict). - */ -export function getProviderValidationGuard(): OutboundUrlGuardMode { - if (arePrivateProviderUrlsAllowed()) return "none"; - if (areLocalProviderUrlsAllowed()) return "block-metadata"; - return "public-only"; -} +// NOTE (#7682): `arePrivateProviderUrlsAllowed`, `areLocalProviderUrlsAllowed`, +// `getProviderOutboundGuard`, `getProviderValidationGuard`, and `parseAndValidateWebhookUrl` +// live in the sibling `./outboundUrlGuardPolicy.ts` module, NOT here. Those helpers need +// `@/shared/utils/featureFlags` (which transitively pulls in the DB layer), and this file is +// loaded by the packaged CLI (`omniroute setup-opencode` → cli-helper/config-generator/ +// opencode.ts) where no `tsconfig.json` is present to resolve the `@/*` path alias. Keeping +// this module free of ANY `@/`-aliased import is what makes it safe to load from the CLI. +// Do not add a `@/`-aliased import here — see docs/security/… (packaging) and #7682. diff --git a/src/shared/network/outboundUrlGuardPolicy.ts b/src/shared/network/outboundUrlGuardPolicy.ts new file mode 100644 index 0000000000..cf32a0c612 --- /dev/null +++ b/src/shared/network/outboundUrlGuardPolicy.ts @@ -0,0 +1,123 @@ +import { resolveFeatureFlag } from "@/shared/utils/featureFlags"; +import { + OutboundUrlGuardError, + PROVIDER_URL_BLOCKED_MESSAGE, + isCloudMetadataHost, + isPrivateHost, + parseOutboundUrl, + type OutboundUrlGuardMode, +} from "./outboundUrlGuard"; + +// #7682: this module is the DB/feature-flag-backed half of the outbound URL guard, split out +// of `./outboundUrlGuard.ts` so the CLI (`omniroute setup-opencode`, loaded via tsx with no +// tsconfig.json in a global npm install) never has to resolve the `@/` alias. Only Next.js / +// webpack-bundled server code (never the CLI) should import from here. + +const TRUE_ENV_VALUES = new Set(["1", "true", "yes", "on"]); + +export const PRIVATE_PROVIDER_URLS_ENV = "OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS"; +// #5066: scoped to provider validation/use. Allows local/private provider endpoints +// (127.0.0.1, localhost, LAN) so local-first OpenAI-compatible providers validate, while +// cloud-metadata endpoints stay blocked. Defaults ON (OmniRoute is local-first); operators +// who only use public providers can disable it to restore strict SSRF blocking. +export const LOCAL_PROVIDER_URLS_ENV = "OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS"; + +function isTrueValue(raw: unknown): boolean { + if (typeof raw !== "string") return false; + return TRUE_ENV_VALUES.has(raw.trim().toLowerCase()); +} + +export function arePrivateProviderUrlsAllowed() { + // 1) DB override takes precedence — it represents an explicit user toggle in + // the dashboard ("Allow Private Provider URLs"). This is critical for the + // Electron build (#2575) where the server is spawned with the env value + // captured at boot, so subsequent UI toggles only land in the DB and the + // env-first ordering would otherwise mask them. + try { + const dbValue = resolveFeatureFlag(PRIVATE_PROVIDER_URLS_ENV); + if (isTrueValue(dbValue)) return true; + } catch { + // DB not initialized yet — fall through to env-only check. + } + + // 2) Explicit env opt-in (for headless/Docker users who set it before boot). + if (isTrueValue(process.env[PRIVATE_PROVIDER_URLS_ENV])) return true; + + // 3) Legacy escape hatch — disabling the outbound guard implies allowing + // private URLs. + const legacyValue = process.env["OUTBOUND_SSRF_GUARD_ENABLED"]; + if ( + typeof legacyValue === "string" && + ["false", "0", "no", "off"].includes(legacyValue.trim().toLowerCase()) + ) { + return true; + } + + return false; +} + +export function getProviderOutboundGuard(): OutboundUrlGuardMode { + return arePrivateProviderUrlsAllowed() ? "none" : "public-only"; +} + +/** + * #5066: whether provider endpoints on local/private addresses are permitted. Defaults ON + * (OmniRoute is local-first — local OpenAI-compatible providers should validate out of the + * box). Disable via the `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` flag (DB toggle or env) to + * restore strict public-only SSRF blocking. Cloud-metadata stays blocked regardless. + */ +export function areLocalProviderUrlsAllowed(): boolean { + try { + const dbValue = resolveFeatureFlag(LOCAL_PROVIDER_URLS_ENV); + if (dbValue !== undefined && dbValue !== "") return isTrueValue(dbValue); + } catch { + // DB not initialized yet — fall through to env / default. + } + const envValue = process.env[LOCAL_PROVIDER_URLS_ENV]; + if (typeof envValue === "string" && envValue !== "") return isTrueValue(envValue); + // Default ON. + return true; +} + +/** + * Guard mode for the provider VALIDATION/use path (not webhooks or remote images). Precedence: + * 1. explicit full opt-in (`arePrivateProviderUrlsAllowed`) → "none" (no checks; power users). + * 2. local-first default (`areLocalProviderUrlsAllowed`) → "block-metadata" (allow LAN, block IMDS). + * 3. otherwise → "public-only" (strict). + */ +export function getProviderValidationGuard(): OutboundUrlGuardMode { + if (arePrivateProviderUrlsAllowed()) return "none"; + if (areLocalProviderUrlsAllowed()) return "block-metadata"; + return "public-only"; +} + +/** + * Webhook variant of `parseAndValidatePublicUrl`. Webhooks legitimately point at + * internal services (n8n, Home Assistant, a LAN box) in Docker/self-hosted deployments, + * so the private-host block is gated behind the same explicit opt-in used for private + * provider URLs (`OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS`, default OFF). Protocol and + * embedded-credential checks in `parseOutboundUrl` remain unconditional. (#3269) + */ +export function parseAndValidateWebhookUrl(input: string | URL) { + const url = parseOutboundUrl(input); + + // Cloud-metadata / link-local endpoints are NEVER a valid webhook target — block them + // even when the private opt-in is enabled (SSRF→IAM-credential pivot). (#3269) + if (isCloudMetadataHost(url.hostname)) { + throw new OutboundUrlGuardError(PROVIDER_URL_BLOCKED_MESSAGE, { + code: "OUTBOUND_URL_GUARD_BLOCKED", + url: url.toString(), + hostname: url.hostname || null, + }); + } + + if (!arePrivateProviderUrlsAllowed() && isPrivateHost(url.hostname)) { + throw new OutboundUrlGuardError(PROVIDER_URL_BLOCKED_MESSAGE, { + code: "OUTBOUND_URL_GUARD_BLOCKED", + url: url.toString(), + hostname: url.hostname || null, + }); + } + + return url; +} diff --git a/src/shared/network/remoteImageFetch.ts b/src/shared/network/remoteImageFetch.ts index eaa778dff2..e05f32b581 100644 --- a/src/shared/network/remoteImageFetch.ts +++ b/src/shared/network/remoteImageFetch.ts @@ -2,11 +2,11 @@ import { isIP } from "node:net"; import dns from "node:dns"; import { type OutboundUrlGuardMode, - getProviderOutboundGuard, isPrivateHost, parseAndValidatePublicUrl, parseOutboundUrl, } from "@/shared/network/outboundUrlGuard"; +import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuardPolicy"; const DEFAULT_MAX_REMOTE_IMAGE_BYTES = 20 * 1024 * 1024; const DEFAULT_MAX_REDIRECTS = 3; diff --git a/tests/unit/cli-setup-opencode-nested-alias-7682.test.ts b/tests/unit/cli-setup-opencode-nested-alias-7682.test.ts new file mode 100644 index 0000000000..97bfdc6c8f --- /dev/null +++ b/tests/unit/cli-setup-opencode-nested-alias-7682.test.ts @@ -0,0 +1,42 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, cpSync, symlinkSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(HERE, "..", ".."); + +test("config-generator/opencode.ts imports cleanly with no tsconfig.json in scope (repro #7682)", () => { + const stage = mkdtempSync(join(tmpdir(), "omniroute-pkg-stage-7682-")); + try { + for (const rel of ["bin", "src/lib", "src/shared"]) { + cpSync(join(REPO_ROOT, rel), join(stage, rel), { recursive: true }); + } + cpSync(join(REPO_ROOT, "package.json"), join(stage, "package.json")); + symlinkSync(join(REPO_ROOT, "node_modules"), join(stage, "node_modules"), "dir"); + + const probeScript = join(stage, "probe-import.mjs"); + writeFileSync( + probeScript, + `await import("tsx/esm"); + await import("./src/lib/cli-helper/config-generator/opencode.ts"); + console.log("IMPORT_OK"); + ` + ); + + const result = spawnSync(process.execPath, [probeScript], { cwd: stage, encoding: "utf8" }); + + assert.equal( + result.stdout.includes("IMPORT_OK"), + true, + `expected config-generator/opencode.ts to import cleanly from a tsconfig-less ` + + `directory (as it will inside a real global npm install), but it failed:\n` + + `stdout: ${result.stdout}\nstderr: ${result.stderr}` + ); + } finally { + rmSync(stage, { recursive: true, force: true }); + } +}); diff --git a/tests/unit/outbound-url-guard-feature-flag.test.ts b/tests/unit/outbound-url-guard-feature-flag.test.ts index bf069f1dc5..a58dacb68a 100644 --- a/tests/unit/outbound-url-guard-feature-flag.test.ts +++ b/tests/unit/outbound-url-guard-feature-flag.test.ts @@ -39,7 +39,7 @@ test("arePrivateProviderUrlsAllowed honors DB override = 'true' even when env is await withEnv("false", async () => { await withDbOverride("true", async () => { const { arePrivateProviderUrlsAllowed } = - await import("../../src/shared/network/outboundUrlGuard.ts"); + await import("../../src/shared/network/outboundUrlGuardPolicy.ts"); assert.equal( arePrivateProviderUrlsAllowed(), true, @@ -53,7 +53,7 @@ test("arePrivateProviderUrlsAllowed returns false when DB override = 'false' and await withEnv(undefined, async () => { await withDbOverride("false", async () => { const { arePrivateProviderUrlsAllowed } = - await import("../../src/shared/network/outboundUrlGuard.ts"); + await import("../../src/shared/network/outboundUrlGuardPolicy.ts"); assert.equal(arePrivateProviderUrlsAllowed(), false); }); }); @@ -63,7 +63,7 @@ test("arePrivateProviderUrlsAllowed honors env = 'true' when DB has no override" await withEnv("true", async () => { await withDbOverride(undefined, async () => { const { arePrivateProviderUrlsAllowed } = - await import("../../src/shared/network/outboundUrlGuard.ts"); + await import("../../src/shared/network/outboundUrlGuardPolicy.ts"); assert.equal(arePrivateProviderUrlsAllowed(), true); }); }); @@ -73,7 +73,7 @@ test("arePrivateProviderUrlsAllowed default (no env, no DB) returns false", asyn await withEnv(undefined, async () => { await withDbOverride(undefined, async () => { const { arePrivateProviderUrlsAllowed } = - await import("../../src/shared/network/outboundUrlGuard.ts"); + await import("../../src/shared/network/outboundUrlGuardPolicy.ts"); assert.equal(arePrivateProviderUrlsAllowed(), false); }); }); diff --git a/tests/unit/provider-models-route-lan-guard.test.ts b/tests/unit/provider-models-route-lan-guard.test.ts index 276399904c..cc490785a9 100644 --- a/tests/unit/provider-models-route-lan-guard.test.ts +++ b/tests/unit/provider-models-route-lan-guard.test.ts @@ -19,6 +19,7 @@ const core = await import("../../src/lib/db/core.ts"); const providersDb = await import("../../src/lib/db/providers.ts"); const providerModelsRoute = await import("../../src/app/api/providers/[id]/models/route.ts"); const outboundUrlGuard = await import("../../src/shared/network/outboundUrlGuard.ts"); +const outboundUrlGuardPolicy = await import("../../src/shared/network/outboundUrlGuardPolicy.ts"); const originalFetch = globalThis.fetch; const originalAllowPrivateProviderUrls = process.env.OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS; @@ -77,7 +78,7 @@ test("#6939: getProviderOutboundGuard() and getProviderValidationGuard() agree f delete process.env.OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS; delete process.env.OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS; - const validationGuard = outboundUrlGuard.getProviderValidationGuard(); + const validationGuard = outboundUrlGuardPolicy.getProviderValidationGuard(); assert.equal(validationGuard, "block-metadata"); // The models route must resolve a guard for LAN-local model discovery that is at least as diff --git a/tests/unit/webhook-metadata-guard-3269.test.ts b/tests/unit/webhook-metadata-guard-3269.test.ts index ef3dd6171e..b08396d859 100644 --- a/tests/unit/webhook-metadata-guard-3269.test.ts +++ b/tests/unit/webhook-metadata-guard-3269.test.ts @@ -14,9 +14,12 @@ import path from "node:path"; process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omni-wh-meta-3269-")); -const { parseAndValidateWebhookUrl, isCloudMetadataHost, OutboundUrlGuardError } = await import( +const { isCloudMetadataHost, OutboundUrlGuardError } = await import( "../../src/shared/network/outboundUrlGuard.ts" ); +const { parseAndValidateWebhookUrl } = await import( + "../../src/shared/network/outboundUrlGuardPolicy.ts" +); const { resetDbInstance } = await import("../../src/lib/db/core.ts"); const FLAG = "OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS"; diff --git a/tests/unit/webhook-private-optin-3269.test.ts b/tests/unit/webhook-private-optin-3269.test.ts index 7edc8bb1e3..196b9ef337 100644 --- a/tests/unit/webhook-private-optin-3269.test.ts +++ b/tests/unit/webhook-private-optin-3269.test.ts @@ -14,8 +14,9 @@ import path from "node:path"; process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omni-wh-3269-")); -const { parseAndValidateWebhookUrl, OutboundUrlGuardError } = await import( - "../../src/shared/network/outboundUrlGuard.ts" +const { OutboundUrlGuardError } = await import("../../src/shared/network/outboundUrlGuard.ts"); +const { parseAndValidateWebhookUrl } = await import( + "../../src/shared/network/outboundUrlGuardPolicy.ts" ); const { resetDbInstance } = await import("../../src/lib/db/core.ts"); From 491f9472b8445158d62d141f3c6a4a2d861b22d8 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:55:44 -0300 Subject: [PATCH 20/22] fix(cli): load DATA_DIR/server.env as fallback for .env on Electron migration (#7302) (#7759) Electron persists secrets (JWT_SECRET, API_KEY_SECRET, STORAGE_ENCRYPTION_KEY) to /server.env, but the CLI bootstrap only ever loaded /.env. Copying storage.sqlite + server.env from an Electron install to the CLI (exactly as the app's own UI text instructs) silently lost STORAGE_ENCRYPTION_KEY, permanently corrupting every encrypted provider credential. bin/omniroute.mjs now does a one-time, one-directory migration: if /.env is absent but /server.env is present, copy it to .env before the normal env-file loading loop runs. An existing .env is never overwritten -- it always wins over a legacy server.env. --- bin/omniroute.mjs | 26 +++- changelog.d/fixes/7302-cli-electron-env.md | 1 + ...n-to-cli-migration-server-env-7302.test.ts | 114 ++++++++++++++++++ 3 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 changelog.d/fixes/7302-cli-electron-env.md create mode 100644 tests/unit/cli-electron-to-cli-migration-server-env-7302.test.ts diff --git a/bin/omniroute.mjs b/bin/omniroute.mjs index 4d6720e097..a683f917df 100755 --- a/bin/omniroute.mjs +++ b/bin/omniroute.mjs @@ -14,7 +14,7 @@ * All other commands are routed through Commander (bin/cli/program.mjs). */ -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import updateNotifier from "update-notifier"; @@ -59,6 +59,28 @@ if (process.argv.includes("--mcp")) { console.warn = stderrConsole.warn.bind(stderrConsole); } +// Electron persists secrets (JWT_SECRET, API_KEY_SECRET, STORAGE_ENCRYPTION_KEY) to +// `/server.env` (electron/main.js), never `.env`. Migrating an existing +// install (storage.sqlite + server.env) to the CLI left those secrets undiscoverable — +// the CLI only ever looked for `.env`, so the STORAGE_ENCRYPTION_KEY needed to decrypt +// the migrated database was silently dropped (#7302). One-time, one-directory migration: +// if `/.env` is absent but `/server.env` is present, copy it to `.env` +// so it flows through the normal env-loading path below. Never overwrites an existing +// `.env` — an explicit `.env` always wins over a legacy `server.env`. +function migrateElectronServerEnv(dataDir) { + try { + const envPath = join(dataDir, ".env"); + const serverEnvPath = join(dataDir, "server.env"); + if (existsSync(envPath) || !existsSync(serverEnvPath)) return; + writeFileSync(envPath, readFileSync(serverEnvPath, "utf-8"), "utf-8"); + console.log( + ` \x1b[2m♻ Migrated Electron secrets from ${serverEnvPath} to ${envPath}\x1b[0m` + ); + } catch { + // Ignore errors migrating server.env — fall back to normal env loading below. + } +} + function loadEnvFile() { const envPaths = []; const loadedEnvPaths = []; @@ -69,6 +91,8 @@ function loadEnvFile() { envPaths.push(envPath); }; + migrateElectronServerEnv(process.env.DATA_DIR || getDefaultDataDir()); + if (process.env.DATA_DIR) { addEnvPath(join(process.env.DATA_DIR, ".env")); } diff --git a/changelog.d/fixes/7302-cli-electron-env.md b/changelog.d/fixes/7302-cli-electron-env.md new file mode 100644 index 0000000000..63fbe89477 --- /dev/null +++ b/changelog.d/fixes/7302-cli-electron-env.md @@ -0,0 +1 @@ +- fix(cli): load DATA_DIR/server.env as a fallback for .env when migrating from Electron, so STORAGE_ENCRYPTION_KEY/JWT_SECRET/API_KEY_SECRET survive an Electron→CLI install migration (#7302) diff --git a/tests/unit/cli-electron-to-cli-migration-server-env-7302.test.ts b/tests/unit/cli-electron-to-cli-migration-server-env-7302.test.ts new file mode 100644 index 0000000000..5f48f1a7de --- /dev/null +++ b/tests/unit/cli-electron-to-cli-migration-server-env-7302.test.ts @@ -0,0 +1,114 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const BIN = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "..", + "bin", + "omniroute.mjs" +); + +function runCli(dataDir: string): { code: number | null; stdout: string; stderr: string } { + const cleanEnv = { ...process.env }; + delete cleanEnv.STORAGE_ENCRYPTION_KEY; + delete cleanEnv.JWT_SECRET; + delete cleanEnv.API_KEY_SECRET; + delete cleanEnv.DATA_DIR; + const isolatedHome = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-migration-home-")); + try { + const res = spawnSync("node", [BIN, "config", "list", "--json"], { + cwd: dataDir, + env: { + ...cleanEnv, + DATA_DIR: dataDir, + HOME: isolatedHome, + USERPROFILE: isolatedHome, + NO_UPDATE_NOTIFIER: "1", + OMNIROUTE_CLI_SKIP_REPO_ENV: "1", + }, + timeout: 60_000, + encoding: "utf-8", + }); + return { code: res.status, stdout: res.stdout ?? "", stderr: res.stderr ?? "" }; + } finally { + fs.rmSync(isolatedHome, { recursive: true, force: true }); + } +} + +// #7302: Electron persists secrets to /server.env (electron/main.js), but the CLI +// (bin/omniroute.mjs) only ever loaded /.env — so migrating storage.sqlite + +// server.env from the desktop app to the CLI silently lost STORAGE_ENCRYPTION_KEY and +// permanently corrupted every encrypted credential. The CLI must recognize server.env as a +// legacy/migration fallback source when .env is absent, without letting it override an +// existing .env. + +test("#7302: CLI must recognize DATA_DIR/server.env (Electron's secrets file) when migrating an existing database", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-migration-")); + try { + fs.writeFileSync(path.join(dir, "storage.sqlite"), "fake-existing-db-with-real-data"); + const electronKey = "electron-storage-key-0123456789abcdef0123456789abcdef"; + fs.writeFileSync( + path.join(dir, "server.env"), + [ + "JWT_SECRET=electron-jwt-secret-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "API_KEY_SECRET=electron-api-key-secret-bbbbbbbbbbbbbbbbbbbbbbbbbbbb", + `STORAGE_ENCRYPTION_KEY=${electronKey}`, + "STORAGE_ENCRYPTION_KEY_VERSION=v1", + "", + ].join("\n") + ); + + const { stderr } = runCli(dir); + + const envPath = path.join(dir, ".env"); + const envContent = fs.existsSync(envPath) ? fs.readFileSync(envPath, "utf-8") : ""; + assert.match( + envContent, + new RegExp(`STORAGE_ENCRYPTION_KEY=${electronKey}`), + "the Electron-persisted STORAGE_ENCRYPTION_KEY from server.env must be honored " + + "after migrating to the CLI install — got .env content: " + JSON.stringify(envContent) + ); + + assert.doesNotMatch( + stderr, + /STORAGE_ENCRYPTION_KEY is not set but a database already exists/, + "the CLI should not need to refuse key generation — it should have found the " + + "Electron-persisted key in server.env" + ); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test("#7302: an existing DATA_DIR/.env must still win over DATA_DIR/server.env when both exist", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-migration-winner-")); + try { + fs.writeFileSync(path.join(dir, "storage.sqlite"), "fake-existing-db-with-real-data"); + const cliKey = "cli-owned-storage-key-fedcba9876543210fedcba9876543210"; + const electronKey = "electron-storage-key-0123456789abcdef0123456789abcdef"; + fs.writeFileSync(path.join(dir, ".env"), `STORAGE_ENCRYPTION_KEY=${cliKey}\n`); + fs.writeFileSync(path.join(dir, "server.env"), `STORAGE_ENCRYPTION_KEY=${electronKey}\n`); + + runCli(dir); + + const envContent = fs.readFileSync(path.join(dir, ".env"), "utf-8"); + assert.match( + envContent, + new RegExp(`STORAGE_ENCRYPTION_KEY=${cliKey}`), + "an existing .env must never be overwritten by server.env" + ); + assert.doesNotMatch( + envContent, + new RegExp(electronKey), + "server.env must not leak into an existing .env" + ); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); From 07b2cf9b7ee90a52788e2f9885d1dcae755f7981 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 19 Jul 2026 10:25:01 -0300 Subject: [PATCH 21/22] test(ci): register 5 covering unit tests in stryker tap.testFiles (base-red unblock) Clears the release base-red where account-fallback-lockout-eviction, cliproxyapi-dedicated-credential-7645, combo-least-used-account, combo/recovery-hint and route-guard-forge-jcode-settings-local-only were covering mutated modules but missing from tap.testFiles. --- changelog.d/maintenance/stryker-register-5-covering-tests.md | 1 + stryker.conf.json | 5 +++++ 2 files changed, 6 insertions(+) create mode 100644 changelog.d/maintenance/stryker-register-5-covering-tests.md diff --git a/changelog.d/maintenance/stryker-register-5-covering-tests.md b/changelog.d/maintenance/stryker-register-5-covering-tests.md new file mode 100644 index 0000000000..b614fa9037 --- /dev/null +++ b/changelog.d/maintenance/stryker-register-5-covering-tests.md @@ -0,0 +1 @@ +- Register 5 covering unit tests (account-fallback lockout eviction, cliproxyapi dedicated credential #7645, combo least-used account, combo recovery-hint, route-guard forge/jcode local-only) in `stryker.conf.json` `tap.testFiles` to clear the release base-red. diff --git a/stryker.conf.json b/stryker.conf.json index d8354ffb1f..9c57ea40d9 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -43,6 +43,7 @@ "tap": { "testFiles": [ "tests/unit/account-fallback-anthropic-quota.test.ts", + "tests/unit/account-fallback-lockout-eviction.test.ts", "tests/unit/account-fallback-retry-after-json.test.ts", "tests/unit/account-fallback-route-restriction-403.test.ts", "tests/unit/account-fallback-service.test.ts", @@ -100,6 +101,9 @@ "tests/unit/circuit-breaker-failure-kind.test.ts", "tests/unit/circuit-breaker-registry-cap.test.ts", "tests/unit/circuit-breaker-stream-controller-4602.test.ts", + "tests/unit/cliproxyapi-dedicated-credential-7645.test.ts", + "tests/unit/combo-least-used-account.test.ts", + "tests/unit/combo/recovery-hint.test.ts", "tests/unit/ddg-circuit-breaker-null-content-6999-7000.test.ts", "tests/unit/claude-code-parity.test.ts", "tests/unit/claude-effort-suffix-strip.test.ts", @@ -231,6 +235,7 @@ "tests/unit/responses-handler.test.ts", "tests/unit/rotation-config-omniroute.test.ts", "tests/unit/route-explainability.test.ts", + "tests/unit/route-guard-forge-jcode-settings-local-only.test.ts", "tests/unit/route-guard-grok-build-settings-local-only.test.ts", "tests/unit/route-guard-middleware-local-only.test.ts", "tests/unit/route-guard-plugins-local-only.test.ts", From 3c30607d30af947ecd99736917897df71ade3d76 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 19 Jul 2026 11:14:56 -0300 Subject: [PATCH 22/22] fix(security): bump adm-zip >=0.6.0 + exact host matching in mitm DNS test (#7732) --- changelog.d/fixes/sec-adm-zip-codeql-host-match.md | 1 + package-lock.json | 8 ++++---- package.json | 3 ++- tests/unit/mitm-dns-graceful-degrade-6127.test.ts | 6 +++--- 4 files changed, 10 insertions(+), 8 deletions(-) create mode 100644 changelog.d/fixes/sec-adm-zip-codeql-host-match.md diff --git a/changelog.d/fixes/sec-adm-zip-codeql-host-match.md b/changelog.d/fixes/sec-adm-zip-codeql-host-match.md new file mode 100644 index 0000000000..e1b3a8f4dc --- /dev/null +++ b/changelog.d/fixes/sec-adm-zip-codeql-host-match.md @@ -0,0 +1 @@ +- fix(security): bump adm-zip to ^0.6.0 (dev transitive via promptfoo/onnxruntime-node) to clear the crafted-ZIP 4GB-allocation advisory, and tighten mitm DNS test host assertions to exact/suffix matching (CodeQL js/incomplete-url-substring-sanitization) diff --git a/package-lock.json b/package-lock.json index c5b8978f8a..1df6e56204 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13072,14 +13072,14 @@ } }, "node_modules/adm-zip": { - "version": "0.5.18", - "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.18.tgz", - "integrity": "sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.0.tgz", + "integrity": "sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg==", "dev": true, "license": "MIT", "optional": true, "engines": { - "node": ">=12.0" + "node": ">=14.0" } }, "node_modules/afinn-165": { diff --git a/package.json b/package.json index 28e75a4077..4890100535 100644 --- a/package.json +++ b/package.json @@ -406,6 +406,7 @@ }, "node-gyp": { "undici": "^6.27.0" - } + }, + "adm-zip": "^0.6.0" } } diff --git a/tests/unit/mitm-dns-graceful-degrade-6127.test.ts b/tests/unit/mitm-dns-graceful-degrade-6127.test.ts index 4f71925d6f..c5937d5dd6 100644 --- a/tests/unit/mitm-dns-graceful-degrade-6127.test.ts +++ b/tests/unit/mitm-dns-graceful-degrade-6127.test.ts @@ -80,7 +80,7 @@ test("provisionDnsEntries: a failing agent/custom step does not stop the others }, addHostsDns: async (hosts: string[]) => { // Custom-hosts call must still happen even after default + agent errors. - if (hosts.includes("custom.example.com")) customCalled = true; + if (hosts.some((h) => h === "custom.example.com")) customCalled = true; }, getAgentStates: () => [{ dns_enabled: true, agent_id: "__nonexistent_agent__" }] as never, listEnabledCustomHosts: () => [{ host: "custom.example.com" }] as never, @@ -205,8 +205,8 @@ test("provisionDnsEntries: canElevate() returning true proceeds with DNS provisi }, addHostsDns: async (hosts: string[], sudoPassword: string) => { capturedPasswords.push(sudoPassword); - if (hosts.some((h) => h.includes("googleapis.com"))) agentCalled = true; - if (hosts.includes("custom.example.com")) customCalled = true; + if (hosts.some((h) => h.endsWith(".googleapis.com"))) agentCalled = true; + if (hosts.some((h) => h === "custom.example.com")) customCalled = true; }, canElevate: () => true, getAgentStates: () => [{ dns_enabled: true, agent_id: "antigravity" }] as never,