mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-17 12:42:21 +03:00
merge: sync feat/7333-pluggable-service-providers with release/v3.8.49
This commit is contained in:
2
.github/workflows/dast-smoke.yml
vendored
2
.github/workflows/dast-smoke.yml
vendored
@@ -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:
|
||||
|
||||
8
.github/workflows/nightly-llm-security.yml
vendored
8
.github/workflows/nightly-llm-security.yml
vendored
@@ -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'
|
||||
|
||||
1
.github/workflows/nightly-resilience.yml
vendored
1
.github/workflows/nightly-resilience.yml
vendored
@@ -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:
|
||||
|
||||
4
.github/workflows/nightly-schemathesis.yml
vendored
4
.github/workflows/nightly-schemathesis.yml
vendored
@@ -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:
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
1
changelog.d/features/notion-web-available-models.md
Normal file
1
changelog.d/features/notion-web-available-models.md
Normal file
@@ -0,0 +1 @@
|
||||
- feat(providers): notion-web live model discovery via getAvailableModels (spaceId + token_v2 cookie)
|
||||
1
changelog.d/fixes/7226-dast-smoke-backend-only.md
Normal file
1
changelog.d/fixes/7226-dast-smoke-backend-only.md
Normal 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)
|
||||
1
changelog.d/fixes/7253-release-green-drift.md
Normal file
1
changelog.d/fixes/7253-release-green-drift.md
Normal 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)
|
||||
1
changelog.d/fixes/7263-settings-local-only.md
Normal file
1
changelog.d/fixes/7263-settings-local-only.md
Normal 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)
|
||||
1
changelog.d/fixes/7302-cli-electron-env.md
Normal file
1
changelog.d/fixes/7302-cli-electron-env.md
Normal 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)
|
||||
1
changelog.d/fixes/7620-noauth-hidden-filter.md
Normal file
1
changelog.d/fixes/7620-noauth-hidden-filter.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(routing): honor eye-icon hidden models for no-auth providers in auto-combo candidate pools (#7620)
|
||||
1
changelog.d/fixes/7661-fumadocs-devdep.md
Normal file
1
changelog.d/fixes/7661-fumadocs-devdep.md
Normal 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)
|
||||
1
changelog.d/fixes/7676-gemini-persist-cookies.md
Normal file
1
changelog.d/fixes/7676-gemini-persist-cookies.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(sse): persist rotated Gemini web-session cookies via onCredentialsRefreshed (#7676)
|
||||
1
changelog.d/fixes/7682-opencode-shared-alias.md
Normal file
1
changelog.d/fixes/7682-opencode-shared-alias.md
Normal 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)
|
||||
1
changelog.d/fixes/7693-wildcard-alias.md
Normal file
1
changelog.d/fixes/7693-wildcard-alias.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(sse): wire settings.wildcardAliases into model resolution so wildcard model aliases created in Settings actually take effect (#7693)
|
||||
1
changelog.d/fixes/7701-mcp-undici-copy.md
Normal file
1
changelog.d/fixes/7701-mcp-undici-copy.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(mcp): copy undici into dist/node_modules to prevent hollow-package shadowing crash (#7701)
|
||||
1
changelog.d/fixes/sec-adm-zip-codeql-host-match.md
Normal file
1
changelog.d/fixes/sec-adm-zip-codeql-host-match.md
Normal 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)
|
||||
@@ -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.
|
||||
@@ -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": {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) |
|
||||
|
||||
---
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 })),
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
319
open-sse/services/notionWebModels.ts
Normal file
319
open-sse/services/notionWebModels.ts
Normal 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
57
package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=...`.",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
123
src/shared/network/outboundUrlGuardPolicy.ts
Normal file
123
src/shared/network/outboundUrlGuardPolicy.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
|
||||
88
tests/unit/build/backend-only-smoke-workflows.test.ts
Normal file
88
tests/unit/build/backend-only-smoke-workflows.test.ts
Normal 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"
|
||||
);
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
114
tests/unit/cli-electron-to-cli-migration-server-env-7302.test.ts
Normal file
114
tests/unit/cli-electron-to-cli-migration-server-env-7302.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
42
tests/unit/cli-setup-opencode-nested-alias-7682.test.ts
Normal file
42
tests/unit/cli-setup-opencode-nested-alias-7682.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
45
tests/unit/credential-health-boot-wiring.test.ts
Normal file
45
tests/unit/credential-health-boot-wiring.test.ts
Normal 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"
|
||||
);
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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> = {};
|
||||
|
||||
93
tests/unit/gemini-web-cookie-rotation-7676.test.ts
Normal file
93
tests/unit/gemini-web-cookie-rotation-7676.test.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
@@ -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 =
|
||||
|
||||
113
tests/unit/mcp-server-hollow-dist-deps.test.ts
Normal file
113
tests/unit/mcp-server-hollow-dist-deps.test.ts
Normal 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).");
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
73
tests/unit/noauth-autocombo-hidden-7620.test.ts
Normal file
73
tests/unit/noauth-autocombo-hidden-7620.test.ts
Normal 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)}`
|
||||
);
|
||||
});
|
||||
182
tests/unit/notion-web-models-discovery.test.ts
Normal file
182
tests/unit/notion-web-models-discovery.test.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
59
tests/unit/npm-runtime-deps-fumadocs-mdx-7661.test.ts
Normal file
59
tests/unit/npm-runtime-deps-fumadocs-mdx-7661.test.ts
Normal 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(", ")}`
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
39
tests/unit/release-green-docs-drift-7253.test.ts
Normal file
39
tests/unit/release-green-docs-drift-7253.test.ts
Normal 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");
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
65
tests/unit/wildcard-alias-settings-not-applied-7693.test.ts
Normal file
65
tests/unit/wildcard-alias-settings-not-applied-7693.test.ts
Normal 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");
|
||||
});
|
||||
Reference in New Issue
Block a user