From 1d17c239d1030ca3e2c881d6b1cb12fcf8957c95 Mon Sep 17 00:00:00 2001 From: Dizzle <112548150+maxmad64bis@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:52:41 +0200 Subject: [PATCH] fix(build): stop the client bundle from reaching server-only modules, and make the guard find them (#13436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the production build break from `node:fs` reaching client bundles (`oauth.ts → cursorAgentCliVersion.ts` through the codebuddy-cn registry) and widens the client-bundle guard so it finds any Node builtin, not just the one that broke. Maintainer rework before merge (kept the idea, no default behavior change): - The guard was 11× slower (3.8s → ~40s) because resolved edges were not cached; with resolved edges and per-file verdicts cached it runs in ~4.6s. - Bare builtins that Next's client build polyfills (`path`, `os`, `crypto`, `buffer`, … from Next's own `resolve.fallback` list) are allowed consistently; `node:` imports are always flagged; a drift test fails if Next stops polyfilling an allowlisted name. Validated first on the combined board of all 38 PRs of this batch (10 merged as-is, 28 after the maintainer rework) on top of release/v3.8.51 c0f92ec: typecheck:core, check:open-sse-typecheck and check:dashboard-typecheck clean; ESLint clean on every changed file; file-size (rebaselined for the combined growth), complexity, cognitive-complexity, changelog-integrity, docs-counts, docs-sync, migration-numbering and i18n new-key gates green; 735 focused node:test cases with the only batch-caused failure (a flag-count assertion) fixed. Then re-validated alone on the fresh release tip right before this merge: ESLint on the changed files, typecheck:core, check:open-sse-typecheck, the file-size/complexity/changelog gates and this PR's own tests. Thanks @maxmad64bis! --- .../13436-client-bundle-server-only-guard.md | 1 + open-sse/services/model.ts | 60 +------- open-sse/services/providerAlias.ts | 58 ++++++++ open-sse/utils/cursorAgentCliVersion.ts | 7 +- open-sse/utils/cursorAgentCliVersionPin.ts | 7 + src/lib/combos/controlCenter.ts | 2 +- src/lib/oauth/constants/oauth.ts | 4 +- ...client-bundle-no-server-only-10692.test.ts | 128 ++++++++++++++++-- 8 files changed, 194 insertions(+), 73 deletions(-) create mode 100644 changelog.d/fixes/13436-client-bundle-server-only-guard.md create mode 100644 open-sse/services/providerAlias.ts create mode 100644 open-sse/utils/cursorAgentCliVersionPin.ts diff --git a/changelog.d/fixes/13436-client-bundle-server-only-guard.md b/changelog.d/fixes/13436-client-bundle-server-only-guard.md new file mode 100644 index 0000000000..7b8cf30097 --- /dev/null +++ b/changelog.d/fixes/13436-client-bundle-server-only-guard.md @@ -0,0 +1 @@ +- **fix(build):** the production build no longer breaks when a client component reaches a server-only module, and the client-bundle guard now discovers server-only modules instead of matching a fixed list ([#13436](https://github.com/diegosouzapw/OmniRoute/pull/13436)) — thanks @maxmad64bis diff --git a/open-sse/services/model.ts b/open-sse/services/model.ts index ec0080ef83..1d36623205 100644 --- a/open-sse/services/model.ts +++ b/open-sse/services/model.ts @@ -1,7 +1,10 @@ import { PROVIDER_ID_TO_ALIAS, PROVIDER_MODELS } from "../config/providerModels.ts"; +import { ALIAS_TO_PROVIDER_ID, resolveProviderAlias } from "./providerAlias.ts"; import { resolveWildcardAlias } from "./wildcardRouter.ts"; import { getRegisteredProviderEffortBaseModelId } from "../utils/registeredEffortVariants.ts"; +export { resolveProviderAlias }; + type ProviderModelAliasMap = Record>; type ModelAliasValue = string | { provider?: string; model?: string }; type ModelAliasMap = Record; @@ -27,38 +30,6 @@ export function stripContextWindowSuffix( return modelStr.replace(CONTEXT_WINDOW_SUFFIX_RE, "").trimEnd(); } -// Derive alias→provider mapping from the single source of truth (PROVIDER_ID_TO_ALIAS) -// This prevents the two maps from drifting out of sync -const ALIAS_TO_PROVIDER_ID: Record = {}; -for (const [id, alias] of Object.entries(PROVIDER_ID_TO_ALIAS)) { - if (ALIAS_TO_PROVIDER_ID[alias]) { - console.log( - `[MODEL] Warning: alias "${alias}" maps to both "${ALIAS_TO_PROVIDER_ID[alias]}" and "${id}". Using "${id}".` - ); - } - ALIAS_TO_PROVIDER_ID[alias] = id; -} -// Manual alias overrides — maps slug-style prefixes to canonical provider IDs. -// These live outside the registry because they represent multiple providers -// or backward-compatible slug changes, not a single provider's display name. -// opencode/ → opencode-zen (the main free/open tier; opencode-go is a separate paid tier) -ALIAS_TO_PROVIDER_ID["opencode"] = "opencode-zen"; -// xiaomi/ is the user-visible prefix for MiMo models; register it so -// parseModel("xiaomi/mimo-v2-flash") resolves provider = "xiaomi-mimo" instead -// of falling through to the identity fallback ("xiaomi"). -ALIAS_TO_PROVIDER_ID["xiaomi"] = "xiaomi-mimo"; -// llamacpp/ is the user-visible alias for the llama-cpp self-hosted provider. -// The canonical ID is "llama-cpp" (with a hyphen), but the catalog and user-facing -// prefix is "llamacpp". Register it so parseModel("llamacpp/") resolves -// provider = "llama-cpp" instead of the identity fallback ("llamacpp"). -ALIAS_TO_PROVIDER_ID["llamacpp"] = "llama-cpp"; -// agy/ is the short alias for antigravity provider. -ALIAS_TO_PROVIDER_ID["agy"] = "antigravity"; -// aq/ is the user-visible prefix for the Amazon Q (AWS Builder ID) provider. -// The canonical provider ID is "amazon-q". Register it so parseModel("aq/") -// resolves provider = "amazon-q" instead of falling through to the identity fallback. -ALIAS_TO_PROVIDER_ID["aq"] = "amazon-q"; - // Provider-scoped legacy model aliases. Used to normalize provider/model inputs // and keep backward compatibility when upstream IDs change. const PROVIDER_MODEL_ALIASES: ProviderModelAliasMap = { @@ -180,31 +151,6 @@ interface ProviderConnectionLike { is_active?: unknown; } -/** - * Resolve provider alias to provider ID - */ -export function resolveProviderAlias(aliasOrId: string | null | undefined): string | null { - if (typeof aliasOrId !== "string") return null; - // Follow the alias chain transitively so intermediate alias-only hops resolve - // to the final target, but STOP as soon as a hop lands on a registered - // provider id (#2901): "oc" must resolve to the no-auth "opencode" provider, - // NOT continue through the manual "opencode" → "opencode-zen" slug override — - // that override is for user-typed `opencode/` prefixes only. Without this - // boundary the no-auth provider becomes unreachable by any prefix. - // Guarded against infinite loops with both a depth limit and a seen-set. - let current = aliasOrId; - const seen = new Set(); - for (let i = 0; i < 10; i++) { - const next = ALIAS_TO_PROVIDER_ID[current]; - if (!next || next === current) return current; - if (next in PROVIDER_ID_TO_ALIAS) return next; - if (seen.has(next)) return next; - seen.add(next); - current = next; - } - return current; -} - /** * #474 — Resolve a bare model name to the selected connection's `defaultModel`. * diff --git a/open-sse/services/providerAlias.ts b/open-sse/services/providerAlias.ts new file mode 100644 index 0000000000..a5941b22e0 --- /dev/null +++ b/open-sse/services/providerAlias.ts @@ -0,0 +1,58 @@ +import { PROVIDER_ID_TO_ALIAS } from "../config/providerModels.ts"; + +// Derive alias→provider mapping from the single source of truth (PROVIDER_ID_TO_ALIAS) +// This prevents the two maps from drifting out of sync +export const ALIAS_TO_PROVIDER_ID: Record = {}; +for (const [id, alias] of Object.entries(PROVIDER_ID_TO_ALIAS)) { + if (ALIAS_TO_PROVIDER_ID[alias]) { + console.log( + `[MODEL] Warning: alias "${alias}" maps to both "${ALIAS_TO_PROVIDER_ID[alias]}" and "${id}". Using "${id}".` + ); + } + ALIAS_TO_PROVIDER_ID[alias] = id; +} +// Manual alias overrides — maps slug-style prefixes to canonical provider IDs. +// These live outside the registry because they represent multiple providers +// or backward-compatible slug changes, not a single provider's display name. +// opencode/ → opencode-zen (the main free/open tier; opencode-go is a separate paid tier) +ALIAS_TO_PROVIDER_ID["opencode"] = "opencode-zen"; +// xiaomi/ is the user-visible prefix for MiMo models; register it so +// parseModel("xiaomi/mimo-v2-flash") resolves provider = "xiaomi-mimo" instead +// of falling through to the identity fallback ("xiaomi"). +ALIAS_TO_PROVIDER_ID["xiaomi"] = "xiaomi-mimo"; +// llamacpp/ is the user-visible alias for the llama-cpp self-hosted provider. +// The canonical ID is "llama-cpp" (with a hyphen), but the catalog and user-facing +// prefix is "llamacpp". Register it so parseModel("llamacpp/") resolves +// provider = "llama-cpp" instead of the identity fallback ("llamacpp"). +ALIAS_TO_PROVIDER_ID["llamacpp"] = "llama-cpp"; +// agy/ is the short alias for antigravity provider. +ALIAS_TO_PROVIDER_ID["agy"] = "antigravity"; +// aq/ is the user-visible prefix for the Amazon Q (AWS Builder ID) provider. +// The canonical provider ID is "amazon-q". Register it so parseModel("aq/") +// resolves provider = "amazon-q" instead of falling through to the identity fallback. +ALIAS_TO_PROVIDER_ID["aq"] = "amazon-q"; + +/** + * Resolve provider alias to provider ID + */ +export function resolveProviderAlias(aliasOrId: string | null | undefined): string | null { + if (typeof aliasOrId !== "string") return null; + // Follow the alias chain transitively so intermediate alias-only hops resolve + // to the final target, but STOP as soon as a hop lands on a registered + // provider id (#2901): "oc" must resolve to the no-auth "opencode" provider, + // NOT continue through the manual "opencode" → "opencode-zen" slug override — + // that override is for user-typed `opencode/` prefixes only. Without this + // boundary the no-auth provider becomes unreachable by any prefix. + // Guarded against infinite loops with both a depth limit and a seen-set. + let current = aliasOrId; + const seen = new Set(); + for (let i = 0; i < 10; i++) { + const next = ALIAS_TO_PROVIDER_ID[current]; + if (!next || next === current) return current; + if (next in PROVIDER_ID_TO_ALIAS) return next; + if (seen.has(next)) return next; + seen.add(next); + current = next; + } + return current; +} diff --git a/open-sse/utils/cursorAgentCliVersion.ts b/open-sse/utils/cursorAgentCliVersion.ts index 91bedd2206..ba0dda576a 100644 --- a/open-sse/utils/cursorAgentCliVersion.ts +++ b/open-sse/utils/cursorAgentCliVersion.ts @@ -19,12 +19,9 @@ import { } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; +import { CURSOR_AGENT_CLI_VERSION } from "./cursorAgentCliVersionPin.ts"; -/** - * Pinned Agent CLI build id used when no local install is found (typical - * headless OmniRoute). Bump when refreshing Cursor CLI impersonation. - */ -export const CURSOR_AGENT_CLI_VERSION = "2026.07.08-0c04a8a"; +export { CURSOR_AGENT_CLI_VERSION }; const VERSION_ID_RE = /^\d{4}\.\d{2}\.\d{2}-[0-9a-f]+$/; const CACHE_TTL_MS = 60 * 60 * 1000; diff --git a/open-sse/utils/cursorAgentCliVersionPin.ts b/open-sse/utils/cursorAgentCliVersionPin.ts new file mode 100644 index 0000000000..eeea27f015 --- /dev/null +++ b/open-sse/utils/cursorAgentCliVersionPin.ts @@ -0,0 +1,7 @@ +// Import-free pin so client bundles can read the version without pulling node-only detection code. + +/** + * Pinned Agent CLI build id used when no local install is found (typical + * headless OmniRoute). Bump when refreshing Cursor CLI impersonation. + */ +export const CURSOR_AGENT_CLI_VERSION = "2026.07.08-0c04a8a"; diff --git a/src/lib/combos/controlCenter.ts b/src/lib/combos/controlCenter.ts index 92a87b2738..3c16a03e8a 100644 --- a/src/lib/combos/controlCenter.ts +++ b/src/lib/combos/controlCenter.ts @@ -1,6 +1,6 @@ import { normalizeComboModels, type ComboStep } from "./steps"; import { resolveComboTargetModelStr } from "../../../open-sse/services/combo/opencodeTargetAlias.ts"; -import { resolveProviderAlias } from "../../../open-sse/services/model.ts"; +import { resolveProviderAlias } from "../../../open-sse/services/providerAlias.ts"; type JsonRecord = Record; diff --git a/src/lib/oauth/constants/oauth.ts b/src/lib/oauth/constants/oauth.ts index 6544ebd7e4..86bfb025e2 100644 --- a/src/lib/oauth/constants/oauth.ts +++ b/src/lib/oauth/constants/oauth.ts @@ -20,7 +20,7 @@ import { GROK_BUILD_TOKEN_URL, } from "@omniroute/open-sse/config/grokBuild.ts"; import { resolvePublicCred } from "@omniroute/open-sse/utils/publicCreds.ts"; -import { CURSOR_AGENT_CLI_VERSION } from "@omniroute/open-sse/utils/cursorAgentCliVersion.ts"; +import { CURSOR_AGENT_CLI_VERSION } from "@omniroute/open-sse/utils/cursorAgentCliVersionPin.ts"; import { buildGitLabOAuthEndpoints, GITLAB_DUO_DEFAULT_BASE_URL } from "../gitlab"; /** @@ -375,7 +375,7 @@ export const KIRO_CONFIG = { // Cursor stores credentials in SQLite database: state.vscdb // Keys: cursorAuth/accessToken, cursorAuth/refreshToken, storage.serviceMachineId // Deep-control PKCE + refresh aligned with OpenCodex (lidge-jun/opencodex src/oauth/cursor.ts). -// clientVersion pin lives in open-sse/utils/cursorAgentCliVersion.ts — single source of truth. +// clientVersion pin lives in open-sse/utils/cursorAgentCliVersionPin.ts — single source of truth. export const CURSOR_CONFIG = { // API endpoints apiEndpoint: "https://api2.cursor.sh", diff --git a/tests/unit/client-bundle-no-server-only-10692.test.ts b/tests/unit/client-bundle-no-server-only-10692.test.ts index e29ae9d2fa..9206d61604 100644 --- a/tests/unit/client-bundle-no-server-only-10692.test.ts +++ b/tests/unit/client-bundle-no-server-only-10692.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import fs from "node:fs"; import path from "node:path"; +import { builtinModules } from "node:module"; import { fileURLToPath } from "node:url"; /** @@ -26,6 +27,20 @@ import { fileURLToPath } from "node:url"; * - **Dynamic `import()` is not followed.** It does not actually break a bundle edge (that was * tried for #10692 and failed), but it does move the module into a chunk the browser only * fetches on demand, which is a legitimate boundary for a lazily-used server path. + * + * A reached module counts as server-only when it statically imports a Node builtin the + * production bundler cannot resolve for the browser. The pinned list below (the original + * #10692 chain) stays explicit so it keeps failing loudly even if the discovery logic + * changes; everything else is found by walking the graph and checking each visited file + * for a builtin import. + * + * Builtins Next ships a browser polyfill for are tolerated when imported by their BARE name + * (`path`, `os`, `crypto`, `buffer`, …): Next's client build maps exactly those names to + * `next/dist/compiled/*` shims (`resolve.fallback` for the client compiler in + * `node_modules/next/dist/build/webpack-config.js`), so flagging them would cry wolf the + * same way counting `import type` did. The `node:` scheme is never tolerated — the client + * build has no fallback for it (`UnhandledSchemeError` on `node:fs` / `node:os` / `node:path` + * is what broke the build this guard was widened for). */ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); @@ -39,6 +54,45 @@ const SERVER_ONLY = new Set([ "open-sse/utils/tlsClient.ts", ]); +/** + * Bare builtin names Next's client build polyfills (the client `resolve.fallback` map in + * `next/dist/build/webpack-config.js`). Kept in sync by the drift test at the bottom. + */ +const NEXT_CLIENT_POLYFILLED_BUILTINS = new Set([ + "assert", + "buffer", + "constants", + "crypto", + "domain", + "events", + "http", + "https", + "os", + "path", + "process", + "punycode", + "querystring", + "stream", + "string_decoder", + "sys", + "timers", + "tty", + "util", + "vm", + "zlib", +]); + +const NODE_BUILTINS = new Set( + builtinModules.map((name) => name.replace(/^node:/, "")).filter((bare) => !bare.startsWith("_")) +); + +/** True when `specifier` names a Node builtin the browser bundle cannot resolve. */ +function isBrowserForbiddenBuiltin(specifier: string): boolean { + if (specifier.startsWith("node:")) return true; // no client fallback for the scheme + const root = specifier.split("/")[0]; // `fs/promises` → `fs` + return NODE_BUILTINS.has(root) && !NEXT_CLIENT_POLYFILLED_BUILTINS.has(root); +} + /** * Non-`"use client"` entry points that still end up in a client bundle because client * components import them. Kept explicit so the original #10692 chain stays pinned even if the @@ -60,6 +114,9 @@ function resolveSpecifier(fromFile: string, specifier: string): string | null { } else if (specifier.startsWith("@omniroute/open-sse")) { const rest = specifier.slice("@omniroute/open-sse".length).replace(/^\//, ""); base = path.join(REPO_ROOT, "open-sse", rest); + } else if (specifier.startsWith("@omniroute/browser-pool")) { + const rest = specifier.slice("@omniroute/browser-pool".length).replace(/^\//, ""); + base = path.join(REPO_ROOT, "packages/browser-pool/src", rest); } else if (specifier.startsWith("@/")) { base = path.join(REPO_ROOT, "src", specifier.slice(2)); } else { @@ -118,29 +175,53 @@ function staticSpecifiers(source: string): string[] { } const specifierCache = new Map(); -function edgesOf(file: string): string[] { +function specifiersOf(file: string): string[] { const cached = specifierCache.get(file); if (cached) return cached; const absolute = path.join(REPO_ROOT, file); - let edges: string[] = []; + let specs: string[] = []; if (fs.existsSync(absolute)) { - edges = staticSpecifiers(fs.readFileSync(absolute, "utf8")) - .map((specifier) => resolveSpecifier(file, specifier)) - .filter((resolved): resolved is string => resolved !== null); + specs = staticSpecifiers(fs.readFileSync(absolute, "utf8")); } - specifierCache.set(file, edges); + specifierCache.set(file, specs); + return specs; +} +// Resolved edges and verdicts are cached per file: the BFS runs once per client entry and +// re-visits the same shared modules thousands of times, so re-resolving specifiers +// (fs.existsSync/statSync per candidate) on every visit made the guard ~6x slower. +const edgeCache = new Map(); +function edgesOf(file: string): string[] { + const cached = edgeCache.get(file); + if (cached) return cached; + const edges = specifiersOf(file) + .map((specifier) => resolveSpecifier(file, specifier)) + .filter((resolved): resolved is string => resolved !== null); + edgeCache.set(file, edges); return edges; } +const serverOnlyVerdictCache = new Map(); +/** True when the file is pinned server-only or itself imports a browser-forbidden builtin. */ +function isServerOnly(file: string): boolean { + const cached = serverOnlyVerdictCache.get(file); + if (cached !== undefined) return cached; + const verdict = SERVER_ONLY.has(file) || specifiersOf(file).some(isBrowserForbiddenBuiltin); + serverOnlyVerdictCache.set(file, verdict); + return verdict; +} + /** BFS over static imports; returns the first path reaching a server-only module. */ function findServerOnlyPath(entry: string): string[] | null { const seen = new Set([entry]); + if (isServerOnly(entry)) return [entry]; const queue: Array = [[entry]]; while (queue.length > 0) { const trail = queue.shift()!; for (const resolved of edgesOf(trail[trail.length - 1])) { if (seen.has(resolved)) continue; - if (SERVER_ONLY.has(resolved)) return [...trail, resolved]; + if (isServerOnly(resolved)) { + return [...trail, resolved]; + } seen.add(resolved); queue.push([...trail, resolved]); } @@ -163,7 +244,9 @@ function walk(dir: string, acc: string[] = []): string[] { function clientEntryPoints(): string[] { return walk(path.join(REPO_ROOT, "src")).filter((file) => - /^\s*["']use client["']/m.test(fs.readFileSync(path.join(REPO_ROOT, file), "utf8").slice(0, 200)) + /^\s*["']use client["']/m.test( + fs.readFileSync(path.join(REPO_ROOT, file), "utf8").slice(0, 200) + ) ); } @@ -184,3 +267,32 @@ test("no client entry point statically reaches server-only code", () => { "carries no runtime edge." ); }); + +test("builtin classification: node: scheme always forbidden, bare polyfilled names tolerated", () => { + for (const specifier of ["node:fs", "node:path", "node:os", "node:crypto", "fs", "fs/promises"]) { + assert.equal(isBrowserForbiddenBuiltin(specifier), true, specifier); + } + for (const specifier of ["child_process", "net", "tls", "module", "worker_threads"]) { + assert.equal(isBrowserForbiddenBuiltin(specifier), true, specifier); + } + for (const specifier of ["path", "os", "crypto", "buffer", "events", "util", "stream"]) { + assert.equal(isBrowserForbiddenBuiltin(specifier), false, specifier); + } + for (const specifier of ["react", "@/lib/db/core", "./local", "zod"]) { + assert.equal(isBrowserForbiddenBuiltin(specifier), false, specifier); + } +}); + +test("the polyfilled-builtin allowlist matches Next's client resolve.fallback", () => { + const webpackConfig = fs.readFileSync( + path.join(REPO_ROOT, "node_modules/next/dist/build/webpack-config.js"), + "utf8" + ); + for (const name of NEXT_CLIENT_POLYFILLED_BUILTINS) { + assert.match( + webpackConfig, + new RegExp(`\\b${name}: require\\.resolve\\(`), + `Next no longer polyfills "${name}" for the client — drop it from the allowlist` + ); + } +});