mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-27 01:22:10 +03:00
Compare commits
4 Commits
fix/codeql
...
fix/11526-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d2abc313e | ||
|
|
91aeca0440 | ||
|
|
49749eb0d0 | ||
|
|
8d17110082 |
@@ -0,0 +1 @@
|
||||
- **fix(sse):** cap the upstream headers-wait phase for STREAMING requests to a client-realistic ceiling (110s, under Codex's own ~120s hard client-abort window) instead of the flat 10-minute `FETCH_TIMEOUT_MS` default — that default was 5x longer than the body-phase readiness watchdog's own adaptive bound, so a request whose upstream never returned any response at all (not even headers, e.g. a stalled NVIDIA target behind a tool-heavy Responses→Chat translation) kept the client connection alive on keepalives only, guaranteeing the client's own patience ran out first with an opaque 499 instead of OmniRoute detecting and failing the stall fast. Non-streaming requests are unaffected — they keep the existing flat default (`open-sse/utils/fetchStartTimeoutPolicy.ts`) (#11526)
|
||||
@@ -1,5 +1,6 @@
|
||||
import { HTTP_STATUS, FETCH_TIMEOUT_MS } from "../config/constants.ts";
|
||||
import { getRegistryEntry } from "../config/providerRegistry.ts";
|
||||
import { resolveFetchStartTimeout } from "../utils/fetchStartTimeoutPolicy.ts";
|
||||
import {
|
||||
resolveAlternateFormat,
|
||||
type AlternateFormat,
|
||||
@@ -902,9 +903,24 @@ export class BaseExecutor {
|
||||
clampNestedThinkingBudget(transformedBody, thinkingBudgetClampedMax);
|
||||
}
|
||||
|
||||
// Timeout only covers response start; stream stalls are handled downstream.
|
||||
// #11526: streaming requests cap the headers-wait phase to a client-realistic
|
||||
// ceiling (see fetchStartTimeoutPolicy.ts) — non-streaming keeps the flat default.
|
||||
// Declared outside the try/catch below so the catch's TIMEOUT log (on the
|
||||
// error path) reports the same effective value the fetch actually used.
|
||||
const fetchStartTimeoutPolicy = resolveFetchStartTimeout({
|
||||
baseTimeoutMs: this.getTimeoutMs(),
|
||||
stream,
|
||||
});
|
||||
const fetchStartTimeoutMs = fetchStartTimeoutPolicy.timeoutMs;
|
||||
if (fetchStartTimeoutPolicy.capped) {
|
||||
log?.debug?.(
|
||||
"TIMEOUT",
|
||||
`fetch-start timeout capped ${fetchStartTimeoutPolicy.baseTimeoutMs}ms -> ${fetchStartTimeoutMs}ms (streaming)`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Timeout only covers response start; stream stalls are handled downstream.
|
||||
const fetchStartTimeoutMs = this.getTimeoutMs();
|
||||
const fetchWithStartTimeout = async (requestUrl: string, requestOptions: RequestInit) => {
|
||||
// GHSA-4f49: guard here (not only next to the first buildUrl) so retries
|
||||
// and fallback URLs are validated too, before any bytes leave the host.
|
||||
@@ -1713,7 +1729,7 @@ export class BaseExecutor {
|
||||
// Distinguish timeout errors from other abort errors
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
if (err.name === "TimeoutError") {
|
||||
log?.warn?.("TIMEOUT", `Fetch timeout after ${this.getTimeoutMs()}ms on ${url}`);
|
||||
log?.warn?.("TIMEOUT", `Fetch timeout after ${fetchStartTimeoutMs}ms on ${url}`);
|
||||
}
|
||||
lastError = err;
|
||||
if (!skipUpstreamRetry && urlIndex + 1 < fallbackCount) {
|
||||
|
||||
@@ -90,13 +90,19 @@ async function resolveZaiBrowserAttachments(
|
||||
> {
|
||||
try {
|
||||
// Browser-page upload: keep the original bytes/mimeType (no Cursor wire prep).
|
||||
// EncodedImage.mimeType is optional on the wire type, but every producer
|
||||
// reachable here (decodeDataUrl / fetchImageBytes) validates an image/*
|
||||
// string before pushing; the fallback only satisfies the attachment type.
|
||||
const images = await resolveCursorImages(imageUrls, { prepareForWire: false });
|
||||
return {
|
||||
attachments: images.map((image, index) => ({
|
||||
name: zaiImageFileName(image.mimeType, index),
|
||||
mimeType: image.mimeType,
|
||||
buffer: image.data,
|
||||
})),
|
||||
attachments: images.map((image, index) => {
|
||||
const mimeType = image.mimeType ?? "image/jpeg";
|
||||
return {
|
||||
name: zaiImageFileName(mimeType, index),
|
||||
mimeType,
|
||||
buffer: image.data,
|
||||
};
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
const message =
|
||||
|
||||
@@ -15,6 +15,7 @@ interface ErrorResponseBody {
|
||||
message: string;
|
||||
type?: string;
|
||||
code?: string;
|
||||
reason?: string;
|
||||
};
|
||||
upstream_details?: Record<string, unknown> | null; // sanitized upstream provider body
|
||||
}
|
||||
@@ -108,6 +109,7 @@ export function sanitizeUpstreamDetails(value: unknown, depth = 0): unknown {
|
||||
export type ErrorBodyClassification = {
|
||||
type?: string;
|
||||
code?: string;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -132,6 +134,7 @@ export function buildErrorBody(
|
||||
message: safeMessage,
|
||||
type: classification?.type ?? errorInfo.type,
|
||||
code: classification?.code ?? errorInfo.code,
|
||||
reason: classification?.reason,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
52
open-sse/utils/fetchStartTimeoutPolicy.ts
Normal file
52
open-sse/utils/fetchStartTimeoutPolicy.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// #11526: the fetch-start (headers-wait) phase had no ceiling comparable to a
|
||||
// real client's patience for STREAMING requests — it inherited the flat,
|
||||
// non-adaptive FETCH_TIMEOUT_MS (default 600_000ms / 10 minutes), five times
|
||||
// longer than Codex's own ~120s hard client-abort window. When an upstream
|
||||
// never returns a response at all (not even headers), OmniRoute kept the
|
||||
// connection open with nothing but keepalives, guaranteeing the client gave
|
||||
// up first with an opaque 499 instead of OmniRoute detecting the stall and
|
||||
// failing fast/over within a client-realistic window.
|
||||
//
|
||||
// This mirrors the adaptive philosophy of streamReadinessPolicy.ts's
|
||||
// resolveStreamReadinessTimeout (which already protects the BODY phase, after
|
||||
// headers arrive) but inverted: instead of bumping a small base timeout up for
|
||||
// heavy payloads, it caps an oversized base timeout down for the HEADERS
|
||||
// phase of streaming requests specifically. Non-streaming requests are left
|
||||
// on the existing flat default — providers that are legitimately slow to
|
||||
// accept a connection (but not streaming SSE) are unaffected.
|
||||
|
||||
export type FetchStartTimeoutPolicyInput = {
|
||||
baseTimeoutMs: number;
|
||||
/** Only streaming requests are capped — non-streaming keeps the flat default. */
|
||||
stream?: boolean | null;
|
||||
capMs?: number;
|
||||
};
|
||||
|
||||
export type FetchStartTimeoutPolicyResult = {
|
||||
timeoutMs: number;
|
||||
baseTimeoutMs: number;
|
||||
/** True when the base timeout was reduced by the streaming cap. */
|
||||
capped: boolean;
|
||||
};
|
||||
|
||||
// Codex's documented hard client-abort window for a stalled turn (nothing but
|
||||
// keepalives in flight) is ~120s. Keep the cap safely under that so OmniRoute's
|
||||
// own headers-phase watchdog always fires before the client gives up on its own.
|
||||
export const CODEX_CLIENT_ABORT_MS = 120_000;
|
||||
export const DEFAULT_FETCH_START_TIMEOUT_CAP_MS = 110_000;
|
||||
|
||||
export function resolveFetchStartTimeout(
|
||||
input: FetchStartTimeoutPolicyInput
|
||||
): FetchStartTimeoutPolicyResult {
|
||||
const baseTimeoutMs = Math.max(0, Math.floor(input.baseTimeoutMs || 0));
|
||||
if (baseTimeoutMs <= 0 || !input.stream) {
|
||||
return { timeoutMs: baseTimeoutMs, baseTimeoutMs, capped: false };
|
||||
}
|
||||
|
||||
const capMs = Math.max(0, Math.floor(input.capMs ?? DEFAULT_FETCH_START_TIMEOUT_CAP_MS));
|
||||
if (capMs <= 0 || baseTimeoutMs <= capMs) {
|
||||
return { timeoutMs: baseTimeoutMs, baseTimeoutMs, capped: false };
|
||||
}
|
||||
|
||||
return { timeoutMs: capMs, baseTimeoutMs, capped: true };
|
||||
}
|
||||
@@ -19,12 +19,20 @@
|
||||
*/
|
||||
|
||||
const KEY = process.env.NVIDIA_API_KEY ?? "";
|
||||
const BASE_URL = process.env.NVIDIA_BASE_URL || "https://integrate.api.nvidia.com/v1/chat/completions";
|
||||
const BASE_URL =
|
||||
process.env.NVIDIA_BASE_URL || "https://integrate.api.nvidia.com/v1/chat/completions";
|
||||
const MODEL = process.env.NVIDIA_MODEL || "openai/gpt-oss-120b";
|
||||
|
||||
// Neutralize CR/LF before logging so env-derived values (NVIDIA_MODEL, etc.)
|
||||
// cannot forge extra log lines (S5145 log injection).
|
||||
const line = (s = "") => console.log(String(s).replace(/[\r\n]+/g, " "));
|
||||
// cannot forge extra log lines (S5145 log injection). Also strip any raw
|
||||
// occurrence of the API key so an upstream error/response that echoes it
|
||||
// back (e.g. inside err.stack or a validation result) never reaches the
|
||||
// terminal in clear text (js/clear-text-logging, CWE-312/532).
|
||||
const line = (s = "") => {
|
||||
let out = String(s).replace(/[\r\n]+/g, " ");
|
||||
if (KEY) out = out.split(KEY).join("[REDACTED]");
|
||||
console.log(out);
|
||||
};
|
||||
const hr = () => line("─".repeat(72));
|
||||
|
||||
function show(label: string, value: unknown) {
|
||||
@@ -52,8 +60,13 @@ async function partA() {
|
||||
});
|
||||
line(" ✅ validateProviderApiKey retornou (sem crash):");
|
||||
show("resultado", result);
|
||||
if (typeof (result as any)?.error === "string" && (result as any).error.includes("startsWith")) {
|
||||
line(" ⚠️ A mensagem de erro contém 'startsWith' → crash CAPTURADO dentro do try/catch da validação.");
|
||||
if (
|
||||
typeof (result as any)?.error === "string" &&
|
||||
(result as any).error.includes("startsWith")
|
||||
) {
|
||||
line(
|
||||
" ⚠️ A mensagem de erro contém 'startsWith' → crash CAPTURADO dentro do try/catch da validação."
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
line(" ❌ validateProviderApiKey LANÇOU (crash não tratado):");
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
statSync,
|
||||
chmodSync,
|
||||
} from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { join, dirname, relative } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { assembleStandalone } from "./assembleStandalone.mjs";
|
||||
@@ -35,6 +35,12 @@ import {
|
||||
APP_STAGING_REMOVAL_PATHS,
|
||||
findUnexpectedArtifactPaths,
|
||||
} from "./pack-artifact-policy.ts";
|
||||
import {
|
||||
collectWorkspaceVersions,
|
||||
findPackageJsonFiles,
|
||||
hasWorkspaceProtocol,
|
||||
resolvePackageJsonWorkspaceProtocols,
|
||||
} from "./resolveWorkspaceProtocols.ts";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
@@ -707,6 +713,33 @@ if (remainingUnexpectedFiles.length > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// -- Step 11: Resolve workspace: protocol dependencies -----------------
|
||||
// npm/pnpm workspace protocol specifiers (workspace:*, workspace:^, ...)
|
||||
// are meaningless to the npm registry and make `npm install -g omniroute`
|
||||
// fail with EUNSUPPORTEDPROTOCOL. Rewrite any that leaked into published
|
||||
// package.json files to the concrete workspace package version.
|
||||
// Only touch files inside the staged dist/ tree; workspace member source
|
||||
// package.json files must never be mutated by the publish step.
|
||||
const workspaceVersions = collectWorkspaceVersions(ROOT);
|
||||
const publishablePackageJsonDirs = [DIST_DIR];
|
||||
const publishablePackageJsonPaths = publishablePackageJsonDirs
|
||||
.flatMap((dir) => (existsSync(dir) ? findPackageJsonFiles(dir) : []))
|
||||
.filter((filePath) => existsSync(filePath));
|
||||
|
||||
for (const pkgJsonPath of publishablePackageJsonPaths) {
|
||||
let pkg: Record<string, unknown>;
|
||||
try {
|
||||
pkg = JSON.parse(readFileSync(pkgJsonPath, "utf8")) as Record<string, unknown>;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!hasWorkspaceProtocol(pkg)) continue;
|
||||
|
||||
const resolved = resolvePackageJsonWorkspaceProtocols(pkg, workspaceVersions);
|
||||
writeFileSync(pkgJsonPath, JSON.stringify(resolved, null, 2) + "\n");
|
||||
console.log(` [resolved] Resolved workspace: protocols in ${relative(ROOT, pkgJsonPath)}`);
|
||||
}
|
||||
|
||||
// ── Done ───────────────────────────────────────────────────
|
||||
const distPkg = join(DIST_DIR, "package.json");
|
||||
if (existsSync(distPkg)) {
|
||||
|
||||
228
scripts/build/resolveWorkspaceProtocols.ts
Normal file
228
scripts/build/resolveWorkspaceProtocols.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Resolve pnpm/npm workspace protocol dependencies to concrete semver versions.
|
||||
*
|
||||
* The npm registry clients cannot parse `workspace:` specifiers. During prepublish
|
||||
* we rewrite any `workspace:*`, `workspace:^`, `workspace:~` (or explicit
|
||||
* `workspace:<range>`) dependency declarations to the matching workspace package's
|
||||
* actual version before npm pack/publish sees them.
|
||||
*/
|
||||
|
||||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import * as yaml from "js-yaml";
|
||||
|
||||
const WORKSPACE_PROTOCOL_RE = /^workspace:/;
|
||||
|
||||
const DEPENDENCY_FIELDS = [
|
||||
"dependencies",
|
||||
"devDependencies",
|
||||
"peerDependencies",
|
||||
"optionalDependencies",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Parse a simple workspace glob entry into concrete directories relative to a root.
|
||||
* Supports entries like "packages/*" and literal directory names like "open-sse".
|
||||
*/
|
||||
function expandWorkspaceEntry(root: string, entry: string): string[] {
|
||||
const trimmed = entry.trim();
|
||||
if (!trimmed) return [];
|
||||
if (!trimmed.endsWith("/*")) {
|
||||
const dir = join(root, trimmed);
|
||||
try {
|
||||
return statSync(dir).isDirectory() ? [dir] : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
const parent = join(root, trimmed.slice(0, -2));
|
||||
let entries: string[] = [];
|
||||
try {
|
||||
entries = readdirSync(parent);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return entries
|
||||
.map((name) => join(parent, name))
|
||||
.filter((dir) => {
|
||||
try {
|
||||
return statSync(dir).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the root package.json and, if present, pnpm-workspace.yaml to discover
|
||||
* workspace member directories. Returns a map of package name -> version.
|
||||
*/
|
||||
export function collectWorkspaceVersions(projectRoot: string): Map<string, string> {
|
||||
const versions = new Map<string, string>();
|
||||
|
||||
const rootPkgPath = join(projectRoot, "package.json");
|
||||
let workspaceEntries: string[] = [];
|
||||
try {
|
||||
const rootPkg = JSON.parse(readFileSync(rootPkgPath, "utf8")) as {
|
||||
workspaces?: string[];
|
||||
};
|
||||
if (Array.isArray(rootPkg.workspaces)) {
|
||||
workspaceEntries.push(...rootPkg.workspaces);
|
||||
}
|
||||
} catch {
|
||||
// ignore unreadable root package.json
|
||||
}
|
||||
|
||||
const pnpmWorkspacePath = join(projectRoot, "pnpm-workspace.yaml");
|
||||
try {
|
||||
const yamlContent = readFileSync(pnpmWorkspacePath, "utf8");
|
||||
const doc = yaml.load(yamlContent) as { packages?: unknown } | null | undefined;
|
||||
if (doc && Array.isArray(doc.packages)) {
|
||||
for (const entry of doc.packages) {
|
||||
if (typeof entry === "string" && entry) {
|
||||
workspaceEntries.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore missing or malformed pnpm-workspace.yaml
|
||||
}
|
||||
|
||||
const seenDirs = new Set<string>();
|
||||
for (const entry of workspaceEntries) {
|
||||
for (const dir of expandWorkspaceEntry(projectRoot, entry)) {
|
||||
if (seenDirs.has(dir)) continue;
|
||||
seenDirs.add(dir);
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as {
|
||||
name?: string;
|
||||
version?: string;
|
||||
};
|
||||
if (pkg.name && pkg.version) {
|
||||
versions.set(pkg.name, pkg.version);
|
||||
}
|
||||
} catch {
|
||||
// skip unreadable workspace member package.json
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return versions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve workspace protocol dependencies inside a package.json object.
|
||||
*
|
||||
* Replaces `workspace:*`, `workspace:^`, `workspace:~`, `workspace:<range>`,
|
||||
* and `workspace:<packageName>` with the concrete version of the referenced
|
||||
* workspace package. Throws if a workspace specifier cannot be resolved.
|
||||
*/
|
||||
export function resolvePackageJsonWorkspaceProtocols(
|
||||
pkg: Record<string, unknown>,
|
||||
workspaceVersions: Map<string, string>
|
||||
): Record<string, unknown> {
|
||||
const resolved: Record<string, unknown> = { ...pkg };
|
||||
|
||||
for (const field of DEPENDENCY_FIELDS) {
|
||||
const deps = pkg[field];
|
||||
if (!deps || typeof deps !== "object" || Array.isArray(deps)) continue;
|
||||
|
||||
const resolvedDeps: Record<string, string> = {};
|
||||
let changed = false;
|
||||
for (const [depName, versionSpec] of Object.entries(deps as Record<string, unknown>)) {
|
||||
if (typeof versionSpec !== "string") {
|
||||
resolvedDeps[depName] = String(versionSpec ?? "");
|
||||
continue;
|
||||
}
|
||||
if (!WORKSPACE_PROTOCOL_RE.test(versionSpec)) {
|
||||
resolvedDeps[depName] = versionSpec;
|
||||
continue;
|
||||
}
|
||||
|
||||
const body = versionSpec.slice("workspace:".length);
|
||||
let concrete: string | undefined;
|
||||
|
||||
if (body === "*") {
|
||||
concrete = workspaceVersions.get(depName);
|
||||
} else if (body === "^") {
|
||||
const version = workspaceVersions.get(depName);
|
||||
concrete = version ? `^${version}` : undefined;
|
||||
} else if (body === "~") {
|
||||
const version = workspaceVersions.get(depName);
|
||||
concrete = version ? `~${version}` : undefined;
|
||||
} else if (body.startsWith("^") || body.startsWith("~") || /^[\d<>=]/.test(body)) {
|
||||
// Explicit range inside workspace: protocol - strip the protocol prefix.
|
||||
concrete = body;
|
||||
} else {
|
||||
// workspace:<packageName> - resolve to that package's version.
|
||||
concrete = workspaceVersions.get(body);
|
||||
}
|
||||
|
||||
if (concrete) {
|
||||
resolvedDeps[depName] = concrete;
|
||||
changed = true;
|
||||
} else {
|
||||
throw new Error(
|
||||
`Cannot resolve workspace protocol "${versionSpec}" for dependency "${depName}". ` +
|
||||
"Make sure the referenced package is a declared workspace member with a version."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
resolved[field] = resolvedDeps;
|
||||
}
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if any dependency field in the package contains a workspace: specifier.
|
||||
*/
|
||||
export function hasWorkspaceProtocol(pkg: Record<string, unknown>): boolean {
|
||||
for (const field of DEPENDENCY_FIELDS) {
|
||||
const deps = pkg[field];
|
||||
if (!deps || typeof deps !== "object" || Array.isArray(deps)) continue;
|
||||
for (const versionSpec of Object.values(deps as Record<string, unknown>)) {
|
||||
if (typeof versionSpec === "string" && WORKSPACE_PROTOCOL_RE.test(versionSpec)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively walk a directory and return every package.json path found.
|
||||
* Stops descending after maxDepth to avoid runaway recursion on deep trees.
|
||||
*/
|
||||
export function findPackageJsonFiles(dir: string, maxDepth = 10): string[] {
|
||||
const results: string[] = [];
|
||||
if (maxDepth < 0) return results;
|
||||
|
||||
let entries: string[] = [];
|
||||
try {
|
||||
entries = readdirSync(dir);
|
||||
} catch {
|
||||
return results;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry === "node_modules") continue;
|
||||
const fullPath = join(dir, entry);
|
||||
let stat;
|
||||
try {
|
||||
stat = statSync(fullPath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (stat.isDirectory()) {
|
||||
results.push(...findPackageJsonFiles(fullPath, maxDepth - 1));
|
||||
} else if (entry === "package.json") {
|
||||
results.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
@@ -66,8 +66,8 @@ export function structuralRejectionResponse(status: 413 | 503, maxMessages: numb
|
||||
{
|
||||
type: historyLimit ? "payload_too_large" : "server_error",
|
||||
code: historyLimit ? "chat_history_too_large" : "chat_admission_busy",
|
||||
reason: historyLimit ? "message_limit" : "structure_limit",
|
||||
}
|
||||
);
|
||||
body.error.reason = historyLimit ? "message_limit" : "structure_limit";
|
||||
return new Response(JSON.stringify(body), { status, headers });
|
||||
}
|
||||
|
||||
269
tests/unit/build/pack-no-workspace-protocol.test.ts
Normal file
269
tests/unit/build/pack-no-workspace-protocol.test.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
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 {
|
||||
collectWorkspaceVersions,
|
||||
resolvePackageJsonWorkspaceProtocols,
|
||||
hasWorkspaceProtocol,
|
||||
findPackageJsonFiles,
|
||||
} from "../../../scripts/build/resolveWorkspaceProtocols.ts";
|
||||
|
||||
function tmpDir(prefix: string): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function writeJson(filePath: string, data: unknown): void {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
function readJson(filePath: string): Record<string, unknown> {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf8")) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
test("resolvePackageJsonWorkspaceProtocols replaces workspace:*, workspace:^, workspace:~", () => {
|
||||
const versions = new Map([
|
||||
["@omniroute/open-sse", "3.8.51"],
|
||||
["@omniroute/shared", "1.2.3"],
|
||||
]);
|
||||
|
||||
const resolved = resolvePackageJsonWorkspaceProtocols(
|
||||
{
|
||||
name: "omniroute",
|
||||
version: "3.8.51",
|
||||
dependencies: {
|
||||
"@omniroute/open-sse": "workspace:^",
|
||||
"@omniroute/shared": "workspace:*",
|
||||
lodash: "^4.17.0",
|
||||
},
|
||||
devDependencies: {
|
||||
"@omniroute/open-sse": "workspace:~",
|
||||
},
|
||||
peerDependencies: {
|
||||
"@omniroute/shared": "workspace:1.2.3",
|
||||
},
|
||||
optionalDependencies: {
|
||||
"@omniroute/open-sse": "workspace:>=3.0.0",
|
||||
},
|
||||
},
|
||||
versions
|
||||
);
|
||||
|
||||
assert.equal((resolved.dependencies as Record<string, string>)["@omniroute/open-sse"], "^3.8.51");
|
||||
assert.equal((resolved.dependencies as Record<string, string>)["@omniroute/shared"], "1.2.3");
|
||||
assert.equal((resolved.dependencies as Record<string, string>).lodash, "^4.17.0");
|
||||
assert.equal(
|
||||
(resolved.devDependencies as Record<string, string>)["@omniroute/open-sse"],
|
||||
"~3.8.51"
|
||||
);
|
||||
assert.equal((resolved.peerDependencies as Record<string, string>)["@omniroute/shared"], "1.2.3");
|
||||
assert.equal(
|
||||
(resolved.optionalDependencies as Record<string, string>)["@omniroute/open-sse"],
|
||||
">=3.0.0"
|
||||
);
|
||||
});
|
||||
|
||||
test("resolvePackageJsonWorkspaceProtocols leaves non-workspace specs untouched", () => {
|
||||
const resolved = resolvePackageJsonWorkspaceProtocols(
|
||||
{
|
||||
name: "x",
|
||||
dependencies: {
|
||||
a: "^1.0.0",
|
||||
b: "file:../b",
|
||||
c: "npm:alias@1.0.0",
|
||||
},
|
||||
},
|
||||
new Map()
|
||||
);
|
||||
|
||||
assert.equal((resolved.dependencies as Record<string, string>).a, "^1.0.0");
|
||||
assert.equal((resolved.dependencies as Record<string, string>).b, "file:../b");
|
||||
assert.equal((resolved.dependencies as Record<string, string>).c, "npm:alias@1.0.0");
|
||||
assert.equal(hasWorkspaceProtocol(resolved), false);
|
||||
});
|
||||
|
||||
test("resolvePackageJsonWorkspaceProtocols throws for unresolvable workspace protocol", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
resolvePackageJsonWorkspaceProtocols(
|
||||
{
|
||||
name: "x",
|
||||
dependencies: {
|
||||
"@missing/pkg": "workspace:^",
|
||||
},
|
||||
},
|
||||
new Map()
|
||||
),
|
||||
/Cannot resolve workspace protocol/
|
||||
);
|
||||
});
|
||||
|
||||
test("collectWorkspaceVersions reads npm workspaces and pnpm-workspace.yaml", () => {
|
||||
const root = tmpDir("workspace-versions-");
|
||||
|
||||
writeJson(path.join(root, "package.json"), {
|
||||
name: "root",
|
||||
version: "0.0.0",
|
||||
workspaces: ["packages/*", "open-sse"],
|
||||
});
|
||||
|
||||
fs.mkdirSync(path.join(root, "packages", "a"), { recursive: true });
|
||||
writeJson(path.join(root, "packages", "a", "package.json"), {
|
||||
name: "@scope/a",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
fs.mkdirSync(path.join(root, "open-sse"), { recursive: true });
|
||||
writeJson(path.join(root, "open-sse", "package.json"), {
|
||||
name: "@scope/open-sse",
|
||||
version: "2.0.0",
|
||||
});
|
||||
|
||||
// pnpm-workspace.yaml adds an extra directory not in npm workspaces.
|
||||
fs.mkdirSync(path.join(root, "packages", "b"), { recursive: true });
|
||||
writeJson(path.join(root, "packages", "b", "package.json"), {
|
||||
name: "@scope/b",
|
||||
version: "3.0.0",
|
||||
});
|
||||
fs.writeFileSync(path.join(root, "pnpm-workspace.yaml"), "packages:\n - 'packages/*'\n");
|
||||
|
||||
const versions = collectWorkspaceVersions(root);
|
||||
assert.equal(versions.get("@scope/a"), "1.0.0");
|
||||
assert.equal(versions.get("@scope/open-sse"), "2.0.0");
|
||||
assert.equal(versions.get("@scope/b"), "3.0.0");
|
||||
});
|
||||
|
||||
test("findPackageJsonFiles skips node_modules and respects maxDepth", () => {
|
||||
const root = tmpDir("pkg-json-files-");
|
||||
fs.mkdirSync(path.join(root, "a"), { recursive: true });
|
||||
writeJson(path.join(root, "a", "package.json"), {});
|
||||
fs.mkdirSync(path.join(root, "node_modules", "x"), { recursive: true });
|
||||
writeJson(path.join(root, "node_modules", "x", "package.json"), {});
|
||||
|
||||
const files = findPackageJsonFiles(root);
|
||||
assert.equal(files.length, 1);
|
||||
assert.ok(files[0].endsWith(path.join("a", "package.json")));
|
||||
|
||||
// Build a deep tree and confirm maxDepth bounds the walk.
|
||||
const deep = tmpDir("pkg-json-deep-");
|
||||
let current = deep;
|
||||
for (let i = 0; i < 12; i += 1) {
|
||||
current = path.join(current, `level${i}`);
|
||||
fs.mkdirSync(current, { recursive: true });
|
||||
}
|
||||
writeJson(path.join(current, "package.json"), {});
|
||||
assert.equal(findPackageJsonFiles(deep, 10).length, 0);
|
||||
assert.equal(findPackageJsonFiles(deep, 12).length, 1);
|
||||
});
|
||||
|
||||
test("collectWorkspaceVersions parses pnpm-workspace.yaml with js-yaml", () => {
|
||||
const root = tmpDir("pnpm-yaml-");
|
||||
|
||||
writeJson(path.join(root, "package.json"), { name: "root", version: "0.0.0" });
|
||||
|
||||
// Flow-style array, nested quotes, comments inside the packages list, and an
|
||||
// unrelated top-level key before packages are all valid YAML that the old line
|
||||
// scanner could not handle.
|
||||
fs.writeFileSync(
|
||||
path.join(root, "pnpm-workspace.yaml"),
|
||||
"preferWorkspacePackages: true\n" +
|
||||
"packages:\n" +
|
||||
' - "packages/*"\n' +
|
||||
" - 'apps/*'\n" +
|
||||
" # comment inside the list\n" +
|
||||
" - open-sse\n"
|
||||
);
|
||||
|
||||
fs.mkdirSync(path.join(root, "packages", "a"), { recursive: true });
|
||||
writeJson(path.join(root, "packages", "a", "package.json"), {
|
||||
name: "@scope/a",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
fs.mkdirSync(path.join(root, "apps", "web"), { recursive: true });
|
||||
writeJson(path.join(root, "apps", "web", "package.json"), {
|
||||
name: "@scope/web",
|
||||
version: "2.0.0",
|
||||
});
|
||||
|
||||
fs.mkdirSync(path.join(root, "open-sse"), { recursive: true });
|
||||
writeJson(path.join(root, "open-sse", "package.json"), {
|
||||
name: "@scope/open-sse",
|
||||
version: "3.0.0",
|
||||
});
|
||||
|
||||
const versions = collectWorkspaceVersions(root);
|
||||
assert.equal(versions.get("@scope/a"), "1.0.0");
|
||||
assert.equal(versions.get("@scope/web"), "2.0.0");
|
||||
assert.equal(versions.get("@scope/open-sse"), "3.0.0");
|
||||
});
|
||||
|
||||
test("prepublish Step 11 fixture resolves workspace: protocols in dist package.json files", () => {
|
||||
const root = tmpDir("prepublish-step11-");
|
||||
const distDir = path.join(root, "dist");
|
||||
|
||||
// Workspace member source files contain a workspace: specifier (simulating the
|
||||
// monorepo source). They must NOT be mutated by the publish step.
|
||||
fs.mkdirSync(path.join(root, "packages", "shared"), { recursive: true });
|
||||
const sourcePkgPath = path.join(root, "packages", "shared", "package.json");
|
||||
writeJson(sourcePkgPath, {
|
||||
name: "@scope/shared",
|
||||
version: "1.2.3",
|
||||
dependencies: {
|
||||
"@scope/other": "workspace:*",
|
||||
},
|
||||
});
|
||||
|
||||
fs.mkdirSync(path.join(root, "packages", "other"), { recursive: true });
|
||||
writeJson(path.join(root, "packages", "other", "package.json"), {
|
||||
name: "@scope/other",
|
||||
version: "4.5.6",
|
||||
});
|
||||
|
||||
writeJson(path.join(root, "package.json"), {
|
||||
name: "root",
|
||||
version: "0.0.0",
|
||||
workspaces: ["packages/*"],
|
||||
});
|
||||
|
||||
// The staged dist/ package.json contains workspace: specifiers that leaked
|
||||
// into the publish artifact and must be rewritten to concrete versions.
|
||||
fs.mkdirSync(distDir, { recursive: true });
|
||||
const distPkgPath = path.join(distDir, "package.json");
|
||||
writeJson(distPkgPath, {
|
||||
name: "omniroute",
|
||||
version: "3.8.51",
|
||||
dependencies: {
|
||||
"@scope/shared": "workspace:^",
|
||||
"@scope/other": "workspace:*",
|
||||
lodash: "^4.17.0",
|
||||
},
|
||||
});
|
||||
|
||||
// This is the same logic prepublish.ts Step 11 runs, scoped to the fixture.
|
||||
const workspaceVersions = collectWorkspaceVersions(root);
|
||||
const publishablePackageJsonPaths = findPackageJsonFiles(distDir).filter((filePath) =>
|
||||
fs.existsSync(filePath)
|
||||
);
|
||||
|
||||
for (const pkgJsonPath of publishablePackageJsonPaths) {
|
||||
const pkg = readJson(pkgJsonPath);
|
||||
if (!hasWorkspaceProtocol(pkg)) continue;
|
||||
const resolved = resolvePackageJsonWorkspaceProtocols(pkg, workspaceVersions);
|
||||
fs.writeFileSync(pkgJsonPath, JSON.stringify(resolved, null, 2) + "\n");
|
||||
}
|
||||
|
||||
// dist/package.json must have concrete versions.
|
||||
const distPkg = readJson(distPkgPath);
|
||||
assert.equal((distPkg.dependencies as Record<string, string>)["@scope/shared"], "^1.2.3");
|
||||
assert.equal((distPkg.dependencies as Record<string, string>)["@scope/other"], "4.5.6");
|
||||
assert.equal((distPkg.dependencies as Record<string, string>).lodash, "^4.17.0");
|
||||
assert.equal(hasWorkspaceProtocol(distPkg), false);
|
||||
|
||||
// Source package.json must remain untouched.
|
||||
const sourcePkg = readJson(sourcePkgPath);
|
||||
assert.equal((sourcePkg.dependencies as Record<string, string>)["@scope/other"], "workspace:*");
|
||||
});
|
||||
35
tests/unit/chat-admission-rejection-reason.test.ts
Normal file
35
tests/unit/chat-admission-rejection-reason.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
structuralRejectionResponse,
|
||||
} from "../../src/shared/middleware/chatAdmissionResponses.ts";
|
||||
|
||||
// Pins the machine-readable error.reason contract of the chat admission
|
||||
// structural rejections (#TS2339 regression guard): buildErrorBody now owns
|
||||
// the reason field via ErrorBodyClassification, so the response bodies keep
|
||||
// carrying it without post-construction mutation of an untyped field.
|
||||
test("structuralRejectionResponse 413 carries reason=message_limit classification", () => {
|
||||
const res = structuralRejectionResponse(413, 40);
|
||||
assert.equal(res.status, 413);
|
||||
assert.ok(!res.headers.has("Retry-After"), "413 is not retryable-by-header");
|
||||
return res.text().then((raw) => {
|
||||
const body = JSON.parse(raw);
|
||||
assert.equal(body.error.reason, "message_limit");
|
||||
assert.equal(body.error.type, "payload_too_large");
|
||||
assert.equal(body.error.code, "chat_history_too_large");
|
||||
assert.ok(!body.error.message.includes("at /"), "must not leak stack traces");
|
||||
});
|
||||
});
|
||||
|
||||
test("structuralRejectionResponse 503 carries reason=structure_limit and Retry-After", () => {
|
||||
const res = structuralRejectionResponse(503, 40);
|
||||
assert.equal(res.status, 503);
|
||||
assert.equal(res.headers.get("Retry-After"), "1");
|
||||
return res.text().then((raw) => {
|
||||
const body = JSON.parse(raw);
|
||||
assert.equal(body.error.reason, "structure_limit");
|
||||
assert.equal(body.error.type, "server_error");
|
||||
assert.equal(body.error.code, "chat_admission_busy");
|
||||
});
|
||||
});
|
||||
63
tests/unit/issue-11526-repro.test.ts
Normal file
63
tests/unit/issue-11526-repro.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { resolveStreamReadinessTimeout } from "../../open-sse/utils/streamReadinessPolicy.ts";
|
||||
import {
|
||||
resolveFetchStartTimeout,
|
||||
CODEX_CLIENT_ABORT_MS,
|
||||
} from "../../open-sse/utils/fetchStartTimeoutPolicy.ts";
|
||||
import { getUpstreamTimeoutConfig } from "../../src/shared/utils/runtimeTimeouts.ts";
|
||||
|
||||
function items(count: number): Array<{ role: string; content: string }> {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
role: "user",
|
||||
content: `message ${index}`,
|
||||
}));
|
||||
}
|
||||
|
||||
function tools(count: number): Array<{ type: string; name: string }> {
|
||||
return Array.from({ length: count }, (_, index) => ({ type: "function", name: `tool_${index}` }));
|
||||
}
|
||||
|
||||
test("issue #11526: body-phase readiness watchdog stays comfortably under Codex's ~120s patience for the reported tool-heavy payload shape", () => {
|
||||
const result = resolveStreamReadinessTimeout({
|
||||
baseTimeoutMs: 80_000,
|
||||
provider: "nvidia",
|
||||
model: "some-nvidia-model",
|
||||
body: { input: items(68), tools: tools(16) },
|
||||
});
|
||||
|
||||
assert.ok(
|
||||
result.timeoutMs < CODEX_CLIENT_ABORT_MS,
|
||||
`body-phase watchdog (${result.timeoutMs}ms) must stay under Codex's ~120s patience`
|
||||
);
|
||||
});
|
||||
|
||||
test("issue #11526 (fixed): headers-phase watchdog for STREAMING requests is bounded under Codex's ~120s patience", () => {
|
||||
const { fetchTimeoutMs } = getUpstreamTimeoutConfig({});
|
||||
// Default FETCH_TIMEOUT_MS (600000ms) is still the flat non-streaming baseline —
|
||||
// the fix does not touch that default, it caps how much of it a STREAMING
|
||||
// request's headers-wait phase is allowed to consume.
|
||||
assert.equal(fetchTimeoutMs, 600_000);
|
||||
|
||||
const streaming = resolveFetchStartTimeout({ baseTimeoutMs: fetchTimeoutMs, stream: true });
|
||||
assert.ok(
|
||||
streaming.timeoutMs <= CODEX_CLIENT_ABORT_MS,
|
||||
`headers-phase watchdog for streaming requests (${streaming.timeoutMs}ms) must not exceed a realistic client abort window (${CODEX_CLIENT_ABORT_MS}ms)`
|
||||
);
|
||||
assert.ok(streaming.capped, "expected the oversized default to be capped for streaming requests");
|
||||
});
|
||||
|
||||
test("issue #11526 scope guard: non-streaming requests keep the flat FETCH_TIMEOUT_MS default", () => {
|
||||
const { fetchTimeoutMs } = getUpstreamTimeoutConfig({});
|
||||
const nonStreaming = resolveFetchStartTimeout({ baseTimeoutMs: fetchTimeoutMs, stream: false });
|
||||
|
||||
assert.equal(nonStreaming.timeoutMs, fetchTimeoutMs);
|
||||
assert.equal(nonStreaming.capped, false);
|
||||
});
|
||||
|
||||
test("issue #11526 scope guard: a base timeout already under the cap is left untouched for streaming requests", () => {
|
||||
const result = resolveFetchStartTimeout({ baseTimeoutMs: 30_000, stream: true });
|
||||
|
||||
assert.equal(result.timeoutMs, 30_000);
|
||||
assert.equal(result.capped, false);
|
||||
});
|
||||
20
tests/unit/zai-web-attachment-mime-contract.test.ts
Normal file
20
tests/unit/zai-web-attachment-mime-contract.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { resolveCursorImages } from "../../open-sse/utils/cursorImages.ts";
|
||||
|
||||
// zai-web maps resolveCursorImages() output into browser-upload attachments
|
||||
// whose mimeType is REQUIRED. EncodedImage.mimeType is optional on the wire
|
||||
// type, so zai-web carries an `?? "image/jpeg"` fallback — this test pins the
|
||||
// producer contract that makes the fallback dead code in practice: every
|
||||
// image that reaches a browser upload must arrive with a concrete image/*
|
||||
// mime string (decodeDataUrl / fetchImageBytes validate it before pushing).
|
||||
const PIXEL_PNG =
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
|
||||
|
||||
test("resolveCursorImages (prepareForWire:false) always yields a concrete image/* mimeType", async () => {
|
||||
const images = await resolveCursorImages([PIXEL_PNG], { prepareForWire: false });
|
||||
assert.equal(images.length, 1);
|
||||
assert.equal(typeof images[0]!.mimeType, "string");
|
||||
assert.match(images[0]!.mimeType as string, /^image\//);
|
||||
});
|
||||
Reference in New Issue
Block a user