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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/package-lock.json b/package-lock.json index d5eb7bd8fd..1df6e56204 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,20 +13065,21 @@ "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" } }, "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": { @@ -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..4890100535 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", @@ -406,6 +406,7 @@ }, "node-gyp": { "undici": "^6.27.0" - } + }, + "adm-zip": "^0.6.0" } } 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/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/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 bab48bb7db..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"; @@ -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/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/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 65e570a10c..39066bc4fe 100644 --- a/src/app/api/providers/[id]/test/route.ts +++ b/src/app/api/providers/[id]/test/route.ts @@ -17,133 +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. */ @@ -188,12 +71,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 +93,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 +601,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 +692,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/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/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/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/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 d2734ea883..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"; @@ -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/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 6321ab4629..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"; /** @@ -92,6 +93,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/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/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/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/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/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/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/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", 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/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"); +}); 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" + ); +}); 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/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" + ); +}); 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"'); +}); 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 }); + } +}); 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/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" + ); + }); +}); 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" + ); +}); 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"); 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/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; + } +}); 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"); +}); 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/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)."); +}); 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, 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)}` + ); +}); 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; + } +}); 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(", ")}` + ); +}); 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); +}); 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/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"); +}); 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"); +}); 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); +}); 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"); 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"); +});