mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-11 01:32:22 +03:00
fix(proxy): isolate TLS sessions by account (#9837)
Co-authored-by: Antigravity Agent (via Agisota) <agisota@users.noreply.github.com>
This commit is contained in:
@@ -644,6 +644,9 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
|
||||
# Reduces risk of JA3/JA4 fingerprint-based blocking by providers (e.g., Google).
|
||||
# Used by: open-sse/executors — replaces Node.js default TLS fingerprint.
|
||||
# ENABLE_TLS_FINGERPRINT=true
|
||||
# New proxied TLS routing requires an explicit, comma-separated provider allowlist.
|
||||
# Direct TLS keeps its legacy behavior when this is unset.
|
||||
# TLS_FINGERPRINT_PROVIDERS=codex,openai
|
||||
|
||||
# Allow the Claude Turnstile Playwright browser context to ignore HTTPS certificate errors.
|
||||
# Only enable for local debugging or trusted MITM/corporate proxy environments.
|
||||
|
||||
@@ -517,6 +517,7 @@ export async function httpBackedChat(
|
||||
headers,
|
||||
body,
|
||||
signal: signal ?? undefined,
|
||||
sessionScope: req.poolKey,
|
||||
});
|
||||
|
||||
const fetchMs = Date.now() - fetchStart;
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
proxyConfigToUrl,
|
||||
proxyUrlForLogs,
|
||||
} from "./proxyDispatcher.ts";
|
||||
import tlsClient from "./tlsClient.ts";
|
||||
import tlsClient, { type TlsFetchOptions } from "./tlsClient.ts";
|
||||
import { isProxyReachable } from "@/lib/proxyHealth";
|
||||
import {
|
||||
isControlPlaneProxyDirectFallbackEnabled,
|
||||
@@ -79,6 +79,32 @@ function isTlsFingerprintEnabled() {
|
||||
return process.env.ENABLE_TLS_FINGERPRINT === "true";
|
||||
}
|
||||
|
||||
function tlsFingerprintProviderAllowed(
|
||||
provider: string | null | undefined,
|
||||
proxied: boolean
|
||||
): boolean {
|
||||
const configured = process.env.TLS_FINGERPRINT_PROVIDERS?.trim();
|
||||
// Preserve the legacy direct-only opt-in. The new proxied transport requires
|
||||
// an explicit allowlist so enabling TLS cannot silently change proxy traffic.
|
||||
if (!configured) return !proxied;
|
||||
if (!provider) return false;
|
||||
const normalizedProvider = provider.trim().toLowerCase();
|
||||
return configured
|
||||
.split(",")
|
||||
.some((candidate) => candidate.trim().toLowerCase() === normalizedProvider);
|
||||
}
|
||||
|
||||
type TlsClientLike = {
|
||||
available: boolean;
|
||||
fetch: (url: string, options?: TlsFetchOptions) => Promise<Response>;
|
||||
};
|
||||
let activeTlsClient: TlsClientLike = tlsClient;
|
||||
|
||||
/** Test seam for exercising wreq selection without replacing the module loader. */
|
||||
export function setTlsClientForTest(client: TlsClientLike | null): void {
|
||||
activeTlsClient = client ?? tlsClient;
|
||||
}
|
||||
|
||||
// #8376: transport-level connect-failure codes that mean "the configured upstream
|
||||
// proxy (or the target itself, for direct egress) is unreachable" — as opposed to an
|
||||
// ordinary upstream HTTP error. Read `.code` first (stable across undici/node
|
||||
@@ -122,9 +148,12 @@ function tagProxyUnreachable<T>(err: T): T {
|
||||
return err;
|
||||
}
|
||||
|
||||
/** Per-request tracking of whether TLS fingerprint was used */
|
||||
type TlsFingerprintStore = { used: boolean };
|
||||
const tlsFingerprintContext = new AsyncLocalStorage<TlsFingerprintStore>();
|
||||
/** Per-request TLS identity and success telemetry. */
|
||||
type TlsFingerprintStore = {
|
||||
used: boolean;
|
||||
provider?: string | null;
|
||||
sessionScope?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* #5217 (Gap-secondary): a mutable sink that records the proxy actually applied
|
||||
@@ -227,20 +256,112 @@ function requestHasNonReplayableBody(
|
||||
return false;
|
||||
}
|
||||
|
||||
const TLS_ALLOWED_OPTION_KEYS: Record<string, true> = {
|
||||
body: true,
|
||||
headers: true,
|
||||
method: true,
|
||||
redirect: true,
|
||||
signal: true,
|
||||
};
|
||||
|
||||
function isWreqBodySupported(body: unknown): boolean {
|
||||
if (body == null || typeof body === "string") return true;
|
||||
if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) return true;
|
||||
if (body instanceof URLSearchParams) return true;
|
||||
if (typeof Blob !== "undefined" && body instanceof Blob) return true;
|
||||
if (typeof FormData !== "undefined" && body instanceof FormData) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isTlsRequestEligible(
|
||||
input: RequestInfo | URL,
|
||||
options: FetchWithDispatcherOptions
|
||||
): boolean {
|
||||
if (typeof Request !== "undefined" && input instanceof Request) return false;
|
||||
if (!isWreqBodySupported(options.body)) return false;
|
||||
return Object.keys(options).every((key) => TLS_ALLOWED_OPTION_KEYS[key] === true);
|
||||
}
|
||||
|
||||
function isTlsFallbackReplaySafe(
|
||||
input: RequestInfo | URL,
|
||||
options: FetchWithDispatcherOptions
|
||||
): boolean {
|
||||
const method = (
|
||||
options.method ??
|
||||
(typeof Request !== "undefined" && input instanceof Request ? input.method : "GET")
|
||||
).toUpperCase();
|
||||
return (
|
||||
(method === "GET" || method === "HEAD" || method === "OPTIONS") &&
|
||||
!requestHasNonReplayableBody(input, options)
|
||||
);
|
||||
}
|
||||
|
||||
function getEffectiveSignal(
|
||||
input: RequestInfo | URL,
|
||||
options: FetchWithDispatcherOptions
|
||||
): AbortSignal | null | undefined {
|
||||
return (
|
||||
options.signal ??
|
||||
(typeof Request !== "undefined" && input instanceof Request ? input.signal : undefined)
|
||||
);
|
||||
}
|
||||
|
||||
function isWreqProxySupported(proxyUrl: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(proxyUrl);
|
||||
return (
|
||||
(parsed.protocol === "http:" || parsed.protocol === "https:") &&
|
||||
parsed.searchParams.get("family") === null
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeTransportError(
|
||||
error: unknown,
|
||||
message: string,
|
||||
fallbackCode: string
|
||||
): Error & { code: string; errorCode?: string; statusCode?: number } {
|
||||
const source = error && typeof error === "object" ? (error as Record<string, unknown>) : {};
|
||||
const sanitized = new Error(message) as Error & {
|
||||
code: string;
|
||||
errorCode?: string;
|
||||
statusCode?: number;
|
||||
};
|
||||
sanitized.code =
|
||||
typeof source.code === "string" && /^[A-Z0-9_:-]{1,64}$/.test(source.code)
|
||||
? source.code
|
||||
: fallbackCode;
|
||||
if (
|
||||
typeof source.errorCode === "string" &&
|
||||
/^[a-zA-Z0-9_:-]{1,64}$/.test(source.errorCode)
|
||||
) {
|
||||
sanitized.errorCode = source.errorCode;
|
||||
}
|
||||
if (typeof source.statusCode === "number" && Number.isFinite(source.statusCode)) {
|
||||
sanitized.statusCode = source.statusCode;
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/** Injectable dependencies for testability (Approach B DI). */
|
||||
export type ProxyFetchDeps = {
|
||||
undiciFetch?: FetchWithDispatcher;
|
||||
nativeFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
findWorkingProxy?: (hostname: string, targetUrl: string) => Promise<string | null>;
|
||||
};
|
||||
|
||||
type PatchState = {
|
||||
originalFetch: typeof globalThis.fetch;
|
||||
proxyContext: AsyncLocalStorage<unknown>;
|
||||
tlsFingerprintContext?: AsyncLocalStorage<TlsFingerprintStore>;
|
||||
isPatched: boolean;
|
||||
};
|
||||
|
||||
const isCloud = typeof caches !== "undefined" && typeof caches === "object";
|
||||
const PATCH_STATE_KEY = Symbol.for("omniroute.proxyFetch.state");
|
||||
const DIRECT_PROXY_CONTEXT = Symbol.for("omniroute.proxyFetch.direct-context");
|
||||
|
||||
function getPatchState(): PatchState {
|
||||
const scopedGlobal = globalThis as typeof globalThis & {
|
||||
@@ -251,6 +372,7 @@ function getPatchState(): PatchState {
|
||||
scopedGlobal[PATCH_STATE_KEY] = {
|
||||
originalFetch: globalThis.fetch,
|
||||
proxyContext: new AsyncLocalStorage(),
|
||||
tlsFingerprintContext: new AsyncLocalStorage(),
|
||||
isPatched: false,
|
||||
};
|
||||
}
|
||||
@@ -258,9 +380,11 @@ function getPatchState(): PatchState {
|
||||
}
|
||||
|
||||
const patchState = getPatchState();
|
||||
patchState.tlsFingerprintContext ??= new AsyncLocalStorage<TlsFingerprintStore>();
|
||||
const originalFetch = patchState.originalFetch;
|
||||
const originalFetchWithDispatcher = originalFetch as FetchWithDispatcher;
|
||||
const proxyContext = patchState.proxyContext;
|
||||
const tlsFingerprintContext = patchState.tlsFingerprintContext;
|
||||
|
||||
function noProxyMatch(targetUrl) {
|
||||
const noProxy = process.env.NO_PROXY || process.env.no_proxy;
|
||||
@@ -381,6 +505,9 @@ export function resolveProxyForRequest(targetUrl) {
|
||||
}
|
||||
|
||||
const contextProxy = proxyContext.getStore();
|
||||
if (contextProxy === DIRECT_PROXY_CONTEXT) {
|
||||
return { source: "direct", proxyUrl: null };
|
||||
}
|
||||
if (contextProxy) {
|
||||
// #9551: NO_PROXY must bypass context-proxy too
|
||||
if (target && noProxyMatch(targetUrl)) {
|
||||
@@ -398,16 +525,15 @@ export function resolveProxyForRequest(targetUrl) {
|
||||
}
|
||||
|
||||
/**
|
||||
* A caller-initiated abort/timeout is not a proxy transport failure — it must
|
||||
* not be misreported as one. Prefer `signal.aborted` because
|
||||
* `AbortController.abort(reason)` may surface a custom Error rather than a
|
||||
* standard AbortError/TimeoutError name.
|
||||
* Ported from decolua/9router#2589 (`isCallerAbort`).
|
||||
* A caller-initiated abort is identified only by the caller's effective signal.
|
||||
* Dependency-internal TimeoutError/AbortError values are transport failures and
|
||||
* retain the normal safe-method fallback behavior.
|
||||
*/
|
||||
function isCallerAbort(error: unknown, signal: AbortSignal | null | undefined): boolean {
|
||||
if (signal?.aborted === true) return true;
|
||||
const name = (error as { name?: unknown } | null)?.name;
|
||||
return name === "AbortError" || name === "TimeoutError";
|
||||
function isCallerAbort(
|
||||
_error: unknown,
|
||||
signal: AbortSignal | null | undefined
|
||||
): boolean {
|
||||
return signal?.aborted === true;
|
||||
}
|
||||
|
||||
function getTargetUrl(input) {
|
||||
@@ -425,9 +551,13 @@ export async function runWithProxyContext(
|
||||
throw new TypeError("runWithProxyContext requires a callback function");
|
||||
}
|
||||
|
||||
// Inherit existing context if no specific proxyConfig is provided
|
||||
// Inherit existing context if no specific proxyConfig is provided. A direct
|
||||
// sentinel must remain direct without being mistaken for a proxy config.
|
||||
const currentContext = proxyContext.getStore();
|
||||
const effectiveProxyConfig = proxyConfig || currentContext || null;
|
||||
const inheritsDirect = currentContext === DIRECT_PROXY_CONTEXT && !proxyConfig;
|
||||
const effectiveProxyConfig =
|
||||
proxyConfig || (inheritsDirect ? null : currentContext) || null;
|
||||
const contextValue = inheritsDirect ? DIRECT_PROXY_CONTEXT : effectiveProxyConfig;
|
||||
|
||||
const resolvedProxyUrl = effectiveProxyConfig ? proxyConfigToUrl(effectiveProxyConfig) : null;
|
||||
|
||||
@@ -435,8 +565,9 @@ export async function runWithProxyContext(
|
||||
// This fallback changes egress IP, so upgrades must not silently turn it on.
|
||||
const directFallbackOnUnreachable =
|
||||
opts?.directFallbackOnUnreachable === true && isControlPlaneProxyDirectFallbackEnabled();
|
||||
// Run fn with the proxy context cleared so the request egresses directly.
|
||||
const runDirect = () => proxyContext.run(null, fn);
|
||||
// Keep an explicit direct sentinel so resolveProxyForRequest cannot re-read
|
||||
// HTTPS_PROXY/HTTP_PROXY after the control-plane route decision.
|
||||
const runDirect = () => proxyContext.run(DIRECT_PROXY_CONTEXT, fn);
|
||||
|
||||
// T14: Proxy Fast-Fail (non-blocking, #9100)
|
||||
// Perform a short TCP reachability check BEFORE issuing upstream requests.
|
||||
@@ -502,7 +633,7 @@ export async function runWithProxyContext(
|
||||
}
|
||||
}
|
||||
|
||||
return proxyContext.run(effectiveProxyConfig, async () => {
|
||||
return proxyContext.run(contextValue, async () => {
|
||||
if (resolvedProxyUrl && effectiveProxyConfig !== currentContext) {
|
||||
// #9158: this fires on EVERY proxied request (innermost context wins).
|
||||
// Gate it behind the same env flag as the relay routing log so request
|
||||
@@ -604,23 +735,47 @@ async function patchedFetch(
|
||||
const { source, proxyUrl } = resolved;
|
||||
|
||||
if (!proxyUrl) {
|
||||
// TLS fingerprint spoofing for direct connections (no proxy configured)
|
||||
if (isTlsFingerprintEnabled() && tlsClient.available) {
|
||||
// TLS fingerprint spoofing for an already-resolved direct route. Explicit
|
||||
// proxy:null prevents wreq from re-reading a global environment proxy.
|
||||
const tlsStore = tlsFingerprintContext.getStore();
|
||||
let tlsDirectFallback = false;
|
||||
if (
|
||||
isTlsFingerprintEnabled() &&
|
||||
activeTlsClient.available &&
|
||||
tlsFingerprintProviderAllowed(tlsStore?.provider, false) &&
|
||||
isTlsRequestEligible(input, options)
|
||||
) {
|
||||
try {
|
||||
const store = tlsFingerprintContext.getStore();
|
||||
if (store) store.used = true;
|
||||
return await tlsClient.fetch(targetUrl, {
|
||||
...options,
|
||||
const response = await activeTlsClient.fetch(targetUrl, {
|
||||
method: options.method,
|
||||
headers: options.headers,
|
||||
signal: options.signal ?? undefined,
|
||||
body: options.body as TlsFetchOptions["body"],
|
||||
redirect: options.redirect,
|
||||
signal: getEffectiveSignal(input, options),
|
||||
proxy: null,
|
||||
sessionScope: tlsStore?.sessionScope,
|
||||
});
|
||||
if (tlsStore) tlsStore.used = true;
|
||||
return response;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(
|
||||
`[ProxyFetch] TLS fingerprint failed, falling back to native fetch: ${message}`
|
||||
);
|
||||
const store = tlsFingerprintContext.getStore();
|
||||
if (store) store.used = false;
|
||||
if (isCallerAbort(error, getEffectiveSignal(input, options))) throw error;
|
||||
const sessionHadCookies =
|
||||
!!error &&
|
||||
typeof error === "object" &&
|
||||
"sessionHadCookies" in error &&
|
||||
error.sessionHadCookies === true;
|
||||
if (!isTlsFallbackReplaySafe(input, options) || sessionHadCookies) {
|
||||
throw sanitizeTransportError(
|
||||
error,
|
||||
sessionHadCookies
|
||||
? "TLS fingerprint request failed; stateful session cannot be replayed"
|
||||
: "TLS fingerprint request failed; request is not safe to replay",
|
||||
"TLS_FINGERPRINT_FAILED"
|
||||
);
|
||||
}
|
||||
console.warn("[ProxyFetch] TLS fingerprint transport failed; using direct dispatcher");
|
||||
if (tlsStore) tlsStore.used = false;
|
||||
tlsDirectFallback = true;
|
||||
}
|
||||
}
|
||||
// Direct connection (no proxy) — use undici with custom dispatcher for timeout control.
|
||||
@@ -695,7 +850,11 @@ async function patchedFetch(
|
||||
}
|
||||
|
||||
// All attempts exhausted — try proxy fallback before native fetch
|
||||
if (source === "direct" && isFeatureFlagEnabled("PROXY_AUTO_SELECT_ENABLED")) {
|
||||
if (
|
||||
!tlsDirectFallback &&
|
||||
source === "direct" &&
|
||||
isFeatureFlagEnabled("PROXY_AUTO_SELECT_ENABLED")
|
||||
) {
|
||||
let targetHostname = "";
|
||||
try {
|
||||
targetHostname = new URL(targetUrl).hostname;
|
||||
@@ -703,7 +862,8 @@ async function patchedFetch(
|
||||
// ignore
|
||||
}
|
||||
if (targetHostname) {
|
||||
const { findWorkingProxy } = await import("./proxyFallback.ts");
|
||||
const findWorkingProxy =
|
||||
deps.findWorkingProxy ?? (await import("./proxyFallback.ts")).findWorkingProxy;
|
||||
const fallbackProxyUrl = await findWorkingProxy(targetHostname, targetUrl);
|
||||
if (fallbackProxyUrl) {
|
||||
try {
|
||||
@@ -854,6 +1014,51 @@ async function patchedFetch(
|
||||
throw lastRelayError;
|
||||
}
|
||||
|
||||
// The proxied TLS overlay is deliberately narrow: approved provider, exact
|
||||
// http(s) proxy, no relay/family pinning, and only options wreq can preserve.
|
||||
const tlsStore = tlsFingerprintContext.getStore();
|
||||
if (
|
||||
isTlsFingerprintEnabled() &&
|
||||
typeof tlsStore?.sessionScope === "string" &&
|
||||
tlsStore.sessionScope.trim().length > 0 &&
|
||||
activeTlsClient.available &&
|
||||
tlsFingerprintProviderAllowed(tlsStore?.provider, true) &&
|
||||
isTlsRequestEligible(input, options) &&
|
||||
isWreqProxySupported(proxyUrl)
|
||||
) {
|
||||
try {
|
||||
const response = await activeTlsClient.fetch(targetUrl, {
|
||||
method: options.method,
|
||||
headers: options.headers,
|
||||
body: options.body as TlsFetchOptions["body"],
|
||||
redirect: options.redirect,
|
||||
signal: getEffectiveSignal(input, options),
|
||||
proxy: proxyUrl,
|
||||
sessionScope: tlsStore?.sessionScope,
|
||||
});
|
||||
if (tlsStore) tlsStore.used = true;
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (isCallerAbort(error, getEffectiveSignal(input, options))) throw error;
|
||||
const sessionHadCookies =
|
||||
!!error &&
|
||||
typeof error === "object" &&
|
||||
"sessionHadCookies" in error &&
|
||||
error.sessionHadCookies === true;
|
||||
if (!isTlsFallbackReplaySafe(input, options) || sessionHadCookies) {
|
||||
throw sanitizeTransportError(
|
||||
error,
|
||||
sessionHadCookies
|
||||
? "TLS fingerprint request failed; stateful session cannot be replayed"
|
||||
: "TLS fingerprint request failed; request is not safe to replay",
|
||||
"TLS_FINGERPRINT_FAILED"
|
||||
);
|
||||
}
|
||||
console.warn("[ProxyFetch] TLS fingerprint transport failed; using proxy dispatcher");
|
||||
if (tlsStore) tlsStore.used = false;
|
||||
}
|
||||
}
|
||||
|
||||
// #9100: proxy path — attempt 0 uses the pooled keep-alive dispatcher
|
||||
// (pipelining 4, ONE reused TCP connection per proxy host). A transient
|
||||
// socket error on a stale pooled socket is retried ONCE on a fresh
|
||||
@@ -872,6 +1077,7 @@ async function patchedFetch(
|
||||
attempt === 0 ? createProxyDispatcher(proxyUrl) : getProxyRetryDispatcher(proxyUrl),
|
||||
});
|
||||
} catch (error) {
|
||||
if (isCallerAbort(error, getEffectiveSignal(input, options))) throw error;
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
const errCode = (error as { code?: unknown })?.code;
|
||||
const isTransportFailure =
|
||||
@@ -889,13 +1095,16 @@ async function patchedFetch(
|
||||
await new Promise((r) => setTimeout(r, RETRY_BACKOFF_MS));
|
||||
continue;
|
||||
}
|
||||
// A caller abort/timeout must propagate unchanged and without a noisy
|
||||
// "Proxy request failed" log — it's not a proxy transport failure.
|
||||
if (!isCallerAbort(error, options?.signal)) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[ProxyFetch] Proxy request failed (${source}, fail-closed): ${message}`);
|
||||
}
|
||||
throw error;
|
||||
tagProxyUnreachable(error);
|
||||
const sanitized = sanitizeTransportError(
|
||||
error,
|
||||
"Proxy request failed",
|
||||
"PROXY_REQUEST_FAILED"
|
||||
);
|
||||
console.error(
|
||||
`[ProxyFetch] Proxy request failed (${source}, fail-closed; code=${sanitized.code})`
|
||||
);
|
||||
throw sanitized;
|
||||
}
|
||||
}
|
||||
throw lastProxyError;
|
||||
@@ -919,19 +1128,64 @@ if (!isCloud && !patchState.isPatched) {
|
||||
patchState.isPatched = true;
|
||||
}
|
||||
|
||||
export type TlsTrackingIdentity = {
|
||||
provider?: string | null;
|
||||
sessionScope?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Run a function with TLS fingerprint tracking context.
|
||||
* After fn completes, returns { result, tlsFingerprintUsed }.
|
||||
* Run a function with account-scoped TLS fingerprint tracking.
|
||||
* Both historical forms remain valid: runWithTlsTracking(fn) and
|
||||
* runWithTlsTracking(provider, fn).
|
||||
*/
|
||||
export async function runWithTlsTracking(fn) {
|
||||
const store = { used: false };
|
||||
const result = await tlsFingerprintContext.run(store, fn);
|
||||
export async function runWithTlsTracking<T>(
|
||||
fn: () => T
|
||||
): Promise<{ result: Awaited<T>; tlsFingerprintUsed: boolean }>;
|
||||
export async function runWithTlsTracking<T>(
|
||||
provider: string | null | undefined,
|
||||
fn: () => T
|
||||
): Promise<{ result: Awaited<T>; tlsFingerprintUsed: boolean }>;
|
||||
export async function runWithTlsTracking<T>(
|
||||
identity: TlsTrackingIdentity,
|
||||
fn: () => T
|
||||
): Promise<{ result: Awaited<T>; tlsFingerprintUsed: boolean }>;
|
||||
export async function runWithTlsTracking<T>(
|
||||
providerOrIdentityOrFn: string | null | undefined | TlsTrackingIdentity | (() => T),
|
||||
maybeFn?: () => T
|
||||
): Promise<{ result: Awaited<T>; tlsFingerprintUsed: boolean }> {
|
||||
const legacyFn =
|
||||
typeof providerOrIdentityOrFn === "function" ? providerOrIdentityOrFn : maybeFn;
|
||||
if (typeof legacyFn !== "function") {
|
||||
throw new TypeError("runWithTlsTracking requires a callback function");
|
||||
}
|
||||
const identity: TlsTrackingIdentity =
|
||||
providerOrIdentityOrFn &&
|
||||
typeof providerOrIdentityOrFn === "object" &&
|
||||
typeof providerOrIdentityOrFn !== "function"
|
||||
? providerOrIdentityOrFn
|
||||
: {
|
||||
provider:
|
||||
typeof providerOrIdentityOrFn === "string" ? providerOrIdentityOrFn : undefined,
|
||||
};
|
||||
const store: TlsFingerprintStore = {
|
||||
used: false,
|
||||
provider: identity.provider,
|
||||
sessionScope: identity.sessionScope,
|
||||
};
|
||||
const result = await tlsFingerprintContext.run(store, legacyFn);
|
||||
return { result, tlsFingerprintUsed: store.used };
|
||||
}
|
||||
|
||||
/** Check if TLS fingerprint is enabled and available */
|
||||
export function isTlsFingerprintActive() {
|
||||
return isTlsFingerprintEnabled() && tlsClient.available;
|
||||
/** Check whether TLS fingerprint transport is enabled for this route identity. */
|
||||
export function isTlsFingerprintActive(
|
||||
provider?: string | null,
|
||||
proxied = false
|
||||
): boolean {
|
||||
return (
|
||||
isTlsFingerprintEnabled() &&
|
||||
activeTlsClient.available &&
|
||||
tlsFingerprintProviderAllowed(provider, proxied)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,20 +1,40 @@
|
||||
import { createRequire } from "module";
|
||||
import { createHash } from "node:crypto";
|
||||
import { getTlsClientTimeoutConfig } from "@/shared/utils/runtimeTimeouts";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const runtimeRequire = createRequire(import.meta.url);
|
||||
|
||||
type WreqSession = {
|
||||
fetch: (url: string, options?: Record<string, unknown>) => Promise<Response>;
|
||||
close: () => Promise<void> | void;
|
||||
function loadRuntimeModule(moduleName: string): unknown {
|
||||
// Keep the specifier dynamic. Turbopack rewrites a literal createRequire call
|
||||
// to a hashed external name that is absent from the standalone Docker runtime.
|
||||
return Reflect.apply(runtimeRequire, undefined, [moduleName]);
|
||||
}
|
||||
|
||||
export type WreqResponse = {
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: Iterable<[string, string]>;
|
||||
body: ReadableStream<Uint8Array> | null;
|
||||
url?: string;
|
||||
redirected?: boolean;
|
||||
};
|
||||
|
||||
type CreateSessionFn = (options: Record<string, unknown>) => Promise<WreqSession>;
|
||||
export type WreqSession = {
|
||||
fetch: (url: string, options?: Record<string, unknown>) => Promise<WreqResponse>;
|
||||
close: () => Promise<void> | void;
|
||||
getCookies?: (url: string | URL) => Record<string, string>;
|
||||
};
|
||||
|
||||
export type CreateSessionFn = (options: Record<string, unknown>) => Promise<WreqSession>;
|
||||
|
||||
let createSession: CreateSessionFn | null;
|
||||
try {
|
||||
const loaded = require("wreq-js") as { createSession?: CreateSessionFn };
|
||||
const loaded = loadRuntimeModule("wreq-js") as { createSession?: CreateSessionFn };
|
||||
createSession = typeof loaded.createSession === "function" ? loaded.createSession : null;
|
||||
} catch {
|
||||
if (process.env.ENABLE_TLS_FINGERPRINT === "true") {
|
||||
console.warn("[TlsClient] wreq-js unavailable; TLS fingerprint transport disabled");
|
||||
}
|
||||
createSession = null;
|
||||
}
|
||||
|
||||
@@ -34,12 +54,26 @@ function getProxyFromEnv(): string | undefined {
|
||||
);
|
||||
}
|
||||
|
||||
interface FetchOptions {
|
||||
export type WreqBodyInit =
|
||||
| string
|
||||
| ArrayBuffer
|
||||
| ArrayBufferView
|
||||
| URLSearchParams
|
||||
| Buffer
|
||||
| Blob
|
||||
| FormData
|
||||
| null;
|
||||
|
||||
export interface TlsFetchOptions {
|
||||
method?: string;
|
||||
headers?: HeadersInit;
|
||||
body?: unknown;
|
||||
redirect?: string;
|
||||
signal?: AbortSignal;
|
||||
body?: WreqBodyInit;
|
||||
redirect?: RequestRedirect;
|
||||
signal?: AbortSignal | null;
|
||||
/** Exact resolved proxy. Undefined preserves legacy environment lookup; null means direct. */
|
||||
proxy?: string | null;
|
||||
/** Stable account/connection identity used to isolate cookies and circuit state. */
|
||||
sessionScope?: string;
|
||||
}
|
||||
|
||||
function normalizeHeaders(headers: HeadersInit | undefined): Record<string, string> | undefined {
|
||||
@@ -62,182 +96,591 @@ function normalizeHeaders(headers: HeadersInit | undefined): Record<string, stri
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* TLS Client — Chrome 124 TLS fingerprint spoofing via wreq-js
|
||||
* Singleton instance used to disguise Node.js TLS handshake as Chrome browser.
|
||||
*
|
||||
* wreq-js natively supports proxy — TLS fingerprinting works through proxy.
|
||||
* Proxy URL is read from environment variables (HTTPS_PROXY, HTTP_PROXY, ALL_PROXY).
|
||||
*/
|
||||
class TlsClient {
|
||||
session: WreqSession | null = null;
|
||||
type SafeWreqError = Error & {
|
||||
code?: string;
|
||||
errorCode?: string;
|
||||
statusCode?: number;
|
||||
};
|
||||
|
||||
private _libraryAvailable: boolean;
|
||||
private failureCount: number = 0;
|
||||
private maxFailures: number = 3;
|
||||
private baseCooldownMs: number = 30_000;
|
||||
private cooldownMs: number = 30_000;
|
||||
private cooldownMultiplier: number = 1;
|
||||
private readonly MAX_COOLDOWN_MS = 600_000; // 10 min
|
||||
private circuitOpenUntil: number = 0;
|
||||
private circuitTripped: boolean = false;
|
||||
|
||||
constructor() {
|
||||
this._libraryAvailable = !!createSession;
|
||||
function sanitizeWreqError(error: unknown, message: string): SafeWreqError {
|
||||
const sanitized = new Error(message) as SafeWreqError;
|
||||
if (!error || typeof error !== "object") return sanitized;
|
||||
if ("code" in error && typeof error.code === "string" && /^[A-Z0-9_:-]{1,64}$/.test(error.code)) {
|
||||
sanitized.code = error.code;
|
||||
}
|
||||
|
||||
get available(): boolean {
|
||||
if (!this._libraryAvailable) return false;
|
||||
if (!this.circuitTripped) return true;
|
||||
return Date.now() >= this.circuitOpenUntil;
|
||||
if (
|
||||
"errorCode" in error &&
|
||||
typeof error.errorCode === "string" &&
|
||||
/^[a-zA-Z0-9_:-]{1,64}$/.test(error.errorCode)
|
||||
) {
|
||||
sanitized.errorCode = error.errorCode;
|
||||
}
|
||||
if (
|
||||
"statusCode" in error &&
|
||||
typeof error.statusCode === "number" &&
|
||||
Number.isFinite(error.statusCode)
|
||||
) {
|
||||
sanitized.statusCode = error.statusCode;
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
private recordFailure(): void {
|
||||
this.failureCount++;
|
||||
if (this.failureCount >= this.maxFailures) {
|
||||
this.circuitOpenUntil = Date.now() + this.cooldownMs;
|
||||
this.circuitTripped = true;
|
||||
// Close the stale session so the next half-open retry creates a
|
||||
// fresh one instead of reusing a broken connection.
|
||||
if (this.session) {
|
||||
Promise.resolve(this.session.close()).catch(() => {});
|
||||
this.session = null;
|
||||
}
|
||||
console.warn(
|
||||
`[TlsClient] Circuit opened after ${this.failureCount} consecutive failures, cooling down for ${this.cooldownMs}ms`
|
||||
function toNativeResponse(
|
||||
response: WreqResponse,
|
||||
onFinalize: () => void,
|
||||
onBodyError: () => void,
|
||||
signal?: AbortSignal | null
|
||||
): Response {
|
||||
let finalized = false;
|
||||
let bodyFailureReported = false;
|
||||
let consumerCancelled = false;
|
||||
let consumerCancelReason: unknown;
|
||||
const finalize = () => {
|
||||
if (finalized) return;
|
||||
finalized = true;
|
||||
onFinalize();
|
||||
};
|
||||
const safeBodyError = (error: unknown): unknown => {
|
||||
if (signal?.aborted) {
|
||||
return signal.reason ?? new DOMException("The operation was aborted", "AbortError");
|
||||
}
|
||||
if (consumerCancelled) {
|
||||
return (
|
||||
consumerCancelReason ?? new DOMException("The response body was cancelled", "AbortError")
|
||||
);
|
||||
// Double cooldown for the next trip: 30s → 60s → 120s → ... → 10 min max
|
||||
this.escalateCooldown();
|
||||
}
|
||||
}
|
||||
|
||||
private recordSuccess(): void {
|
||||
this.failureCount = 0;
|
||||
if (this.circuitTripped) {
|
||||
this.cooldownMultiplier = 1;
|
||||
this.cooldownMs = this.baseCooldownMs;
|
||||
console.log("[TlsClient] Circuit closed (success after cooldown)");
|
||||
this.circuitTripped = false;
|
||||
if (!bodyFailureReported) {
|
||||
bodyFailureReported = true;
|
||||
onBodyError();
|
||||
}
|
||||
return sanitizeWreqError(error, "wreq-js response body failed");
|
||||
};
|
||||
if (response instanceof Response) {
|
||||
finalize();
|
||||
return response;
|
||||
}
|
||||
|
||||
private escalateCooldown(): void {
|
||||
this.cooldownMultiplier = Math.min(this.cooldownMultiplier * 2, 20);
|
||||
this.cooldownMs = Math.min(this.baseCooldownMs * this.cooldownMultiplier, this.MAX_COOLDOWN_MS);
|
||||
try {
|
||||
const headers = new Headers();
|
||||
for (const [name, value] of response.headers) headers.append(name, value);
|
||||
let body: ReadableStream<Uint8Array> | null = null;
|
||||
if (response.body) {
|
||||
const reader = response.body.getReader();
|
||||
body = new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
try {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) {
|
||||
finalize();
|
||||
controller.close();
|
||||
} else {
|
||||
controller.enqueue(chunk.value);
|
||||
}
|
||||
} catch (error) {
|
||||
controller.error(safeBodyError(error));
|
||||
finalize();
|
||||
}
|
||||
},
|
||||
async cancel(reason) {
|
||||
consumerCancelled = true;
|
||||
consumerCancelReason = reason;
|
||||
try {
|
||||
await reader.cancel(reason);
|
||||
} catch (error) {
|
||||
throw safeBodyError(error);
|
||||
} finally {
|
||||
finalize();
|
||||
}
|
||||
},
|
||||
});
|
||||
} else {
|
||||
finalize();
|
||||
}
|
||||
const adapted = new Response(body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
});
|
||||
if (response.url) {
|
||||
Object.defineProperty(adapted, "url", { value: response.url, configurable: true });
|
||||
}
|
||||
if (response.redirected !== undefined) {
|
||||
Object.defineProperty(adapted, "redirected", {
|
||||
value: response.redirected,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
return adapted;
|
||||
} catch (error) {
|
||||
finalize();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TLS Client — Chrome 124 TLS fingerprint spoofing via wreq-js.
|
||||
* Sessions, cookie jars, and circuit state are isolated by account scope and exact proxy.
|
||||
*/
|
||||
export class TlsClient {
|
||||
private readonly createSessionFn: CreateSessionFn | null;
|
||||
private readonly sessions = new Map<string, WreqSession>();
|
||||
private readonly pendingSessions = new Map<string, Promise<WreqSession>>();
|
||||
private readonly pendingCloses = new Set<Promise<void>>();
|
||||
private readonly sessionEpochs = new Map<string, number>();
|
||||
private readonly sessionUseCounts = new Map<string, number>();
|
||||
private readonly sessionLastUsed = new Map<string, number>();
|
||||
private readonly pendingEvictions = new Set<string>();
|
||||
private accessSequence = 0;
|
||||
private readonly circuits = new Map<
|
||||
string,
|
||||
{
|
||||
failureCount: number;
|
||||
cooldownMs: number;
|
||||
cooldownMultiplier: number;
|
||||
circuitOpenUntil: number;
|
||||
circuitTripped: boolean;
|
||||
halfOpenInFlight: boolean;
|
||||
sessionHadCookies: boolean;
|
||||
}
|
||||
>();
|
||||
private globalSessionEpoch = 0;
|
||||
private readonly maxFailures = 3;
|
||||
private readonly baseCooldownMs = 30_000;
|
||||
private readonly maxCooldownMs = 600_000;
|
||||
private readonly legacySessionScope = "legacy";
|
||||
private readonly _libraryAvailable: boolean;
|
||||
private readonly maxSessions: number;
|
||||
|
||||
constructor(
|
||||
createSessionFn: CreateSessionFn | null = createSession,
|
||||
maxSessions = 128
|
||||
) {
|
||||
this.createSessionFn = createSessionFn;
|
||||
this._libraryAvailable = !!createSessionFn;
|
||||
this.maxSessions =
|
||||
Number.isInteger(maxSessions) && maxSessions > 0 ? maxSessions : 128;
|
||||
}
|
||||
|
||||
private checkCircuit(): boolean {
|
||||
if (!this.circuitTripped) return true;
|
||||
/** Library availability only. Per-session circuit state is enforced inside fetch(). */
|
||||
get available(): boolean {
|
||||
return this._libraryAvailable;
|
||||
}
|
||||
|
||||
if (Date.now() >= this.circuitOpenUntil) {
|
||||
console.log("[TlsClient] Half-open: retrying after cooldown");
|
||||
// Don't call recordSuccess() here — that would reset failureCount.
|
||||
// Instead, let the fetch() call succeed or fail naturally.
|
||||
// If it succeeds, recordSuccess() in fetch() handles cleanup.
|
||||
// If it fails, recordFailure() finds failureCount still >= maxFailures
|
||||
// and re-opens with escalated cooldown.
|
||||
private resolveProxy(proxy?: string | null): string | null {
|
||||
return proxy === undefined ? (getProxyFromEnv() ?? null) : proxy;
|
||||
}
|
||||
|
||||
private getSessionKey(resolvedProxy: string | null, sessionScope?: string): string {
|
||||
const scope = sessionScope?.trim() || this.legacySessionScope;
|
||||
return createHash("sha256")
|
||||
.update(scope)
|
||||
.update("\0")
|
||||
.update(resolvedProxy ?? "")
|
||||
.digest("base64url");
|
||||
}
|
||||
|
||||
private getDefaultSessionKey(): string {
|
||||
return this.getSessionKey(this.resolveProxy(undefined), this.legacySessionScope);
|
||||
}
|
||||
|
||||
private getSessionEpoch(key: string): number {
|
||||
return this.sessionEpochs.get(key) ?? 0;
|
||||
}
|
||||
|
||||
private hasSessionCookies(session: WreqSession | null, url: string): boolean {
|
||||
if (!session) return false;
|
||||
if (!session.getCookies) return true;
|
||||
try {
|
||||
return Object.keys(session.getCookies(url)).length > 0;
|
||||
} catch {
|
||||
// If cookie state cannot be inspected, fail closed and forbid replay.
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async getSession() {
|
||||
if (!this.checkCircuit()) return null;
|
||||
if (!this.available) return null;
|
||||
if (this.session) return this.session;
|
||||
const createSessionFn = createSession;
|
||||
if (!createSessionFn) return null;
|
||||
private closeSession(session: WreqSession): Promise<void> {
|
||||
let closing: Promise<void>;
|
||||
closing = Promise.resolve()
|
||||
.then(() => session.close())
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
this.pendingCloses.delete(closing);
|
||||
});
|
||||
this.pendingCloses.add(closing);
|
||||
return closing;
|
||||
}
|
||||
|
||||
private findOldestIdleSession(protectedKey?: string): string | undefined {
|
||||
let candidate: string | undefined;
|
||||
let candidateSequence = Number.POSITIVE_INFINITY;
|
||||
for (const key of this.sessions.keys()) {
|
||||
if (key === protectedKey || (this.sessionUseCounts.get(key) ?? 0) > 0) continue;
|
||||
const sequence = this.sessionLastUsed.get(key) ?? 0;
|
||||
if (sequence < candidateSequence) {
|
||||
candidate = key;
|
||||
candidateSequence = sequence;
|
||||
}
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private reserveSessionCapacity(protectedKey: string): void {
|
||||
if (
|
||||
this.pendingSessions.size >= this.maxSessions ||
|
||||
this.pendingCloses.size >= this.maxSessions
|
||||
) {
|
||||
const error = new Error("wreq-js session capacity exhausted") as Error & {
|
||||
code?: string;
|
||||
};
|
||||
error.code = "TLS_SESSION_CAPACITY";
|
||||
throw error;
|
||||
}
|
||||
while (this.sessions.size >= this.maxSessions) {
|
||||
const candidate = this.findOldestIdleSession(protectedKey);
|
||||
if (!candidate) {
|
||||
const error = new Error("wreq-js session capacity exhausted") as Error & {
|
||||
code?: string;
|
||||
};
|
||||
error.code = "TLS_SESSION_CAPACITY";
|
||||
throw error;
|
||||
}
|
||||
void this.invalidateSession(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
private retainSession(key: string): void {
|
||||
this.pendingEvictions.delete(key);
|
||||
this.sessionUseCounts.set(key, (this.sessionUseCounts.get(key) ?? 0) + 1);
|
||||
this.sessionLastUsed.set(key, ++this.accessSequence);
|
||||
}
|
||||
|
||||
private releaseSession(key: string): void {
|
||||
const remaining = (this.sessionUseCounts.get(key) ?? 1) - 1;
|
||||
if (remaining > 0) {
|
||||
this.sessionUseCounts.set(key, remaining);
|
||||
return;
|
||||
}
|
||||
this.sessionUseCounts.delete(key);
|
||||
if (this.pendingEvictions.delete(key)) {
|
||||
void this.invalidateSession(key);
|
||||
return;
|
||||
}
|
||||
this.evictSessionsIfNeeded();
|
||||
}
|
||||
|
||||
private evictSessionsIfNeeded(protectedKey?: string): void {
|
||||
while (this.sessions.size > this.maxSessions) {
|
||||
const candidate = this.findOldestIdleSession(protectedKey);
|
||||
if (candidate) {
|
||||
void this.invalidateSession(candidate);
|
||||
continue;
|
||||
}
|
||||
|
||||
let activeCandidate: string | undefined;
|
||||
let candidateSequence = Number.POSITIVE_INFINITY;
|
||||
for (const key of this.sessions.keys()) {
|
||||
if (key === protectedKey || this.pendingEvictions.has(key)) continue;
|
||||
const sequence = this.sessionLastUsed.get(key) ?? 0;
|
||||
if (sequence < candidateSequence) {
|
||||
activeCandidate = key;
|
||||
candidateSequence = sequence;
|
||||
}
|
||||
}
|
||||
if (activeCandidate) this.pendingEvictions.add(activeCandidate);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private invalidateSession(key: string): Promise<void> {
|
||||
const pending = this.pendingSessions.get(key);
|
||||
const invalidatedEpoch = this.getSessionEpoch(key) + 1;
|
||||
this.sessionEpochs.set(key, invalidatedEpoch);
|
||||
this.pendingSessions.delete(key);
|
||||
this.sessionUseCounts.delete(key);
|
||||
this.sessionLastUsed.delete(key);
|
||||
this.pendingEvictions.delete(key);
|
||||
const session = this.sessions.get(key);
|
||||
this.sessions.delete(key);
|
||||
if (pending) {
|
||||
void pending
|
||||
.finally(() => {
|
||||
if (
|
||||
this.getSessionEpoch(key) === invalidatedEpoch &&
|
||||
!this.pendingSessions.has(key) &&
|
||||
!this.sessions.has(key)
|
||||
) {
|
||||
this.sessionEpochs.delete(key);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
} else {
|
||||
this.sessionEpochs.delete(key);
|
||||
}
|
||||
return session ? this.closeSession(session) : Promise.resolve();
|
||||
}
|
||||
|
||||
private async closeSessions(): Promise<void> {
|
||||
const pending = [...this.pendingSessions.values()];
|
||||
this.globalSessionEpoch++;
|
||||
this.pendingSessions.clear();
|
||||
this.sessionEpochs.clear();
|
||||
const sessions = [...this.sessions.values()];
|
||||
this.sessions.clear();
|
||||
this.sessionUseCounts.clear();
|
||||
this.sessionLastUsed.clear();
|
||||
this.pendingEvictions.clear();
|
||||
this.circuits.clear();
|
||||
const closes = sessions.map((session) => this.closeSession(session));
|
||||
await Promise.allSettled([...closes, ...pending]);
|
||||
await Promise.allSettled([...this.pendingCloses]);
|
||||
}
|
||||
|
||||
private checkCircuit(key = this.getDefaultSessionKey()): boolean {
|
||||
const state = this.circuits.get(key);
|
||||
if (!state || !state.circuitTripped) return true;
|
||||
if (Date.now() < state.circuitOpenUntil) return false;
|
||||
if (state.halfOpenInFlight) return false;
|
||||
state.halfOpenInFlight = true;
|
||||
console.log("[TlsClient] Half-open: retrying after cooldown");
|
||||
return true;
|
||||
}
|
||||
|
||||
private recordFailure(
|
||||
key = this.getDefaultSessionKey(),
|
||||
sessionHadCookies = false
|
||||
): void {
|
||||
const state = this.circuits.get(key) ?? {
|
||||
failureCount: 0,
|
||||
cooldownMs: this.baseCooldownMs,
|
||||
cooldownMultiplier: 1,
|
||||
circuitOpenUntil: 0,
|
||||
circuitTripped: false,
|
||||
halfOpenInFlight: false,
|
||||
sessionHadCookies: false,
|
||||
};
|
||||
state.sessionHadCookies ||= sessionHadCookies;
|
||||
state.failureCount++;
|
||||
state.halfOpenInFlight = false;
|
||||
if (state.failureCount >= this.maxFailures) {
|
||||
state.circuitOpenUntil = Date.now() + state.cooldownMs;
|
||||
state.circuitTripped = true;
|
||||
if ((this.sessionUseCounts.get(key) ?? 0) > 0) {
|
||||
this.pendingEvictions.add(key);
|
||||
} else {
|
||||
void this.invalidateSession(key);
|
||||
}
|
||||
console.warn(
|
||||
`[TlsClient] Circuit opened after ${state.failureCount} consecutive failures, cooling down for ${state.cooldownMs}ms`
|
||||
);
|
||||
state.cooldownMultiplier = Math.min(state.cooldownMultiplier * 2, 20);
|
||||
state.cooldownMs = Math.min(
|
||||
this.baseCooldownMs * state.cooldownMultiplier,
|
||||
this.maxCooldownMs
|
||||
);
|
||||
}
|
||||
this.circuits.delete(key);
|
||||
this.circuits.set(key, state);
|
||||
const maxCircuitEntries = this.maxSessions * 2;
|
||||
while (this.circuits.size > maxCircuitEntries) {
|
||||
const oldestKey = this.circuits.keys().next().value;
|
||||
if (typeof oldestKey !== "string") break;
|
||||
this.circuits.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
private recordSuccess(key = this.getDefaultSessionKey()): void {
|
||||
const state = this.circuits.get(key);
|
||||
if (state?.circuitTripped) {
|
||||
console.log("[TlsClient] Circuit closed (success after cooldown)");
|
||||
}
|
||||
this.circuits.delete(key);
|
||||
}
|
||||
|
||||
private releaseHalfOpen(key: string): void {
|
||||
const state = this.circuits.get(key);
|
||||
if (state) state.halfOpenInFlight = false;
|
||||
}
|
||||
|
||||
private async getSession(
|
||||
resolvedProxy: string | null,
|
||||
key: string
|
||||
): Promise<WreqSession | null> {
|
||||
const cached = this.sessions.get(key);
|
||||
if (cached) {
|
||||
this.pendingEvictions.delete(key);
|
||||
this.sessionLastUsed.set(key, ++this.accessSequence);
|
||||
return cached;
|
||||
}
|
||||
const pending = this.pendingSessions.get(key);
|
||||
if (pending) return pending;
|
||||
if (!this.createSessionFn) return null;
|
||||
this.reserveSessionCapacity(key);
|
||||
|
||||
const proxy = getProxyFromEnv();
|
||||
const sessionOpts: Record<string, unknown> = {
|
||||
browser: "chrome_124",
|
||||
os: "macos",
|
||||
};
|
||||
if (proxy) {
|
||||
sessionOpts.proxy = proxy;
|
||||
console.log(`[TlsClient] Using proxy: ${proxy}`);
|
||||
}
|
||||
if (resolvedProxy) sessionOpts.proxy = resolvedProxy;
|
||||
const globalEpoch = this.globalSessionEpoch;
|
||||
const sessionEpoch = this.getSessionEpoch(key);
|
||||
|
||||
this.session = await createSessionFn(sessionOpts);
|
||||
console.log("[TlsClient] Session created (Chrome 124 TLS fingerprint)");
|
||||
return this.session;
|
||||
const creating = Reflect.apply(this.createSessionFn, undefined, [sessionOpts])
|
||||
.then(async (session) => {
|
||||
if (
|
||||
globalEpoch !== this.globalSessionEpoch ||
|
||||
sessionEpoch !== this.getSessionEpoch(key)
|
||||
) {
|
||||
await this.closeSession(session);
|
||||
throw new Error("wreq-js session invalidated");
|
||||
}
|
||||
if (this.sessions.size >= this.maxSessions) {
|
||||
const candidate = this.findOldestIdleSession(key);
|
||||
if (!candidate) {
|
||||
await this.closeSession(session);
|
||||
const error = new Error("wreq-js session capacity exhausted") as Error & {
|
||||
code?: string;
|
||||
};
|
||||
error.code = "TLS_SESSION_CAPACITY";
|
||||
throw error;
|
||||
}
|
||||
void this.invalidateSession(candidate);
|
||||
}
|
||||
this.sessions.set(key, session);
|
||||
this.sessionLastUsed.set(key, ++this.accessSequence);
|
||||
this.evictSessionsIfNeeded(key);
|
||||
console.log("[TlsClient] Session created (Chrome 124 TLS fingerprint)");
|
||||
return session;
|
||||
})
|
||||
.finally(() => {
|
||||
if (this.pendingSessions.get(key) === creating) {
|
||||
this.pendingSessions.delete(key);
|
||||
this.sessionEpochs.delete(key);
|
||||
}
|
||||
});
|
||||
this.pendingSessions.set(key, creating);
|
||||
return creating;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch with Chrome 124 TLS fingerprint.
|
||||
* wreq-js Response is already fetch-compatible (headers, text(), json(), clone(), body).
|
||||
*/
|
||||
async fetch(url: string, options: FetchOptions = {}) {
|
||||
if (!this.checkCircuit()) {
|
||||
throw new Error("wreq-js circuit open — skipping TLS request");
|
||||
/** Fetch with Chrome 124 TLS fingerprint and an account-scoped persistent cookie jar. */
|
||||
async fetch(url: string, options: TlsFetchOptions = {}): Promise<Response> {
|
||||
const resolvedProxy = this.resolveProxy(options.proxy);
|
||||
const key = this.getSessionKey(resolvedProxy, options.sessionScope);
|
||||
if (!this.checkCircuit(key)) {
|
||||
const state = this.circuits.get(key);
|
||||
const error = new Error("wreq-js circuit open — skipping TLS request") as Error & {
|
||||
code?: string;
|
||||
};
|
||||
error.code = "TLS_CIRCUIT_OPEN";
|
||||
if (state?.sessionHadCookies) {
|
||||
Object.defineProperty(error, "sessionHadCookies", {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
let session: WreqSession | null = null;
|
||||
let sessionUseRetained = false;
|
||||
const releaseSession = () => {
|
||||
if (!sessionUseRetained) return;
|
||||
sessionUseRetained = false;
|
||||
this.releaseSession(key);
|
||||
};
|
||||
try {
|
||||
const session = await this.getSession();
|
||||
session = await this.getSession(resolvedProxy, key);
|
||||
if (!session) throw new Error("wreq-js not available");
|
||||
this.retainSession(key);
|
||||
sessionUseRetained = true;
|
||||
const { timeoutMs } = getTlsClientTimeoutConfig(process.env, (message) => {
|
||||
console.warn(`[TlsClient] ${message}`);
|
||||
});
|
||||
|
||||
const method = (options.method || "GET").toUpperCase();
|
||||
|
||||
const wreqOptions: Record<string, unknown> = {
|
||||
method,
|
||||
method: (options.method || "GET").toUpperCase(),
|
||||
headers: normalizeHeaders(options.headers),
|
||||
body: options.body,
|
||||
redirect: options.redirect === "manual" ? "manual" : "follow",
|
||||
redirect: options.redirect ?? "follow",
|
||||
timeout: timeoutMs,
|
||||
};
|
||||
if (options.signal) wreqOptions.signal = options.signal;
|
||||
|
||||
if (options.signal) {
|
||||
wreqOptions.signal = options.signal;
|
||||
}
|
||||
|
||||
const response = await session.fetch(url, wreqOptions);
|
||||
this.recordSuccess();
|
||||
const response = toNativeResponse(
|
||||
await session.fetch(url, wreqOptions),
|
||||
releaseSession,
|
||||
() => this.recordFailure(key, this.hasSessionCookies(session, url)),
|
||||
options.signal
|
||||
);
|
||||
this.recordSuccess(key);
|
||||
return response;
|
||||
} catch (err) {
|
||||
const isAbort =
|
||||
err instanceof Error && (err.name === "AbortError" || err.message.includes("aborted"));
|
||||
if (!isAbort) {
|
||||
this.recordFailure();
|
||||
const isCallerAbort = options.signal?.aborted === true;
|
||||
const sessionHadCookies =
|
||||
!isCallerAbort && this.hasSessionCookies(session, url);
|
||||
releaseSession();
|
||||
if (isCallerAbort) {
|
||||
this.releaseHalfOpen(key);
|
||||
} else {
|
||||
this.recordFailure(key, sessionHadCookies);
|
||||
}
|
||||
throw err;
|
||||
if (isCallerAbort) throw err;
|
||||
const transportError = sanitizeWreqError(err, "wreq-js transport failed");
|
||||
if (sessionHadCookies) {
|
||||
Object.defineProperty(transportError, "sessionHadCookies", {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
throw transportError;
|
||||
}
|
||||
}
|
||||
|
||||
async exit() {
|
||||
if (this.session) {
|
||||
await this.session.close();
|
||||
this.session = null;
|
||||
async exit(): Promise<void> {
|
||||
await this.closeSessions();
|
||||
}
|
||||
|
||||
resetCircuit(proxy?: string | null, sessionScope?: string): void {
|
||||
if (arguments.length === 0) {
|
||||
this.circuits.clear();
|
||||
return;
|
||||
}
|
||||
const resolvedProxy = this.resolveProxy(proxy);
|
||||
this.circuits.delete(this.getSessionKey(resolvedProxy, sessionScope));
|
||||
}
|
||||
|
||||
resetCircuit(): void {
|
||||
this.failureCount = 0;
|
||||
this.circuitTripped = false;
|
||||
this.circuitOpenUntil = 0;
|
||||
}
|
||||
|
||||
getCircuitState(): {
|
||||
getCircuitState(
|
||||
proxy?: string | null,
|
||||
sessionScope?: string
|
||||
): {
|
||||
available: boolean;
|
||||
circuitTripped: boolean;
|
||||
failureCount: number;
|
||||
circuitOpenUntil: number;
|
||||
coolDownRemainingMs: number;
|
||||
} {
|
||||
const resolvedProxy = this.resolveProxy(proxy);
|
||||
const key = this.getSessionKey(resolvedProxy, sessionScope);
|
||||
const state = this.circuits.get(key);
|
||||
const circuitOpenUntil = state?.circuitOpenUntil ?? 0;
|
||||
const circuitTripped = state?.circuitTripped ?? false;
|
||||
return {
|
||||
available: this.available,
|
||||
circuitTripped: this.circuitTripped,
|
||||
failureCount: this.failureCount,
|
||||
circuitOpenUntil: this.circuitOpenUntil,
|
||||
available:
|
||||
this._libraryAvailable &&
|
||||
(!circuitTripped || Date.now() >= circuitOpenUntil),
|
||||
circuitTripped,
|
||||
failureCount: state?.failureCount ?? 0,
|
||||
circuitOpenUntil,
|
||||
coolDownRemainingMs:
|
||||
this.circuitOpenUntil > 0 ? Math.max(0, this.circuitOpenUntil - Date.now()) : 0,
|
||||
circuitOpenUntil > 0 ? Math.max(0, circuitOpenUntil - Date.now()) : 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const tlsClient = new TlsClient();
|
||||
const TLS_CLIENT_KEY = Symbol.for("omniroute.tlsClient.instance");
|
||||
const scopedGlobal = globalThis as typeof globalThis & {
|
||||
[TLS_CLIENT_KEY]?: TlsClient;
|
||||
};
|
||||
const tlsClient = scopedGlobal[TLS_CLIENT_KEY] ?? new TlsClient();
|
||||
scopedGlobal[TLS_CLIENT_KEY] = tlsClient;
|
||||
|
||||
export default tlsClient;
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* .next/standalone -> outDir (cp) Y Y Y SHARED
|
||||
* .next/static -> outDir/.next/static (cp) Y Y Y SHARED
|
||||
* public/ -> outDir/public/ (cp) Y Y Y SHARED
|
||||
* wreq-js/rust -> outDir/node_modules/wreq-js/rust Y - - SHARED (native asset)
|
||||
* wreq-js -> outDir/node_modules/wreq-js Y Y Y SHARED (extra module)
|
||||
* better-sqlite3/build -> outDir/node_modules/better-sqlite3/ Y - - SHARED (native asset)
|
||||
* @swc/helpers -> outDir/node_modules/@swc/helpers Y Y Y SHARED (extra module)
|
||||
* pino-abstract-transport -> outDir/node_modules/... Y - - SHARED (extra module)
|
||||
@@ -76,11 +76,6 @@ async function exists(targetPath) {
|
||||
* for either path/platform. @type {{label:string, src:string[], dest:string[]}[]}
|
||||
*/
|
||||
export const NATIVE_ASSET_ENTRIES = [
|
||||
{
|
||||
label: "wreq-js native runtime",
|
||||
src: ["node_modules", "wreq-js", "rust"],
|
||||
dest: ["node_modules", "wreq-js", "rust"],
|
||||
},
|
||||
{
|
||||
label: "better-sqlite3 native binary",
|
||||
src: ["node_modules", "better-sqlite3", "build"],
|
||||
@@ -117,6 +112,15 @@ export const NATIVE_ASSET_ENTRIES = [
|
||||
|
||||
/** @type {{label:string, src:string[], dest:string[]}[]} */
|
||||
const EXTRA_MODULE_ENTRIES = [
|
||||
{
|
||||
// tlsClient.ts intentionally resolves wreq-js through a runtime-dynamic
|
||||
// require so Turbopack cannot rewrite the package name to a hashed external.
|
||||
// That also makes the package invisible to static tracing, so copy the whole
|
||||
// module—not only rust/—into every standalone artifact.
|
||||
label: "wreq-js TLS runtime",
|
||||
src: ["node_modules", "wreq-js"],
|
||||
dest: ["node_modules", "wreq-js"],
|
||||
},
|
||||
{
|
||||
label: "@swc/helpers",
|
||||
src: ["node_modules", "@swc", "helpers"],
|
||||
@@ -278,7 +282,7 @@ const EXTRA_MODULE_ENTRIES = [
|
||||
];
|
||||
|
||||
/**
|
||||
* Copy native standalone assets (wreq-js rust/, better-sqlite3 build/).
|
||||
* Copy native standalone assets (better-sqlite3 build/prebuilds and TPROXY).
|
||||
*
|
||||
* The destination is derived as <rootDir>/<distDir>/standalone/node_modules/...
|
||||
* for backward compatibility with existing callers and tests.
|
||||
@@ -512,8 +516,8 @@ function copyStaticAndPublic({ distDir, relDistDir, projectRoot, resolvedOutDir
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy native assets (wreq-js, better-sqlite3) and extra runtime modules/sidecars
|
||||
* (pino, migrations, MITM server, helper scripts, sqlite-vec platform packages, …)
|
||||
* Copy native assets (better-sqlite3 and TPROXY) and extra runtime modules/sidecars
|
||||
* (wreq-js, pino, migrations, MITM server, helper scripts, sqlite-vec platform packages, …)
|
||||
* into the assembled bundle. Missing sources are skipped silently.
|
||||
*
|
||||
* @param {string} projectRoot
|
||||
|
||||
@@ -529,6 +529,15 @@ export async function executeChatWithBreaker({
|
||||
)
|
||||
);
|
||||
|
||||
const tlsTrackingIdentity = {
|
||||
provider,
|
||||
sessionScope: credentials.connectionId,
|
||||
};
|
||||
// Track whenever direct TLS is possible. proxyFetch decides against wreq only
|
||||
// after resolving NO_PROXY/local bypasses, so predicting from proxyInfo here
|
||||
// would drop the account scope when a configured proxy resolves to direct.
|
||||
const tlsFingerprintActive = isTlsFingerprintActive(provider);
|
||||
|
||||
if (isShadowTraffic) {
|
||||
if (!bypassCircuitBreaker && breaker && !breaker.canExecute()) {
|
||||
const retryAfterMs = breaker.getRetryAfterMs();
|
||||
@@ -542,8 +551,8 @@ export async function executeChatWithBreaker({
|
||||
};
|
||||
}
|
||||
|
||||
if (!proxyInfo?.proxy && isTlsFingerprintActive()) {
|
||||
const tracked = await runWithTlsTracking(chatFn);
|
||||
if (tlsFingerprintActive) {
|
||||
const tracked = await runWithTlsTracking(tlsTrackingIdentity, chatFn);
|
||||
return { result: tracked.result, tlsFingerprintUsed: tracked.tlsFingerprintUsed };
|
||||
}
|
||||
|
||||
@@ -552,8 +561,8 @@ export async function executeChatWithBreaker({
|
||||
}
|
||||
|
||||
if (bypassCircuitBreaker) {
|
||||
if (!proxyInfo?.proxy && isTlsFingerprintActive()) {
|
||||
const tracked = await runWithTlsTracking(chatFn);
|
||||
if (tlsFingerprintActive) {
|
||||
const tracked = await runWithTlsTracking(tlsTrackingIdentity, chatFn);
|
||||
return { result: tracked.result, tlsFingerprintUsed: tracked.tlsFingerprintUsed };
|
||||
}
|
||||
|
||||
@@ -561,8 +570,10 @@ export async function executeChatWithBreaker({
|
||||
return { result, tlsFingerprintUsed: false };
|
||||
}
|
||||
|
||||
if (!proxyInfo?.proxy && isTlsFingerprintActive()) {
|
||||
const tracked = await breaker.execute(async () => runWithTlsTracking(chatFn));
|
||||
if (tlsFingerprintActive) {
|
||||
const tracked = await breaker.execute(async () =>
|
||||
runWithTlsTracking(tlsTrackingIdentity, chatFn)
|
||||
);
|
||||
return { result: tracked.result, tlsFingerprintUsed: tracked.tlsFingerprintUsed };
|
||||
}
|
||||
|
||||
|
||||
@@ -4,14 +4,15 @@ import fs from "node:fs/promises";
|
||||
import fsSync from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const {
|
||||
import {
|
||||
getTransientBuildPaths,
|
||||
movePath,
|
||||
pruneStandaloneArtifacts,
|
||||
resolveNextBuildEnv,
|
||||
syncStandaloneExtraModules,
|
||||
syncStandaloneNativeAssets,
|
||||
} = await import("../../scripts/build/build-next-isolated.mjs");
|
||||
} from "../../scripts/build/build-next-isolated.mjs";
|
||||
|
||||
|
||||
async function withTempDir(fn) {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "omniroute-build-next-isolated-"));
|
||||
@@ -177,36 +178,49 @@ test("pruneStandaloneArtifacts removes traced _tasks from standalone output", as
|
||||
});
|
||||
});
|
||||
|
||||
test("syncStandaloneNativeAssets copies wreq-js native runtime into standalone output", async () => {
|
||||
test("syncStandaloneExtraModules copies the complete wreq-js runtime", async () => {
|
||||
await withTempDir(async (tempDir) => {
|
||||
const sourceNativeFile = path.join(
|
||||
tempDir,
|
||||
"node_modules",
|
||||
"wreq-js",
|
||||
"rust",
|
||||
"wreq-js.linux-x64-gnu.node"
|
||||
);
|
||||
const destinationNativeFile = path.join(
|
||||
const sourcePackage = path.join(tempDir, "node_modules", "wreq-js");
|
||||
const destinationPackage = path.join(
|
||||
tempDir,
|
||||
".build",
|
||||
"next",
|
||||
"standalone",
|
||||
"node_modules",
|
||||
"wreq-js",
|
||||
"rust",
|
||||
"wreq-js.linux-x64-gnu.node"
|
||||
"wreq-js"
|
||||
);
|
||||
const logs: string[] = [];
|
||||
|
||||
await fs.mkdir(path.dirname(sourceNativeFile), { recursive: true });
|
||||
await fs.writeFile(sourceNativeFile, "native module bytes");
|
||||
|
||||
const changed = await syncStandaloneNativeAssets(tempDir, fs, {
|
||||
const logger: Console = Object.assign(Object.create(console), {
|
||||
log: (message: unknown) => logs.push(String(message)),
|
||||
});
|
||||
|
||||
await fs.mkdir(path.join(sourcePackage, "dist"), { recursive: true });
|
||||
await fs.mkdir(path.join(sourcePackage, "rust"), { recursive: true });
|
||||
await fs.writeFile(path.join(sourcePackage, "package.json"), '{"name":"wreq-js"}');
|
||||
await fs.writeFile(path.join(sourcePackage, "dist", "wreq-js.cjs"), "exports.fetch = () => {}");
|
||||
await fs.writeFile(
|
||||
path.join(sourcePackage, "rust", "wreq-js.linux-x64-gnu.node"),
|
||||
"native module bytes"
|
||||
);
|
||||
|
||||
const changed = await syncStandaloneExtraModules(tempDir, fs, logger);
|
||||
|
||||
assert.equal(changed, true);
|
||||
assert.equal(await fs.readFile(destinationNativeFile, "utf8"), "native module bytes");
|
||||
assert.match((logs[0] ?? "").replaceAll("\\", "/"), /wreq-js\/rust/);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(destinationPackage, "package.json"), "utf8"),
|
||||
'{"name":"wreq-js"}'
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(destinationPackage, "dist", "wreq-js.cjs"), "utf8"),
|
||||
"exports.fetch = () => {}"
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(
|
||||
path.join(destinationPackage, "rust", "wreq-js.linux-x64-gnu.node"),
|
||||
"utf8"
|
||||
),
|
||||
"native module bytes"
|
||||
);
|
||||
assert.match(logs[0] ?? "", /wreq-js TLS runtime/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import net from "node:net";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-chat-helpers-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
@@ -21,6 +22,8 @@ const {
|
||||
} = await import("../../src/sse/handlers/chatHelpers.ts");
|
||||
const { getCircuitBreaker, resetAllCircuitBreakers, STATE } =
|
||||
await import("../../src/shared/utils/circuitBreaker.ts");
|
||||
// DATA_DIR must be fixed before these modules load; keep this test seam dynamic.
|
||||
const { setTlsClientForTest } = await import("../../open-sse/utils/proxyFetch.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
resetAllCircuitBreakers();
|
||||
@@ -421,6 +424,96 @@ test("executeChatWithBreaker converts proxy fast-fail errors", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("executeChatWithBreaker preserves account TLS scope when a proxy bypasses to direct", async () => {
|
||||
const server = net.createServer((socket) => socket.end());
|
||||
const listening = Promise.withResolvers<void>();
|
||||
server.listen(0, "127.0.0.1", listening.resolve);
|
||||
await listening.promise;
|
||||
const address = server.address();
|
||||
assert.ok(address && typeof address !== "string");
|
||||
const prior = {
|
||||
enable: process.env.ENABLE_TLS_FINGERPRINT,
|
||||
providers: process.env.TLS_FINGERPRINT_PROVIDERS,
|
||||
noProxy: process.env.NO_PROXY,
|
||||
};
|
||||
process.env.ENABLE_TLS_FINGERPRINT = "true";
|
||||
delete process.env.TLS_FINGERPRINT_PROVIDERS;
|
||||
process.env.NO_PROXY = "api.openai.com";
|
||||
let observedProxy: string | null | undefined;
|
||||
let observedScope: string | undefined;
|
||||
setTlsClientForTest({
|
||||
available: true,
|
||||
fetch: async (_url, options) => {
|
||||
observedProxy = options?.proxy;
|
||||
observedScope = options?.sessionScope;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: "chatcmpl-test",
|
||||
object: "chat.completion",
|
||||
created: 0,
|
||||
model: "gpt-4o-mini",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: "assistant", content: "ok" },
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
||||
}),
|
||||
{ headers: { "content-type": "application/json" } },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const credentials = {
|
||||
connectionId: "conn_tls_scope",
|
||||
apiKey: "sk-openai-helper",
|
||||
providerSpecificData: {},
|
||||
};
|
||||
const result = await executeChatWithBreaker({
|
||||
bypassCircuitBreaker: false,
|
||||
breaker: getCircuitBreaker("openai"),
|
||||
body: { model: "openai/gpt-4o-mini", messages: [] },
|
||||
provider: "openai",
|
||||
model: "gpt-4o-mini",
|
||||
refreshedCredentials: credentials,
|
||||
proxyInfo: {
|
||||
proxy: `http://127.0.0.1:${address.port}`,
|
||||
level: "connection",
|
||||
levelId: credentials.connectionId,
|
||||
},
|
||||
log: console,
|
||||
clientRawRequest: null,
|
||||
credentials,
|
||||
apiKeyInfo: null,
|
||||
userAgent: "",
|
||||
comboName: null,
|
||||
comboStrategy: null,
|
||||
isCombo: false,
|
||||
extendedContext: false,
|
||||
comboStepId: null,
|
||||
comboExecutionKey: null,
|
||||
});
|
||||
|
||||
assert.equal(result.tlsFingerprintUsed, true);
|
||||
assert.equal(observedProxy, null);
|
||||
assert.equal(observedScope, credentials.connectionId);
|
||||
} finally {
|
||||
setTlsClientForTest(null);
|
||||
if (prior.enable === undefined) delete process.env.ENABLE_TLS_FINGERPRINT;
|
||||
else process.env.ENABLE_TLS_FINGERPRINT = prior.enable;
|
||||
if (prior.providers === undefined) delete process.env.TLS_FINGERPRINT_PROVIDERS;
|
||||
else process.env.TLS_FINGERPRINT_PROVIDERS = prior.providers;
|
||||
if (prior.noProxy === undefined) delete process.env.NO_PROXY;
|
||||
else process.env.NO_PROXY = prior.noProxy;
|
||||
const closed = Promise.withResolvers<void>();
|
||||
server.close(() => closed.resolve());
|
||||
await closed.promise;
|
||||
}
|
||||
});
|
||||
|
||||
test("safeLogEvents tolerates success and timeout payloads", () => {
|
||||
const credentials = { connectionId: "conn_log_12345678" };
|
||||
|
||||
|
||||
989
tests/unit/tls-proxy-context.test.ts
Normal file
989
tests/unit/tls-proxy-context.test.ts
Normal file
@@ -0,0 +1,989 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
proxyFetch,
|
||||
resolveProxyForRequest,
|
||||
runWithProxyContext,
|
||||
runWithTlsTracking,
|
||||
setTlsClientForTest,
|
||||
} from "../../open-sse/utils/proxyFetch.ts";
|
||||
import tlsClient, {
|
||||
TlsClient,
|
||||
type TlsFetchOptions,
|
||||
type WreqSession,
|
||||
} from "../../open-sse/utils/tlsClient.ts";
|
||||
import { httpBackedChat } from "../../open-sse/services/browserBackedChat.ts";
|
||||
|
||||
type EnvState = Record<string, string | undefined>;
|
||||
|
||||
const ENV_KEYS = [
|
||||
"ENABLE_TLS_FINGERPRINT",
|
||||
"TLS_FINGERPRINT_PROVIDERS",
|
||||
"HTTPS_PROXY",
|
||||
"https_proxy",
|
||||
"HTTP_PROXY",
|
||||
"http_proxy",
|
||||
"ALL_PROXY",
|
||||
"all_proxy",
|
||||
"NO_PROXY",
|
||||
"no_proxy",
|
||||
"OMNIROUTE_CONTROL_PLANE_PROXY_DIRECT_FALLBACK",
|
||||
"PROXY_AUTO_SELECT_ENABLED",
|
||||
] as const;
|
||||
|
||||
async function withEnv(env: EnvState, fn: () => Promise<void> | void): Promise<void> {
|
||||
const prior = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]]));
|
||||
for (const key of ENV_KEYS) {
|
||||
if (env[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = env[key];
|
||||
}
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
for (const key of ENV_KEYS) {
|
||||
if (prior[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = prior[key];
|
||||
}
|
||||
setTlsClientForTest(null);
|
||||
}
|
||||
}
|
||||
|
||||
function fakeTlsClient(
|
||||
fetch: (url: string, options?: TlsFetchOptions) => Promise<Response>,
|
||||
) {
|
||||
return { available: true, fetch };
|
||||
}
|
||||
|
||||
test("explicit direct proxy resolution keeps a session and never rereads the environment", async () => {
|
||||
await withEnv({ HTTPS_PROXY: "http://placeholder.proxy:8080" }, async () => {
|
||||
const created: Array<Record<string, unknown>> = [];
|
||||
const client = new TlsClient(async (options) => {
|
||||
created.push(options);
|
||||
return {
|
||||
close: async () => {},
|
||||
fetch: async () => new Response("ok"),
|
||||
};
|
||||
});
|
||||
|
||||
await client.fetch("https://upstream.example", { proxy: null });
|
||||
await client.fetch("https://upstream.example", { proxy: null });
|
||||
await client.fetch("https://upstream.example");
|
||||
|
||||
assert.equal(created.length, 2);
|
||||
assert.equal(created[0]?.proxy, undefined);
|
||||
assert.equal(created[1]?.proxy, "http://placeholder.proxy:8080");
|
||||
});
|
||||
});
|
||||
|
||||
test("same proxy is isolated by stable account session scope", async () => {
|
||||
const created: Array<Record<string, unknown>> = [];
|
||||
const client = new TlsClient(async (options) => {
|
||||
created.push(options);
|
||||
return {
|
||||
close: async () => {},
|
||||
fetch: async () => new Response("ok"),
|
||||
};
|
||||
});
|
||||
|
||||
const proxy = "http://shared.proxy:8080";
|
||||
await client.fetch("https://upstream.example", { proxy, sessionScope: "account-a" });
|
||||
await client.fetch("https://upstream.example", { proxy, sessionScope: "account-a" });
|
||||
await client.fetch("https://upstream.example", { proxy, sessionScope: "account-b" });
|
||||
|
||||
assert.equal(created.length, 2);
|
||||
});
|
||||
|
||||
test("circuit failures are isolated to the exact session scope and proxy", async () => {
|
||||
const client = new TlsClient(async (options) => ({
|
||||
close: async () => {},
|
||||
fetch: async () => {
|
||||
if (options.proxy === "http://bad.proxy:8080") throw new Error("bad proxy");
|
||||
return new Response("good");
|
||||
},
|
||||
}));
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
await assert.rejects(
|
||||
client.fetch("https://upstream.example", {
|
||||
proxy: "http://bad.proxy:8080",
|
||||
sessionScope: "bad-account",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const response = await client.fetch("https://upstream.example", {
|
||||
proxy: "http://good.proxy:8080",
|
||||
sessionScope: "good-account",
|
||||
});
|
||||
assert.equal(await response.text(), "good");
|
||||
});
|
||||
|
||||
test("redirect error semantics are forwarded to wreq unchanged", async () => {
|
||||
let redirect: unknown;
|
||||
const client = new TlsClient(async () => ({
|
||||
close: async () => {},
|
||||
fetch: async (_url, options) => {
|
||||
redirect = options?.redirect;
|
||||
return new Response("ok");
|
||||
},
|
||||
}));
|
||||
|
||||
await client.fetch("https://upstream.example", { redirect: "error" });
|
||||
assert.equal(redirect, "error");
|
||||
});
|
||||
|
||||
test("Request input bypasses wreq without losing method headers or body", async () => {
|
||||
await withEnv(
|
||||
{
|
||||
ENABLE_TLS_FINGERPRINT: "true",
|
||||
TLS_FINGERPRINT_PROVIDERS: "codex",
|
||||
},
|
||||
async () => {
|
||||
let tlsCalls = 0;
|
||||
let received: Request | null = null;
|
||||
setTlsClientForTest(
|
||||
fakeTlsClient(async () => {
|
||||
tlsCalls++;
|
||||
return new Response("tls");
|
||||
}),
|
||||
);
|
||||
|
||||
const input = new Request("https://upstream.example/v1", {
|
||||
method: "POST",
|
||||
headers: { "x-test": "present" },
|
||||
body: "payload",
|
||||
});
|
||||
const tracked = await runWithTlsTracking("codex", () =>
|
||||
proxyFetch(input, {}, {
|
||||
undiciFetch: async (forwarded) => {
|
||||
received = forwarded as Request;
|
||||
return new Response("dispatcher");
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(tlsCalls, 0);
|
||||
assert.equal(received, input);
|
||||
assert.equal(received?.method, "POST");
|
||||
assert.equal(received?.headers.get("x-test"), "present");
|
||||
assert.equal(await received?.text(), "payload");
|
||||
assert.equal(tracked.tlsFingerprintUsed, false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("non-idempotent TLS failures are never replayed", async () => {
|
||||
await withEnv(
|
||||
{
|
||||
ENABLE_TLS_FINGERPRINT: "true",
|
||||
TLS_FINGERPRINT_PROVIDERS: "codex",
|
||||
},
|
||||
async () => {
|
||||
let dispatcherCalls = 0;
|
||||
setTlsClientForTest(
|
||||
fakeTlsClient(async () => {
|
||||
throw new Error("post-send transport failure");
|
||||
}),
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
runWithTlsTracking("codex", () =>
|
||||
proxyFetch(
|
||||
"https://upstream.example/v1",
|
||||
{ method: "POST", body: "{}" },
|
||||
{
|
||||
undiciFetch: async () => {
|
||||
dispatcherCalls++;
|
||||
return new Response("unexpected");
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
(error: Error & { code?: string }) =>
|
||||
error.code === "TLS_FINGERPRINT_FAILED" &&
|
||||
error.message === "TLS fingerprint request failed; request is not safe to replay",
|
||||
);
|
||||
assert.equal(dispatcherCalls, 0);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("safe GET TLS failure falls back through the same configured proxy", async () => {
|
||||
await withEnv(
|
||||
{
|
||||
ENABLE_TLS_FINGERPRINT: "true",
|
||||
TLS_FINGERPRINT_PROVIDERS: "codex",
|
||||
HTTPS_PROXY: "http://placeholder.proxy:8080",
|
||||
},
|
||||
async () => {
|
||||
setTlsClientForTest(
|
||||
fakeTlsClient(async () => {
|
||||
throw new Error("transport failed");
|
||||
}),
|
||||
);
|
||||
let dispatcher: unknown;
|
||||
const tracked = await runWithTlsTracking("codex", () =>
|
||||
proxyFetch("https://upstream.example/v1", {}, {
|
||||
undiciFetch: async (_input, init) => {
|
||||
dispatcher = init?.dispatcher;
|
||||
return new Response("fallback");
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.ok(dispatcher);
|
||||
assert.equal(await tracked.result.text(), "fallback");
|
||||
assert.equal(tracked.tlsFingerprintUsed, false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("internal TimeoutError is not classified as a caller abort", async () => {
|
||||
await withEnv(
|
||||
{
|
||||
ENABLE_TLS_FINGERPRINT: "true",
|
||||
TLS_FINGERPRINT_PROVIDERS: "codex",
|
||||
},
|
||||
async () => {
|
||||
const timeout = new Error("internal timeout");
|
||||
timeout.name = "TimeoutError";
|
||||
let dispatcherCalls = 0;
|
||||
setTlsClientForTest(
|
||||
fakeTlsClient(async () => {
|
||||
throw timeout;
|
||||
}),
|
||||
);
|
||||
|
||||
const tracked = await runWithTlsTracking("codex", () =>
|
||||
proxyFetch("https://upstream.example/v1", {}, {
|
||||
undiciFetch: async () => {
|
||||
dispatcherCalls++;
|
||||
return new Response("fallback");
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(dispatcherCalls, 1);
|
||||
assert.equal(await tracked.result.text(), "fallback");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("control-plane direct fallback bypasses an environment proxy", async () => {
|
||||
await withEnv(
|
||||
{
|
||||
HTTPS_PROXY: "http://placeholder.proxy:8080",
|
||||
OMNIROUTE_CONTROL_PLANE_PROXY_DIRECT_FALLBACK: "true",
|
||||
},
|
||||
async () => {
|
||||
const result = await runWithProxyContext(
|
||||
{ type: "http", host: "127.0.0.1", port: "9" },
|
||||
() => resolveProxyForRequest("https://upstream.example/v1"),
|
||||
{ directFallbackOnUnreachable: true },
|
||||
);
|
||||
|
||||
assert.deepEqual(result, { source: "direct", proxyUrl: null });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("new proxied TLS transport requires an explicit provider allowlist", async () => {
|
||||
await withEnv(
|
||||
{
|
||||
ENABLE_TLS_FINGERPRINT: "true",
|
||||
TLS_FINGERPRINT_PROVIDERS: undefined,
|
||||
HTTPS_PROXY: "http://placeholder.proxy:8080",
|
||||
},
|
||||
async () => {
|
||||
let tlsCalls = 0;
|
||||
let dispatcherCalls = 0;
|
||||
setTlsClientForTest(
|
||||
fakeTlsClient(async () => {
|
||||
tlsCalls++;
|
||||
return new Response("tls");
|
||||
}),
|
||||
);
|
||||
|
||||
const tracked = await runWithTlsTracking("codex", () =>
|
||||
proxyFetch("https://upstream.example/v1", {}, {
|
||||
undiciFetch: async () => {
|
||||
dispatcherCalls++;
|
||||
return new Response("dispatcher");
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(tlsCalls, 0);
|
||||
assert.equal(dispatcherCalls, 1);
|
||||
assert.equal(await tracked.result.text(), "dispatcher");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("caller abort propagates unchanged and never falls back", async () => {
|
||||
await withEnv(
|
||||
{
|
||||
ENABLE_TLS_FINGERPRINT: "true",
|
||||
TLS_FINGERPRINT_PROVIDERS: "codex",
|
||||
},
|
||||
async () => {
|
||||
const controller = new AbortController();
|
||||
const abortError = new Error("caller stopped");
|
||||
let dispatcherCalls = 0;
|
||||
setTlsClientForTest(
|
||||
fakeTlsClient(async () => {
|
||||
controller.abort(abortError);
|
||||
throw abortError;
|
||||
}),
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
runWithTlsTracking("codex", () =>
|
||||
proxyFetch(
|
||||
"https://upstream.example/v1",
|
||||
{ signal: controller.signal },
|
||||
{
|
||||
undiciFetch: async () => {
|
||||
dispatcherCalls++;
|
||||
return new Response("unexpected");
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
(error) => error === abortError,
|
||||
);
|
||||
assert.equal(dispatcherCalls, 0);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("stateful TLS session failures never fall back even for GET", async () => {
|
||||
await withEnv(
|
||||
{
|
||||
ENABLE_TLS_FINGERPRINT: "true",
|
||||
TLS_FINGERPRINT_PROVIDERS: "codex",
|
||||
},
|
||||
async () => {
|
||||
let dispatcherCalls = 0;
|
||||
setTlsClientForTest(
|
||||
fakeTlsClient(async () => {
|
||||
const error = new Error("transport failed");
|
||||
Object.defineProperty(error, "sessionHadCookies", { value: true });
|
||||
throw error;
|
||||
}),
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
runWithTlsTracking("codex", () =>
|
||||
proxyFetch("https://upstream.example/v1", {}, {
|
||||
undiciFetch: async () => {
|
||||
dispatcherCalls++;
|
||||
return new Response("unexpected");
|
||||
},
|
||||
}),
|
||||
),
|
||||
(error: Error & { code?: string }) =>
|
||||
error.code === "TLS_FINGERPRINT_FAILED" &&
|
||||
error.message === "TLS fingerprint request failed; stateful session cannot be replayed",
|
||||
);
|
||||
assert.equal(dispatcherCalls, 0);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("TLS transport failures never expose proxy credentials", async () => {
|
||||
await withEnv(
|
||||
{
|
||||
ENABLE_TLS_FINGERPRINT: "true",
|
||||
TLS_FINGERPRINT_PROVIDERS: "codex",
|
||||
HTTPS_PROXY: "http://user:password@placeholder.proxy:8080",
|
||||
},
|
||||
async () => {
|
||||
const warnings: string[] = [];
|
||||
const originalWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => warnings.push(args.map(String).join(" "));
|
||||
try {
|
||||
setTlsClientForTest(
|
||||
fakeTlsClient(async () => {
|
||||
throw new Error(
|
||||
"connect failed via http://user:password@placeholder.proxy:8080",
|
||||
);
|
||||
}),
|
||||
);
|
||||
const tracked = await runWithTlsTracking("codex", () =>
|
||||
proxyFetch("https://upstream.example/v1", {}, {
|
||||
undiciFetch: async () => new Response("fallback"),
|
||||
}),
|
||||
);
|
||||
assert.equal(await tracked.result.text(), "fallback");
|
||||
assert.equal(warnings.some((line) => line.includes("user:password")), false);
|
||||
assert.equal(warnings.some((line) => line.includes("placeholder.proxy")), false);
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("family-pinned proxies retain dispatcher enforcement instead of using wreq", async () => {
|
||||
await withEnv(
|
||||
{
|
||||
ENABLE_TLS_FINGERPRINT: "true",
|
||||
TLS_FINGERPRINT_PROVIDERS: "codex",
|
||||
HTTPS_PROXY: "http://placeholder.proxy:8080?family=ipv4",
|
||||
},
|
||||
async () => {
|
||||
let tlsCalls = 0;
|
||||
let dispatcherCalls = 0;
|
||||
setTlsClientForTest(
|
||||
fakeTlsClient(async () => {
|
||||
tlsCalls++;
|
||||
return new Response("tls");
|
||||
}),
|
||||
);
|
||||
|
||||
const tracked = await runWithTlsTracking("codex", () =>
|
||||
proxyFetch("https://upstream.example/v1", {}, {
|
||||
undiciFetch: async () => {
|
||||
dispatcherCalls++;
|
||||
return new Response("dispatcher");
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert.equal(tlsCalls, 0);
|
||||
assert.equal(dispatcherCalls, 1);
|
||||
assert.equal(await tracked.result.text(), "dispatcher");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("relay contexts never route through wreq", async () => {
|
||||
await withEnv(
|
||||
{
|
||||
ENABLE_TLS_FINGERPRINT: "true",
|
||||
TLS_FINGERPRINT_PROVIDERS: "codex",
|
||||
},
|
||||
async () => {
|
||||
let tlsCalls = 0;
|
||||
let relayCalls = 0;
|
||||
setTlsClientForTest(
|
||||
fakeTlsClient(async () => {
|
||||
tlsCalls++;
|
||||
return new Response("tls");
|
||||
}),
|
||||
);
|
||||
|
||||
const tracked = await runWithTlsTracking("codex", () =>
|
||||
runWithProxyContext(
|
||||
{ type: "vercel", host: "relay.example", relayAuth: "test-auth" },
|
||||
() =>
|
||||
proxyFetch("https://upstream.example/v1", {}, {
|
||||
undiciFetch: async () => {
|
||||
relayCalls++;
|
||||
return new Response("relay");
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
assert.equal(tlsCalls, 0);
|
||||
assert.equal(relayCalls, 1);
|
||||
assert.equal(await tracked.result.text(), "relay");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("wreq responses are adapted to the native Response API", async () => {
|
||||
const sourceBody = new Response("adapted").body;
|
||||
assert.ok(sourceBody);
|
||||
const client = new TlsClient(async () => ({
|
||||
close: async () => {},
|
||||
fetch: async () => ({
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: new Map([["x-source", "wreq"]]),
|
||||
body: sourceBody,
|
||||
url: "https://upstream.example/v1",
|
||||
redirected: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
const response = await client.fetch("https://upstream.example/v1", { proxy: null });
|
||||
assert.equal(response instanceof Response, true);
|
||||
assert.equal(response.headers.get("x-source"), "wreq");
|
||||
assert.equal(response.url, "https://upstream.example/v1");
|
||||
assert.equal(response.redirected, true);
|
||||
assert.equal(await response.text(), "adapted");
|
||||
});
|
||||
|
||||
test("exit waits for pending session creation and closes the late session", async () => {
|
||||
type TestSession = {
|
||||
close: () => Promise<void>;
|
||||
fetch: () => Promise<Response>;
|
||||
};
|
||||
const sessionGate = Promise.withResolvers<TestSession>();
|
||||
const creationStarted = Promise.withResolvers<void>();
|
||||
const closeStarted = Promise.withResolvers<void>();
|
||||
const closeGate = Promise.withResolvers<void>();
|
||||
let closed = 0;
|
||||
const client = new TlsClient(() => {
|
||||
creationStarted.resolve();
|
||||
return sessionGate.promise;
|
||||
});
|
||||
const request = client.fetch("https://upstream.example/v1", { proxy: null });
|
||||
await creationStarted.promise;
|
||||
|
||||
let exitSettled = false;
|
||||
const exiting = client.exit().then(() => {
|
||||
exitSettled = true;
|
||||
});
|
||||
sessionGate.resolve({
|
||||
close: async () => {
|
||||
closed++;
|
||||
closeStarted.resolve();
|
||||
await closeGate.promise;
|
||||
},
|
||||
fetch: async () => new Response("unexpected"),
|
||||
});
|
||||
await closeStarted.promise;
|
||||
assert.equal(exitSettled, false);
|
||||
|
||||
closeGate.resolve();
|
||||
await assert.rejects(request, /wreq-js transport failed/);
|
||||
await exiting;
|
||||
assert.equal(closed, 1);
|
||||
});
|
||||
|
||||
test("bounded session cache closes the least-recently-used idle session", async () => {
|
||||
const closed: string[] = [];
|
||||
const client = new TlsClient(async (options) => {
|
||||
const proxy = String(options.proxy);
|
||||
return {
|
||||
close: async () => {
|
||||
closed.push(proxy);
|
||||
},
|
||||
fetch: async () => new Response("ok"),
|
||||
};
|
||||
}, 2);
|
||||
|
||||
await client.fetch("https://upstream.example/v1", { proxy: "http://proxy-1:8080" });
|
||||
await client.fetch("https://upstream.example/v1", { proxy: "http://proxy-2:8080" });
|
||||
await client.fetch("https://upstream.example/v1", { proxy: "http://proxy-3:8080" });
|
||||
|
||||
assert.deepEqual(closed, ["http://proxy-1:8080"]);
|
||||
await client.exit();
|
||||
});
|
||||
|
||||
test("direct browser-backed TLS calls isolate sessions by pool key", async () => {
|
||||
const originalFetch = tlsClient.fetch.bind(tlsClient);
|
||||
let observedScope: string | undefined;
|
||||
tlsClient.fetch = async (_url, options) => {
|
||||
observedScope = options?.sessionScope;
|
||||
return new Response("ok", {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
};
|
||||
try {
|
||||
const result = await httpBackedChat({
|
||||
poolKey: "claude-web:account-123",
|
||||
chatUrl: "https://claude.ai/api/chat",
|
||||
chatPageUrl: "https://claude.ai/new",
|
||||
userMessage: "hello",
|
||||
chatUrlMatchDomain: "claude.ai",
|
||||
inputSelector: "#prompt",
|
||||
});
|
||||
assert.equal(result.status, 200);
|
||||
assert.equal(observedScope, "claude-web:account-123");
|
||||
} finally {
|
||||
tlsClient.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("allowlisted proxied TLS receives the exact proxy and account scope", async () => {
|
||||
await withEnv(
|
||||
{
|
||||
ENABLE_TLS_FINGERPRINT: "true",
|
||||
TLS_FINGERPRINT_PROVIDERS: "codex",
|
||||
HTTPS_PROXY: "http://placeholder.proxy:8080",
|
||||
},
|
||||
async () => {
|
||||
let observedOptions: TlsFetchOptions | undefined;
|
||||
setTlsClientForTest(
|
||||
fakeTlsClient(async (_url, options) => {
|
||||
observedOptions = options;
|
||||
return new Response("tls");
|
||||
}),
|
||||
);
|
||||
const tracked = await runWithTlsTracking(
|
||||
{ provider: "codex", sessionScope: "connection-123" },
|
||||
() =>
|
||||
proxyFetch("https://upstream.example/v1", {}, {
|
||||
undiciFetch: async () => {
|
||||
throw new Error("dispatcher must not run");
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(observedOptions?.proxy, "http://placeholder.proxy:8080");
|
||||
assert.equal(observedOptions?.sessionScope, "connection-123");
|
||||
assert.equal(tracked.tlsFingerprintUsed, true);
|
||||
assert.equal(await tracked.result.text(), "tls");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("proxy dispatcher failures sanitize logs and propagated errors", async () => {
|
||||
await withEnv(
|
||||
{
|
||||
ENABLE_TLS_FINGERPRINT: "false",
|
||||
HTTPS_PROXY: "http://user:password@placeholder.proxy:8080",
|
||||
},
|
||||
async () => {
|
||||
const errors: string[] = [];
|
||||
const originalError = console.error;
|
||||
console.error = (...args: unknown[]) => errors.push(args.map(String).join(" "));
|
||||
try {
|
||||
let caught: unknown;
|
||||
try {
|
||||
await proxyFetch("https://upstream.example/v1", {}, {
|
||||
undiciFetch: async () => {
|
||||
const error = new Error(
|
||||
"connect failed via http://user:password@placeholder.proxy:8080",
|
||||
) as Error & { code?: string };
|
||||
error.code = "ECONNREFUSED";
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
assert.ok(caught instanceof Error);
|
||||
assert.equal(caught.message, "Proxy request failed");
|
||||
assert.equal("code" in caught ? caught.code : undefined, "PROXY_UNREACHABLE");
|
||||
assert.equal(errors.some((line) => line.includes("user:password")), false);
|
||||
assert.equal(errors.some((line) => line.includes("placeholder.proxy")), false);
|
||||
} finally {
|
||||
console.error = originalError;
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("half-open circuit admits only one probe for an isolated session key", async () => {
|
||||
const originalNow = Date.now;
|
||||
const probeStarted = Promise.withResolvers<void>();
|
||||
const probeGate = Promise.withResolvers<void>();
|
||||
let probeMode = false;
|
||||
let fetchCalls = 0;
|
||||
const client = new TlsClient(async () => ({
|
||||
close: async () => {},
|
||||
fetch: async () => {
|
||||
fetchCalls++;
|
||||
if (!probeMode) throw new Error("upstream unavailable");
|
||||
probeStarted.resolve();
|
||||
await probeGate.promise;
|
||||
return new Response("recovered");
|
||||
},
|
||||
}));
|
||||
|
||||
try {
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
await assert.rejects(
|
||||
client.fetch("https://upstream.example/v1", {
|
||||
proxy: null,
|
||||
sessionScope: "connection-123",
|
||||
}),
|
||||
/wreq-js transport failed/,
|
||||
);
|
||||
}
|
||||
probeMode = true;
|
||||
const afterCooldown = originalNow() + 31_000;
|
||||
Date.now = () => afterCooldown;
|
||||
|
||||
const probe = client.fetch("https://upstream.example/v1", {
|
||||
proxy: null,
|
||||
sessionScope: "connection-123",
|
||||
});
|
||||
await probeStarted.promise;
|
||||
await assert.rejects(
|
||||
client.fetch("https://upstream.example/v1", {
|
||||
proxy: null,
|
||||
sessionScope: "connection-123",
|
||||
}),
|
||||
(error: unknown) =>
|
||||
error instanceof Error &&
|
||||
"code" in error &&
|
||||
error.code === "TLS_CIRCUIT_OPEN",
|
||||
);
|
||||
assert.equal(fetchCalls, 4);
|
||||
probeGate.resolve();
|
||||
assert.equal(await (await probe).text(), "recovered");
|
||||
} finally {
|
||||
Date.now = originalNow;
|
||||
probeGate.resolve();
|
||||
await client.exit();
|
||||
}
|
||||
});
|
||||
|
||||
test("circuit invalidation snapshots cookies before closing the failed session", async () => {
|
||||
let closed = false;
|
||||
const client = new TlsClient(async () => ({
|
||||
close: () => {
|
||||
closed = true;
|
||||
},
|
||||
getCookies: () => (closed ? {} : { session: "account-a" }),
|
||||
fetch: async () => {
|
||||
throw new Error("upstream unavailable");
|
||||
},
|
||||
}));
|
||||
|
||||
try {
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
await assert.rejects(
|
||||
client.fetch("https://upstream.example/v1", {
|
||||
proxy: null,
|
||||
sessionScope: "connection-123",
|
||||
}),
|
||||
(error: unknown) =>
|
||||
error instanceof Error &&
|
||||
"sessionHadCookies" in error &&
|
||||
error.sessionHadCookies === true,
|
||||
);
|
||||
}
|
||||
await Promise.resolve();
|
||||
assert.equal(closed, true);
|
||||
await assert.rejects(
|
||||
client.fetch("https://upstream.example/v1", {
|
||||
proxy: null,
|
||||
sessionScope: "connection-123",
|
||||
}),
|
||||
(error: unknown) =>
|
||||
error instanceof Error &&
|
||||
"code" in error &&
|
||||
error.code === "TLS_CIRCUIT_OPEN" &&
|
||||
"sessionHadCookies" in error &&
|
||||
error.sessionHadCookies === true,
|
||||
);
|
||||
} finally {
|
||||
await client.exit();
|
||||
}
|
||||
});
|
||||
|
||||
test("pending session creation is bounded per TLS client", async () => {
|
||||
const sessionGates = [
|
||||
Promise.withResolvers<WreqSession>(),
|
||||
Promise.withResolvers<WreqSession>(),
|
||||
];
|
||||
let creates = 0;
|
||||
const client = new TlsClient(() => {
|
||||
const gate = sessionGates[creates++];
|
||||
if (!gate) throw new Error("unexpected session creation");
|
||||
return gate.promise;
|
||||
}, 2);
|
||||
const session: WreqSession = {
|
||||
close: async () => {},
|
||||
fetch: async () => new Response("ok"),
|
||||
};
|
||||
|
||||
const first = client.fetch("https://upstream.example/v1", {
|
||||
proxy: "http://proxy-1:8080",
|
||||
sessionScope: "connection-1",
|
||||
});
|
||||
const second = client.fetch("https://upstream.example/v1", {
|
||||
proxy: "http://proxy-2:8080",
|
||||
sessionScope: "connection-2",
|
||||
});
|
||||
try {
|
||||
assert.equal(creates, 2);
|
||||
await assert.rejects(
|
||||
client.fetch("https://upstream.example/v1", {
|
||||
proxy: "http://proxy-3:8080",
|
||||
sessionScope: "connection-3",
|
||||
}),
|
||||
(error: unknown) =>
|
||||
error instanceof Error &&
|
||||
"code" in error &&
|
||||
error.code === "TLS_SESSION_CAPACITY",
|
||||
);
|
||||
assert.equal(creates, 2);
|
||||
|
||||
for (const gate of sessionGates) gate.resolve(session);
|
||||
assert.equal(await (await first).text(), "ok");
|
||||
assert.equal(await (await second).text(), "ok");
|
||||
} finally {
|
||||
for (const gate of sessionGates) gate.resolve(session);
|
||||
await Promise.allSettled([first, second]);
|
||||
await client.exit();
|
||||
}
|
||||
});
|
||||
|
||||
test("direct TLS fallback never auto-selects a different proxy route", async () => {
|
||||
await withEnv(
|
||||
{
|
||||
ENABLE_TLS_FINGERPRINT: "true",
|
||||
TLS_FINGERPRINT_PROVIDERS: undefined,
|
||||
PROXY_AUTO_SELECT_ENABLED: "true",
|
||||
},
|
||||
async () => {
|
||||
let tlsCalls = 0;
|
||||
let dispatcherCalls = 0;
|
||||
let autoSelectCalls = 0;
|
||||
let nativeCalls = 0;
|
||||
setTlsClientForTest(
|
||||
fakeTlsClient(async () => {
|
||||
tlsCalls++;
|
||||
throw new Error("wreq transport failed");
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await proxyFetch("https://upstream.example/v1", {}, {
|
||||
undiciFetch: async () => {
|
||||
dispatcherCalls++;
|
||||
const error = new Error("fetch failed: ECONNREFUSED") as Error & { code?: string };
|
||||
error.code = "ECONNREFUSED";
|
||||
throw error;
|
||||
},
|
||||
findWorkingProxy: async () => {
|
||||
autoSelectCalls++;
|
||||
return "http://unexpected.proxy:8080";
|
||||
},
|
||||
nativeFetch: async () => {
|
||||
nativeCalls++;
|
||||
return new Response("native");
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(tlsCalls, 1);
|
||||
assert.equal(dispatcherCalls, 2);
|
||||
assert.equal(autoSelectCalls, 0);
|
||||
assert.equal(nativeCalls, 1);
|
||||
assert.equal(await response.text(), "native");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("proxied TLS compatibility overload requires an explicit session scope", async () => {
|
||||
await withEnv(
|
||||
{
|
||||
ENABLE_TLS_FINGERPRINT: "true",
|
||||
TLS_FINGERPRINT_PROVIDERS: "codex",
|
||||
HTTPS_PROXY: "http://placeholder.proxy:8080",
|
||||
},
|
||||
async () => {
|
||||
let tlsCalls = 0;
|
||||
let dispatcherCalls = 0;
|
||||
setTlsClientForTest(
|
||||
fakeTlsClient(async () => {
|
||||
tlsCalls++;
|
||||
return new Response("unexpected");
|
||||
}),
|
||||
);
|
||||
|
||||
const tracked = await runWithTlsTracking("codex", () =>
|
||||
proxyFetch("https://upstream.example/v1", {}, {
|
||||
undiciFetch: async () => {
|
||||
dispatcherCalls++;
|
||||
return new Response("dispatcher");
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(tlsCalls, 0);
|
||||
assert.equal(dispatcherCalls, 1);
|
||||
assert.equal(tracked.tlsFingerprintUsed, false);
|
||||
assert.equal(await tracked.result.text(), "dispatcher");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("circuit trip defers session close until active response streams release", async () => {
|
||||
let first = true;
|
||||
let closed = 0;
|
||||
const client = new TlsClient(async () => ({
|
||||
close: async () => {
|
||||
closed++;
|
||||
},
|
||||
fetch: async () => {
|
||||
if (first) {
|
||||
first = false;
|
||||
return {
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: [],
|
||||
body: new ReadableStream<Uint8Array>({}),
|
||||
};
|
||||
}
|
||||
throw new Error("upstream unavailable");
|
||||
},
|
||||
}));
|
||||
|
||||
try {
|
||||
const activeResponse = await client.fetch("https://upstream.example/v1", {
|
||||
proxy: null,
|
||||
sessionScope: "connection-123",
|
||||
});
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
await assert.rejects(
|
||||
client.fetch("https://upstream.example/v1", {
|
||||
proxy: null,
|
||||
sessionScope: "connection-123",
|
||||
}),
|
||||
/wreq-js transport failed/,
|
||||
);
|
||||
}
|
||||
assert.equal(closed, 0);
|
||||
|
||||
await activeResponse.body?.cancel();
|
||||
await Promise.resolve();
|
||||
assert.equal(closed, 1);
|
||||
} finally {
|
||||
await client.exit();
|
||||
}
|
||||
});
|
||||
|
||||
test("streaming wreq body failures are sanitized and counted by the circuit", async () => {
|
||||
const secret = "http://user:password@proxy.example:8080";
|
||||
const client = new TlsClient(async () => ({
|
||||
close: async () => {},
|
||||
fetch: async () => ({
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: [["content-type", "text/plain"]],
|
||||
body: new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
const error = new Error(`body failed through ${secret}`) as Error & {
|
||||
code?: string;
|
||||
};
|
||||
error.code = "UND_ERR_SOCKET";
|
||||
controller.error(error);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
try {
|
||||
const response = await client.fetch("https://upstream.example/v1", {
|
||||
proxy: "http://user:password@proxy.example:8080",
|
||||
sessionScope: "connection-123",
|
||||
});
|
||||
await assert.rejects(
|
||||
response.text(),
|
||||
(error: unknown) =>
|
||||
error instanceof Error &&
|
||||
error.message === "wreq-js response body failed" &&
|
||||
"code" in error &&
|
||||
error.code === "UND_ERR_SOCKET" &&
|
||||
!String(error).includes("user:password"),
|
||||
);
|
||||
assert.equal(
|
||||
client.getCircuitState(
|
||||
"http://user:password@proxy.example:8080",
|
||||
"connection-123",
|
||||
).failureCount,
|
||||
1,
|
||||
);
|
||||
} finally {
|
||||
await client.exit();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user