merge: sync feat/7333-pluggable-service-providers with release/v3.8.49

This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-19 12:04:42 -03:00
76 changed files with 1908 additions and 202 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
// `<DATA_DIR>/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 `<dataDir>/.env` is absent but `<dataDir>/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"));
}

View File

@@ -0,0 +1 @@
- feat(providers): notion-web live model discovery via getAvailableModels (spaceId + token_v2 cookie)

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
- fix(routing): honor eye-icon hidden models for no-auth providers in auto-combo candidate pools (#7620)

View File

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

View File

@@ -0,0 +1 @@
- fix(sse): persist rotated Gemini web-session cookies via onCredentialsRefreshed (#7676)

View File

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

View File

@@ -0,0 +1 @@
- fix(sse): wire settings.wildcardAliases into model resolution so wildcard model aliases created in Settings actually take effect (#7693)

View File

@@ -0,0 +1 @@
- fix(mcp): copy undici into dist/node_modules to prevent hollow-package shadowing crash (#7701)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<Record<string, unknown>> {
return messages
.filter((m) => typeof m?.content === "string" && m.content.length > 0)
.map((m) => ({
const entries: Array<Record<string, unknown>> = [];
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<string, unknown> = {
traceId: randomUUID(),
transcript: buildNotionTranscript(messages),
transcript: buildNotionTranscript(messages, modelId),
createThread: false,
asPatchResponse: true,
threadType: "workflow",
createdSource: "ai_module",
};
if (spaceId) reqBody.spaceId = spaceId;

View File

@@ -140,7 +140,8 @@ function getNoAuthCandidates(
excludedProviders: Set<string>,
blockedProviders: Set<string>,
disabledNoAuthProviders: Set<string>,
noAuthProviderSpecificData: Map<string, Record<string, unknown> | null | undefined>
noAuthProviderSpecificData: Map<string, Record<string, unknown> | null | undefined>,
hiddenModelsMap: Map<string, Set<string>>
): 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
)
);

View File

@@ -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<string, unknown>): 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<string>
): NotionDiscoveredModel | null {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null;
const row = entry as Record<string, unknown>;
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<string>
): 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<string>();
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<string, string> {
const cookie = normalizeNotionWebCookie(token);
const spaceId = extractSpaceIdFromNotionCookie(cookie);
const userId = extractNotionUserIdFromCookie(cookie);
const headers: Record<string, string> = {
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<string> {
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, unknown>): string {
for (const value of Object.values(root)) {
if (!value || typeof value !== "object" || Array.isArray(value)) continue;
const spaceMap = (value as Record<string, unknown>).space;
if (spaceMap && typeof spaceMap === "object" && !Array.isArray(spaceMap)) {
const ids = Object.keys(spaceMap as Record<string, unknown>);
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<string, unknown>;
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,
};

57
package-lock.json generated
View File

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

View File

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

View File

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

View File

@@ -50,6 +50,8 @@ export const SPAWN_CAPABLE_ROUTE_ROOTS: ReadonlyArray<string> = [
"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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -379,6 +379,22 @@ export async function registerNodejs(): Promise<void> {
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();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -32,6 +32,8 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray<string> = [
"/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

View File

@@ -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=<workspace-uuid>` so live model discovery (getAvailableModels) can list GPT/Claude/Gemini/etc. " +
"Optionally also `; notion_browser_id=...` / `; notion_user_id=...`.",
},
};

View File

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

View File

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

View File

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

View File

@@ -35,16 +35,41 @@ function getReservedProviderPrefixes(): Set<string> {
}
/**
* 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<string, unknown>): Record<string, unknown> {
const wildcardEntries = Array.isArray(settings.wildcardAliases)
? (settings.wildcardAliases as Array<{ pattern?: unknown; target?: unknown }>)
: [];
const wildcardMap: Record<string, unknown> = {};
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<Record<string, unknown>> {
const [dbAliases, settings] = await Promise.all([
@@ -59,8 +84,9 @@ async function getCombinedModelAliases(): Promise<Record<string, unknown>> {
? (settings.modelAliases as Record<string, unknown>)
: {};
// Settings-based aliases win over DB-namespace aliases on key collision
return { ...dbAliases, ...settingsAliases };
const wildcardMap = buildWildcardAliasMap(settings);
return { ...dbAliases, ...settingsAliases, ...wildcardMap };
}
/**

View File

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

View File

@@ -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<string, string>;
[key: string]: unknown;
}
interface WorkflowJob {
steps: WorkflowStep[];
[key: string]: unknown;
}
interface WorkflowDoc {
jobs: Record<string, WorkflowJob>;
[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"
);
});

View File

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

View File

@@ -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 <DATA_DIR>/server.env (electron/main.js), but the CLI
// (bin/omniroute.mjs) only ever loaded <DATA_DIR>/.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 });
}
});

View File

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

View File

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

View File

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

View File

@@ -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<string, string> = {};

View File

@@ -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<string> }) => 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<string, unknown> | 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<string, unknown>) => {
persistedCredentials = newCreds;
},
} as unknown as Parameters<InstanceType<typeof GeminiWebExecutor>["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;
}
});

View File

@@ -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<string, any>).extractM365CredentialParts(raw, {});
const extract = (raw: string) => B.extractM365CredentialParts(raw, {});
test("#7078 m365.cloud.microsoft wss URL extracts access_token + chathubPath", () => {
const raw =

View File

@@ -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/<pkg>/` directory containing only
// package.json, no code. Node's module resolution stops at the FIRST
// node_modules/<pkg> directory found while walking up from the importer -- so a
// hollow dist/node_modules/<pkg> SHADOWS the fully-populated sibling
// node_modules/<pkg> 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<string>();
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<string> {
const text = fs.readFileSync(ASSEMBLE, "utf8");
const entries = [...text.matchAll(/src:\s*\[([^\]]+)\]/gs)];
const guaranteed = new Set<string>();
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).");
});

View File

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

View File

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

View File

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

View File

@@ -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<string, string> = pkg.dependencies || {};
const devDeps: Record<string, string> = 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(", ")}`
);
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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