Files
OmniRoute/open-sse/services/browserBackedChat.ts
oyi77 0f96a97e33 fix: browser-pool stub type errors, silent-fallback regression, grokClearance signature
Pre-merge fixes for the CloakBrowser/Playwright extraction into the
optional @omniroute/browser-pool workspace package:

- Add the @omniroute/browser-pool path mapping to the root tsconfig.json
  (tsconfig.typecheck-core.json extends the root, not open-sse/tsconfig.json,
  so typecheck:core was failing with 3x TS2307 in browserPool.ts).
- tryBackedChat() in browserBackedChat.ts silently returned the stale
  httpResult (the unsolved challenge/403 body) when the optional
  browser-pool package was absent, instead of surfacing an error. Now
  throws a descriptive error so callers (claude-web, duckduckgo-web) and
  upstream fallback handling can react correctly instead of treating an
  unsolved challenge as a definitive response. Restored
  resolveBrowserContextProxy (dropped during the browserPool.ts merge
  conflict resolution against origin/release/v3.8.49) which
  tests/unit/browserPool-proxy.test.ts already depends on.
- Fix acquireFreshGrokClearance stub signature in grokClearance.ts to
  match the real package impl and the grok-web.ts caller:
  (signal?: AbortSignal | null) => Promise<string | null>, not
  (prompt: string) => Promise<PooledContext | null>.
- Add tests/unit/browserBackedChat-optional-package-absent.test.ts
  covering the previously-silent "package absent + challenge response"
  regression, plus a __setBrowserPoolModOverrideForTesting() hook to
  simulate that path deterministically.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-07-23 20:35:58 -03:00

391 lines
12 KiB
TypeScript

import { Buffer } from "node:buffer";
import tlsClient from "../utils/tlsClient.ts";
import { sanitizeErrorMessage } from "../utils/error.ts";
import { resolveHttpBackedChatFingerprint } from "./httpBackedChatFingerprint.ts";
// ===================== TYPES =====================
export interface BrowserBackedChatRequest {
poolKey: string;
chatUrl: string;
chatPageUrl?: string;
userMessage: string;
chatUrlMatchDomain?: string;
cookieDomain?: string;
inputSelector?: string;
cookieString?: string;
userAgent?: string;
locale?: string;
timezone?: string;
submitButtonSelector?: string;
postSubmitWaitMs?: number;
signal?: AbortSignal;
reuseContext?: boolean;
}
export interface BrowserBackedChatResult {
status: number;
contentType: string | null;
body: Buffer;
isStealth: boolean;
timing: {
acquireContextMs: number;
navigateMs: number;
submitMs: number;
captureResponseMs: number;
totalMs: number;
};
}
// ===================== MODULE PROXY =====================
export interface BrowserPoolModule {
tryBackedChat(req: TryBackedChatRequest, signal?: AbortSignal): Promise<TryBackedChatResult>;
browserBackedChat(req: BrowserBackedChatRequest): Promise<ChatResult>;
getFreshCookiesWithWarmup(req: CookieRefreshRequest): Promise<CookieRefreshResult>;
getCachedCookies(domain: string): Promise<CookieStore | null>;
setCachedCookies(domain: string, cookies: CookieStore): Promise<void>;
clearCookieCache(domain?: string): Promise<void>;
shouldUseGrokBrowserBacked(): boolean;
setProxyResolver(fn: ProxyResolver): void;
}
let modPromise: Promise<BrowserPoolModule | null> | null = null;
// Test-only escape hatch: lets tests simulate the "optional @omniroute/browser-pool
// package is not installed" path deterministically, without depending on a real
// failing dynamic import (which can otherwise trip unrelated module-resolution
// network fallbacks and hang the test process).
let modOverride: BrowserPoolModule | null | undefined = undefined;
export function __setBrowserPoolModOverrideForTesting(
value: BrowserPoolModule | null | undefined
): void {
modOverride = value;
modPromise = null;
}
export function __resetBrowserPoolModOverrideForTesting(): void {
modOverride = undefined;
modPromise = null;
}
function getMod(): Promise<BrowserPoolModule | null> {
if (modOverride !== undefined) return Promise.resolve(modOverride);
if (!modPromise) {
modPromise = import("@omniroute/browser-pool").catch(() => null);
}
return modPromise;
}
const MAX_RESPONSE_BYTES = 10 * 1024 * 1024;
const COOKIE_CACHE_TTL_MS = 5 * 60 * 1000;
const COOKIE_POLL_INTERVAL_MS = 500;
const COOKIE_POLL_TIMEOUT_MS = 5 * 1000;
// ===================== MODULE PROXY =====================
// ===================== COOKIE CACHE (DELEGATED) =====================
async function getCachedCookies(domain: string | undefined): Promise<string | undefined> {
const mod = await getMod();
if (!mod) return undefined;
return mod.getCachedCookies(domain);
}
async function setCachedCookies(domain: string | undefined, cookies: string): Promise<void> {
if (!domain) return;
const mod = await getMod();
if (mod) await mod.setCachedCookies(domain, cookies);
}
export async function clearCookieCache(): Promise<void> {
const mod = await getMod();
if (mod) await mod.clearCookieCache();
}
// ===================== TEST OVERRIDES =====================
let browserBackedChatOverride:
((req: BrowserBackedChatRequest) => Promise<BrowserBackedChatResult>) | null = null;
let httpBackedChatOverride:
((req: BrowserBackedChatRequest) => Promise<BrowserBackedChatResult>) | null = null;
export function __setBrowserBackedChatOverrideForTesting(
fn: ((req: BrowserBackedChatRequest) => Promise<BrowserBackedChatResult>) | null
): void {
browserBackedChatOverride = fn;
}
export function __resetBrowserBackedChatOverrideForTesting(): void {
browserBackedChatOverride = null;
}
export function __setHttpBackedChatOverrideForTesting(
fn: ((req: BrowserBackedChatRequest) => Promise<BrowserBackedChatResult>) | null
): void {
httpBackedChatOverride = fn;
}
export function __resetHttpBackedChatOverrideForTesting(): void {
httpBackedChatOverride = null;
}
// ===================== HELPER FUNCTIONS =====================
export function chatUrlMatcher(u: string, matchDomain: string, chatUrl: string): boolean {
if (u === chatUrl) return true;
let parsed: URL;
let chatParsed: URL;
try {
parsed = new URL(u);
chatParsed = new URL(chatUrl);
} catch {
return false;
}
if (!parsed.host.endsWith(matchDomain)) return false;
const chatSeg = chatParsed.pathname.split("/").filter(Boolean);
const reqSeg = parsed.pathname.split("/").filter(Boolean);
if (chatSeg.length < 2 || reqSeg.length !== chatSeg.length) return false;
// All segments except the PLACEHOLDER segment must match.
let allowedDynamic = 1;
for (let i = 0; i < chatSeg.length; i++) {
if (chatSeg[i] === reqSeg[i]) continue;
if (chatSeg[i] === "PLACEHOLDER" && allowedDynamic > 0) {
allowedDynamic--;
continue;
}
return false;
}
return true;
}
export function isChallengeResponse(status: number): boolean {
return status >= 400 && status !== 501;
}
// ===================== HTTP-BACKED CHAT (INLINE) =====================
export async function httpBackedChat(
req: BrowserBackedChatRequest
): Promise<BrowserBackedChatResult> {
if (httpBackedChatOverride) return httpBackedChatOverride(req);
const startTime = Date.now();
const headers: Record<string, string> = {
"user-agent":
req.userAgent ??
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"content-type": "application/json",
accept: "*/*",
};
if (req.cookieString) {
headers["cookie"] = req.cookieString;
}
const body = JSON.stringify({ messages: [{ role: "user", content: req.userMessage }] });
const fingerprint = resolveHttpBackedChatFingerprint(req.poolKey);
let response: Response;
try {
response = await tlsClient.fetch(req.chatUrl, {
method: "POST",
headers,
body,
fingerprint,
signal: req.signal,
});
} catch (err) {
const msg = sanitizeErrorMessage(err);
return {
status: 0,
contentType: "text/plain",
body: Buffer.from(msg),
isStealth: true,
timing: {
acquireContextMs: 0,
navigateMs: 0,
submitMs: 0,
captureResponseMs: 0,
totalMs: Date.now() - startTime,
},
};
}
// Read body with OOM guard
const reader = response.body?.getReader();
const chunks: Buffer[] = [];
let totalBytes = 0;
if (reader) {
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const buf = Buffer.from(value);
chunks.push(buf);
totalBytes += buf.byteLength;
if (totalBytes > MAX_RESPONSE_BYTES) {
reader.cancel().catch(() => {});
return {
status: 413,
contentType: "text/plain",
body: Buffer.from("Response exceeded maximum size"),
isStealth: true,
timing: {
acquireContextMs: 0,
navigateMs: 0,
submitMs: 0,
captureResponseMs: 0,
totalMs: Date.now() - startTime,
},
};
}
}
} catch {
// stream read error
}
}
const responseBody = Buffer.concat(chunks);
const contentType = response.headers.get("content-type") ?? null;
return {
status: response.status,
contentType,
body: responseBody,
isStealth: true,
timing: {
acquireContextMs: 0,
navigateMs: 0,
submitMs: 0,
captureResponseMs: 0,
totalMs: Date.now() - startTime,
},
};
}
// ===================== TRY-BACKED CHAT (INLINE) =====================
export async function tryBackedChat(
req: BrowserBackedChatRequest
): Promise<BrowserBackedChatResult> {
const startTime = Date.now();
// Background-load the package module
const mod = getMod();
// Start browser warmup in parallel
mod.then((m) => m?.startBrowserWarmup?.().catch(() => {})).catch(() => {});
// Try HTTP path first
try {
const httpResult = await httpBackedChat(req);
if (!isChallengeResponse(httpResult.status)) {
return httpResult;
}
// Chase cached cookies
if (req.cookieDomain) {
const cached = await getCachedCookies(req.cookieDomain);
if (cached) {
const retry = await httpBackedChat({ ...req, cookieString: cached });
if (!isChallengeResponse(retry.status)) return retry;
}
}
// Need browser-backed path — await the module
const loaded = await mod;
// The optional @omniroute/browser-pool package is not installed. Unlike
// the pre-refactor inline implementation (which always had the browser
// fallback available), there is no way to solve the challenge here —
// silently returning the stale httpResult (the 403/challenge body) would
// make callers (claude-web, duckduckgo-web) treat an unsolved challenge
// as a definitive upstream response. Throw instead so upstream fallback
// handling (combo routing / executor error paths) can react correctly.
if (!loaded) {
throw new Error(
"tryBackedChat: upstream returned a challenge response " +
`(status ${httpResult.status}) and the optional @omniroute/browser-pool ` +
"package is not installed — cannot solve the challenge. Install " +
"@omniroute/browser-pool to enable the browser-backed fallback."
);
}
// Get fresh cookies via browser
try {
const fresh = await loaded.getFreshCookiesWithWarmup(req);
if (fresh) {
if (req.cookieDomain) await setCachedCookies(req.cookieDomain, fresh);
const retry = await httpBackedChat({ ...req, cookieString: fresh });
if (!isChallengeResponse(retry.status)) return retry;
}
} catch {
// fall through to browser fallback
}
// Full browser fallback
try {
return await browserBackedChat(req);
} catch (inner: unknown) {
if (inner instanceof DOMException && inner.name === "AbortError") {
return {
status: 504,
contentType: "application/json",
body: Buffer.from(
JSON.stringify({
error: {
message: "tryBackedChat timed out",
type: "timeout_error",
},
})
),
isStealth: false,
timing: {
acquireContextMs: 0,
navigateMs: 0,
submitMs: 0,
captureResponseMs: 0,
totalMs: 0,
},
};
}
throw inner;
}
} catch (err: unknown) {
if (err instanceof DOMException && err.name === "AbortError") {
return {
status: 504,
contentType: "application/json",
body: Buffer.from(
JSON.stringify({
error: {
message: "tryBackedChat timed out",
type: "timeout_error",
},
})
),
isStealth: false,
timing: {
acquireContextMs: 0,
navigateMs: 0,
submitMs: 0,
captureResponseMs: 0,
totalMs: Date.now() - startTime,
},
};
}
throw err;
}
}
// ===================== BROWSER-BACKED CHAT (DELEGATED) =====================
export async function browserBackedChat(
req: BrowserBackedChatRequest
): Promise<BrowserBackedChatResult> {
if (browserBackedChatOverride) return browserBackedChatOverride(req);
const mod = await getMod();
if (!mod) throw new Error("Browser pool package not available");
return mod.browserBackedChat(req);
}