diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index c4e3bd4361..49dba75362 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -409,7 +409,7 @@ "open-sse/executors/antigravity.ts": 1665, "open-sse/executors/base.ts": 1751, "open-sse/executors/chatgpt-web.ts": 5056, - "open-sse/executors/codex.ts": 1499, + "open-sse/executors/codex.ts": 1503, "open-sse/executors/cursor.ts": 1759, "open-sse/executors/muse-spark-web.ts": 1405, "open-sse/handlers/chatCore.ts": 5976, @@ -417,13 +417,13 @@ "open-sse/handlers/search.ts": 1789, "open-sse/mcp-server/schemas/tools.ts": 1621, "open-sse/mcp-server/server.ts": 1572, - "open-sse/services/accountFallback.ts": 2422, + "open-sse/services/accountFallback.ts": 2429, "open-sse/services/adobeFireflyBrowserLogin.ts": 1401, "open-sse/services/combo.ts": 4023, "open-sse/translator/response/openai-responses.ts": 1466, "open-sse/utils/cursorAgentProtobuf.ts": 1547, "open-sse/utils/proxyFetch.ts": 1271, - "open-sse/utils/stream.ts": 3072, + "open-sse/utils/stream.ts": 3078, "open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/browser-worker.ts": 4398, "open-sse/vendor/codex-chatgpt-web/bridge.ts": 1322, "src/app/(dashboard)/dashboard/HomePageClient.tsx": 1344, @@ -451,7 +451,7 @@ "src/shared/components/RequestLoggerV2.tsx": 1718, "src/shared/constants/providers/apikey/gateways.ts": 1439, "src/shared/services/cliRuntime.ts": 1296, - "src/sse/handlers/chat.ts": 2424, + "src/sse/handlers/chat.ts": 2434, "src/sse/services/auth.ts": 3427, "tests/unit/account-fallback-service.test.ts": 2453, "tests/unit/provider-validation-specialty.test.ts": 4656 @@ -629,5 +629,6 @@ "_rebaseline_2026_06_30_v3842_release_chatgptweb_compression": "v3.8.42 cycle-close file-size reconciliation (DRIFT measured OK on each PR's base, stacked above frozen at the merge tip; fast-path PR->release/** does not run check:file-size). (1) open-sse/executors/chatgpt-web.ts 2870->3206 (+336 = #5531 portable SHA3-512 sentinel-PoW wiring with the native-vs-fallback digest path + #5536 GPT-5.5 Pro handoff branch; the pure Keccak-f[1600] fallback itself already lives in the separate leaf open-sse/utils/sha3-512.ts — the executor growth is the cohesive call-site/handoff logic, not extractable without hiding the sentinel chokepoint). (2) tests/unit/chatgpt-web.test.ts 2855->3159 (+304 = #5536 GPT-5.5 Pro handoff coverage; pair-file with its executor). (3) open-sse/services/compression/strategySelector.ts 997->1022 (+25 = #5527 T02 honest default-on pipeline inflation guard wiring at the existing finalizeStackedResult choke). All cohesive at existing chokepoints; covered by tests/unit/chatgpt-web-sha3-boringssl-5531.test.ts, chatgpt-web.test.ts (GPT-5.5 Pro), compression-pipeline-inflation-guard.test.ts.", "open-sse/executors/chatgpt-web.ts": "3241", "_rebaseline_2026_08_30_11771_vercel_gateway_passthrough": "PR #11771 adds passthroughModels: true (1 line) to the Vercel AI Gateway registry entry — no split available, single-line provider-config addition.", - "_relax_velocity_2026_08_30": "127 frozen line caps and cap/testCap raised by 20% (velocity phase; see quality-baseline.json _policy)." + "_relax_velocity_2026_08_30": "127 frozen line caps and cap/testCap raised by 20% (velocity phase; see quality-baseline.json _policy).", + "_rebaseline_2026_09_03_basereds_v3851_merged_growth": "Base-red drain 2026-09-03 (release/v3.8.51): four frozen files grew past their cap through already-merged PRs, all cohesive additions at the existing chokepoint with no extraction that would not hide the choke. (1) src/sse/handlers/chat.ts 2424->2434 (+10 = #12427 video-transcript log/memory redaction and its #12503 re-anchor follow-up: the redaction entry must be derived from the POST-guardrail payload at the single dispatch point, so the threading cannot move out of the handler). (2) open-sse/utils/stream.ts 3072->3078, (3) open-sse/services/accountFallback.ts 2422->2429 and (4) open-sse/executors/codex.ts 1499->1503 (+17 total = #12179 perf: hot-path regexes hoisted to module scope, bounded token caches and the quadratic-buffering fix — module-level constants and guard clauses inside the existing hot loops). Covered by the suites those PRs shipped; measured with npm run check:file-size on the pure tip." } diff --git a/scripts/check/check-openapi-security-tiers.mjs b/scripts/check/check-openapi-security-tiers.mjs index 812e4a0c1b..78f9527590 100644 --- a/scripts/check/check-openapi-security-tiers.mjs +++ b/scripts/check/check-openapi-security-tiers.mjs @@ -9,35 +9,36 @@ import fs from "node:fs"; import path from "node:path"; import * as yaml from "js-yaml"; +import { isLocalOnlyDocPath, readRegexArray, readStringArray } from "./routeGuardConstants.mjs"; const ROOT = process.cwd(); const OPENAPI_PATH = path.join(ROOT, "docs", "openapi.yaml"); const ROUTE_GUARD_PATH = path.join(ROOT, "src", "server", "authz", "routeGuard.ts"); -function parseStringArray(match) { - if (!match) return []; - // Strip line comments before splitting — array entries in routeGuard.ts often - // carry inline `// T-XX:` annotations that would otherwise pollute the parsed tokens. - return match[1] - .replace(/\/\/[^\n]*/g, "") - .split(",") - .map((s) => s.trim().replace(/^["']|["']$/g, "")) - .filter(Boolean); -} - const guardSrc = fs.readFileSync(ROUTE_GUARD_PATH, "utf-8"); -const LOCAL_ONLY_PREFIXES = parseStringArray( - guardSrc.match(/export const LOCAL_ONLY_API_PREFIXES.*?=\s*\[([^\]]+)\]/s) -); -const ALWAYS_PROTECTED_PATHS = parseStringArray( - guardSrc.match(/export const ALWAYS_PROTECTED_API_PATHS.*?=\s*\[([^\]]+)\]/s) -); + +// Both halves of isLocalOnlyPath(): the flat prefixes AND the regex patterns. +// Reading only the prefixes reported regex-gated and imported-constant routes +// as unprotected (see routeGuardConstants.mjs). +let LOCAL_ONLY_PREFIXES; +let LOCAL_ONLY_PATTERNS; +let ALWAYS_PROTECTED_PATHS; +try { + LOCAL_ONLY_PREFIXES = readStringArray(guardSrc, "LOCAL_ONLY_API_PREFIXES", { root: ROOT }); + LOCAL_ONLY_PATTERNS = readRegexArray(guardSrc, "LOCAL_ONLY_API_PATTERNS"); + ALWAYS_PROTECTED_PATHS = readStringArray(guardSrc, "ALWAYS_PROTECTED_API_PATHS", { root: ROOT }); +} catch (err) { + console.error(`[openapi-security-tiers] FAIL — ${err.message}`); + process.exit(1); +} if (LOCAL_ONLY_PREFIXES.length === 0 || ALWAYS_PROTECTED_PATHS.length === 0) { console.error("[openapi-security-tiers] FAIL — could not parse routeGuard.ts constants"); process.exit(1); } +const localOnlyGuards = { prefixes: LOCAL_ONLY_PREFIXES, patterns: LOCAL_ONLY_PATTERNS }; + const raw = yaml.load(fs.readFileSync(OPENAPI_PATH, "utf-8")); const paths = raw.paths || {}; @@ -49,14 +50,11 @@ for (const [pathStr, methods] of Object.entries(paths)) { if (!["get", "post", "put", "patch", "delete"].includes(method) || !spec) continue; if (spec["x-loopback-only"] === true) { - const matchesPrefix = LOCAL_ONLY_PREFIXES.some((prefix) => { - const norm = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix; - return pathStr === norm || pathStr.startsWith(norm + "/"); - }); - if (!matchesPrefix) { + if (!isLocalOnlyDocPath(pathStr, localOnlyGuards)) { errors.push( `${method.toUpperCase()} ${pathStr}: has x-loopback-only but is NOT covered by ` + - `LOCAL_ONLY_API_PREFIXES [${LOCAL_ONLY_PREFIXES.join(", ")}]` + `LOCAL_ONLY_API_PREFIXES [${LOCAL_ONLY_PREFIXES.join(", ")}] ` + + `nor by LOCAL_ONLY_API_PATTERNS [${LOCAL_ONLY_PATTERNS.join(", ")}]` ); } } @@ -87,16 +85,12 @@ for (const [pathStr, methods] of Object.entries(paths)) { const reverseWarnings = []; for (const [pathStr, methods] of Object.entries(paths)) { if (!methods || typeof methods !== "object") continue; - const fallsUnderLocalOnly = LOCAL_ONLY_PREFIXES.some((prefix) => { - const norm = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix; - return pathStr === norm || pathStr.startsWith(norm + "/"); - }); - if (!fallsUnderLocalOnly) continue; + if (!isLocalOnlyDocPath(pathStr, localOnlyGuards)) continue; for (const [method, spec] of Object.entries(methods)) { if (!["get", "post", "put", "patch", "delete"].includes(method) || !spec) continue; if (spec["x-loopback-only"] !== true) { reverseWarnings.push( - `${method.toUpperCase()} ${pathStr}: falls under LOCAL_ONLY_API_PREFIXES ` + + `${method.toUpperCase()} ${pathStr}: is LOCAL_ONLY per routeGuard ` + `but is missing x-loopback-only: true annotation` ); } diff --git a/scripts/check/routeGuardConstants.mjs b/scripts/check/routeGuardConstants.mjs new file mode 100644 index 0000000000..94381c15da --- /dev/null +++ b/scripts/check/routeGuardConstants.mjs @@ -0,0 +1,187 @@ +// scripts/check/routeGuardConstants.mjs +// Shared reader for the compile-time constants in src/server/authz/routeGuard.ts. +// +// The security-tier gate cannot import the module itself (routeGuard pulls the +// server runtime — runtimeSettings → localDb → ioredis — and the gate runs on +// plain `node`), so it re-reads the constants from source. That text parse has +// to mirror `isLocalOnlyPath()` exactly, which means BOTH halves of the +// predicate: +// +// LOCAL_ONLY_API_PREFIXES.some(...) || LOCAL_ONLY_API_PATTERNS.some(...) +// +// Reading only the prefix array made every route that is gated by a regex +// (e.g. /api/providers/volcengine-plan/connect/*) or by an imported constant +// (VNC_ROUTE_PREFIX) look unprotected, and the gate then demanded the removal +// of a CORRECT `x-loopback-only` annotation — a false positive that pushes the +// fix in the unsafe direction. Unresolvable tokens now throw instead of +// silently degrading into a literal. + +import fs from "node:fs"; +import path from "node:path"; + +/** + * Strip `//` line comments without eating regex literals: a `//` preceded by a + * backslash (`\/\/`) or a colon (`https://`) is content, not a comment. + */ +export function stripLineComments(text) { + return text + .split("\n") + .map((line) => { + for (let i = 0; i < line.length - 1; i++) { + if (line[i] !== "/" || line[i + 1] !== "/") continue; + const prev = i > 0 ? line[i - 1] : ""; + if (prev === "\\" || prev === ":") continue; + return line.slice(0, i); + } + return line; + }) + .join("\n"); +} + +/** + * Entries of the array literal assigned to `constName`. + * + * Hand-rolled instead of a `\[([^\]]+)\]` capture because the pattern arrays + * hold regex literals whose character classes (`[^/]`) contain the very + * brackets and commas a naive capture/split would break on. + */ +export function parseArrayTokens(src, constName) { + const decl = src.search(new RegExp(`export const ${constName}\\b`)); + if (decl < 0) return []; + const open = src.indexOf("[", decl); + if (open < 0) return []; + + let depth = 0; + let end = -1; + for (let i = open; i < src.length; i++) { + const c = src[i]; + if (c === "[") depth++; + else if (c === "]") { + depth--; + if (depth === 0) { + end = i; + break; + } + } + } + if (end < 0) return []; + + const body = stripLineComments(src.slice(open + 1, end)); + const tokens = []; + let current = ""; + let nesting = 0; + for (const c of body) { + if (c === "," && nesting === 0) { + tokens.push(current); + current = ""; + continue; + } + if (c === "[" || c === "(" || c === "{") nesting++; + else if (c === "]" || c === ")" || c === "}") nesting--; + current += c; + } + tokens.push(current); + return tokens.map((t) => t.trim()).filter(Boolean); +} + +/** True for a token that is already a quoted string literal. */ +function isStringLiteral(token) { + return /^["'`]/.test(token); +} + +function unquote(token) { + return token.replace(/^["'`]|["'`]$/g, ""); +} + +/** + * Resolve a bare identifier used inside a routeGuard array (e.g. VNC_ROUTE_PREFIX) + * to its string value, following a local `const` or a named import. Returns null + * when it cannot be resolved — callers must treat that as fatal. + */ +export function resolveIdentifier(name, { guardSrc, root, readFile = readFileIfExists } = {}) { + const localDef = guardSrc.match( + new RegExp(`(?:export\\s+)?const\\s+${name}\\b[^=]*=\\s*["'\`]([^"'\`]+)["'\`]`) + ); + if (localDef) return localDef[1]; + + const imported = guardSrc.match( + new RegExp(`import\\s*\\{[^}]*\\b${name}\\b[^}]*\\}\\s*from\\s*["']([^"']+)["']`, "s") + ); + if (!imported) return null; + + for (const candidate of moduleCandidates(imported[1], root)) { + const src = readFile(candidate); + if (!src) continue; + const def = src.match( + new RegExp(`export\\s+const\\s+${name}\\b[^=]*=\\s*["'\`]([^"'\`]+)["'\`]`) + ); + if (def) return def[1]; + } + return null; +} + +/** Candidate on-disk paths for an import specifier (`@/x` → src/x, plus relatives). */ +export function moduleCandidates(spec, root) { + const base = spec.startsWith("@/") + ? path.join(root, "src", spec.slice(2)) + : spec.startsWith(".") + ? path.join(root, "src", "server", "authz", spec) + : null; + if (!base) return []; + return [`${base}.ts`, `${base}.tsx`, path.join(base, "index.ts")]; +} + +function readFileIfExists(p) { + try { + return fs.readFileSync(p, "utf-8"); + } catch { + return null; + } +} + +/** String entries of a prefix array, with identifiers resolved. Throws if any cannot be. */ +export function readStringArray(src, constName, opts = {}) { + const tokens = parseArrayTokens(src, constName); + return tokens.map((token) => { + if (isStringLiteral(token)) return unquote(token); + const resolved = resolveIdentifier(token, { guardSrc: src, ...opts }); + if (resolved === null) { + throw new Error( + `[routeGuardConstants] could not resolve \`${token}\` used in ${constName}. ` + + `Add a resolvable \`export const ${token} = "…"\` or inline the literal — ` + + `leaving it unresolved would make the gate report protected routes as open.` + ); + } + return resolved; + }); +} + +/** RegExp entries of a pattern array (regex literals only). */ +export function readRegexArray(src, constName) { + return parseArrayTokens(src, constName) + .filter((token) => token.startsWith("/")) + .map((token) => { + const body = token.match(/^\/(.*)\/([a-z]*)$/s); + if (!body) + throw new Error(`[routeGuardConstants] unparsable regex in ${constName}: ${token}`); + return new RegExp(body[1], body[2]); + }); +} + +/** + * OpenAPI templates use `{param}`; the runtime sees a concrete segment. Swap the + * placeholders for a segment without slashes so `[^/]+`-style patterns match. + */ +export function concreteFromTemplate(pathStr) { + return pathStr.replace(/\{[^}]*\}/g, "_param_"); +} + +/** Mirror of routeGuard.isLocalOnlyPath() for a documented (templated) path. */ +export function isLocalOnlyDocPath(pathStr, { prefixes = [], patterns = [] } = {}) { + const concrete = concreteFromTemplate(pathStr); + const underPrefix = prefixes.some((prefix) => { + const norm = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix; + return pathStr === norm || pathStr.startsWith(`${norm}/`); + }); + return underPrefix || patterns.some((re) => re.test(concrete) || re.test(pathStr)); +} diff --git a/tests/unit/build/route-guard-constants.test.ts b/tests/unit/build/route-guard-constants.test.ts new file mode 100644 index 0000000000..e692bb684a --- /dev/null +++ b/tests/unit/build/route-guard-constants.test.ts @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { test } from "node:test"; + +import { + concreteFromTemplate, + isLocalOnlyDocPath, + parseArrayTokens, + readRegexArray, + readStringArray, + resolveIdentifier, + stripLineComments, +} from "../../../scripts/check/routeGuardConstants.mjs"; + +const ROOT = path.resolve(import.meta.dirname, "..", "..", ".."); +const GUARD_SRC = fs.readFileSync( + path.join(ROOT, "src", "server", "authz", "routeGuard.ts"), + "utf-8" +); + +test("parseArrayTokens survives regex literals whose char classes hold ] and ,", () => { + const src = [ + "export const PATTERNS: ReadonlyArray = [", + " /^\\/api\\/providers\\/[^/]+\\/login\\/?$/, // inline note", + " /^\\/api\\/x\\/[a-z]{1,3}$/,", + "];", + ].join("\n"); + assert.deepEqual(parseArrayTokens(src, "PATTERNS"), [ + "/^\\/api\\/providers\\/[^/]+\\/login\\/?$/", + "/^\\/api\\/x\\/[a-z]{1,3}$/", + ]); +}); + +test("stripLineComments keeps escaped slashes and URLs, drops real comments", () => { + assert.equal(stripLineComments("/a\\/\\/b/, // note"), "/a\\/\\/b/, "); + assert.equal(stripLineComments('"https://x", // note'), '"https://x", '); + assert.equal(stripLineComments("// whole line"), ""); +}); + +test("resolveIdentifier follows a named import to its exported literal", () => { + const guardSrc = 'import { VNC_ROUTE_PREFIX } from "@/lib/vncSession/manifest";'; + const readFile = (p: string) => + p.endsWith(path.join("src", "lib", "vncSession", "manifest.ts")) + ? 'export const VNC_ROUTE_PREFIX = "/api/vnc-session";' + : null; + assert.equal( + resolveIdentifier("VNC_ROUTE_PREFIX", { guardSrc, root: ROOT, readFile }), + "/api/vnc-session" + ); + assert.equal(resolveIdentifier("MISSING", { guardSrc, root: ROOT, readFile }), null); +}); + +test("readStringArray throws instead of degrading an unresolvable token into a literal", () => { + const src = 'export const P: ReadonlyArray = ["/api/a", SOME_CONST];'; + assert.throws( + () => readStringArray(src, "P", { root: ROOT, readFile: () => null }), + /SOME_CONST/ + ); +}); + +test("concreteFromTemplate replaces OpenAPI placeholders with a slash-free segment", () => { + assert.equal(concreteFromTemplate("/api/x/{id}/y"), "/api/x/_param_/y"); +}); + +test("isLocalOnlyDocPath honours prefixes AND patterns, and stays closed otherwise", () => { + const guards = { + prefixes: ["/api/mcp/", "/api/vnc-session"], + patterns: [/^\/api\/providers\/[^/]+\/login\/?$/], + }; + assert.equal(isLocalOnlyDocPath("/api/vnc-session/{params}", guards), true); + assert.equal(isLocalOnlyDocPath("/api/mcp/tools", guards), true); + assert.equal(isLocalOnlyDocPath("/api/providers/{provider}/login", guards), true); + assert.equal(isLocalOnlyDocPath("/api/providers/{provider}/refresh", guards), false); + assert.equal(isLocalOnlyDocPath("/v1/chat/completions", guards), false); +}); + +// Regression guard for the real file: reading only LOCAL_ONLY_API_PREFIXES made +// the gate report the VNC (imported constant) and volcengine-plan (regex) routes +// as unprotected, and demand the removal of a correct x-loopback-only annotation. +test("the real routeGuard constants cover the imported-constant and regex-gated routes", () => { + const guards = { + prefixes: readStringArray(GUARD_SRC, "LOCAL_ONLY_API_PREFIXES", { root: ROOT }), + patterns: readRegexArray(GUARD_SRC, "LOCAL_ONLY_API_PATTERNS"), + }; + assert.ok(guards.prefixes.includes("/api/vnc-session"), "VNC_ROUTE_PREFIX must resolve"); + assert.ok(guards.patterns.length > 0, "pattern list must be parsed"); + + for (const documented of [ + "/api/vnc-session", + "/api/vnc-session/{params}", + "/api/providers/volcengine-plan/connect/{sessionId}/status", + "/api/providers/volcengine-plan/connect/{sessionId}/resend", + ]) { + assert.equal(isLocalOnlyDocPath(documented, guards), true, `${documented} must be LOCAL_ONLY`); + } + + // Control: a deliberately remote-reachable provider route stays open. + assert.equal(isLocalOnlyDocPath("/api/providers/{id}/refresh", guards), false); +});