diff --git a/docs/issues/plugin-browser-pool-proposal.md b/docs/issues/plugin-browser-pool-proposal.md new file mode 100644 index 0000000000..ff661c4bfd --- /dev/null +++ b/docs/issues/plugin-browser-pool-proposal.md @@ -0,0 +1,168 @@ +# [Feature] Pluginization Phase 1: Extract Playwright/CloakBrowser Browser Pool + +**Labels:** `enhancement`, `plugin`, `architecture` + +## Problem / Use Case + +OmniRoute's Playwright/CloakBrowser dependency is a **large, non-essential dependency** (~1500+ LOC across 22 source files + ~35 test files) pulled into every installation regardless of whether the user needs browser-backed chat. Users who run OmniRoute purely as a proxy/router (the majority) pay for: + +- **Disk space**: ~200+ MB from Playwright browsers + Chromium binaries (installed via `npx playwright install`) +- **Build complexity**: Turbopack must handle the `cloakbrowser` package +- **Bundle size**: All browser-pool code is compiled into the main codebase +- **Surface area**: 7 files with direct Playwright imports (4 dynamic, 3 static type imports) +- **CI/cache impact**: Playwright installs in CI pipelines even when not needed + +Currently, only environment variables (`OMNIROUTE_BROWSER_POOL=off`) gate _runtime_ execution — the code still loads, imports get resolved, and Playwright must be installed. + +## Proposed Solution + +Extract the Playwright/CloakBrowser browser pool into an **optional package** loaded via dynamic `import()` at runtime, following the existing pattern used for `cloakbrowser` (computed-string dynamic import to avoid resolution). The core retains thin interface stubs that gracefully degrade when the optional package is absent. + +### Architecture + +``` +open-sse/ + interfaces/ + browserPool.ts ← NEW: BrowserPoolProvider interface + types + services/ + browserPool.ts ← BECOMES: thin stub, delegates to optional package + browserBackedChat.ts ← BECOMES: thin stub, delegates to optional package + grokClearance.ts ← BECOMES: thin stub + +packages/browser-pool/ ← NEW: optional package + index.ts ← exports BrowserPoolProvider implementation + src/ + browserPool.ts ← extracted from open-sse/services/browserPool.ts + browserBackedChat.ts ← extracted from open-sse/services/browserBackedChat.ts + grokClearance.ts ← extracted from open-sse/services/grokClearance.ts + claudeTurnstileSolver.ts ← extracted (tightly coupled, moved as-is) + inAppLoginService.ts ← extracted (own Playwright instance, separate lifecycle) + package.json + tsconfig.json +``` + +### Phase Breakdown + +**Phase 1 — Core pool extraction (this issue):** + +1. Define `BrowserPoolProvider` interface in `open-sse/interfaces/browserPool.ts` +2. Extract `browserPool.ts` (~502 LOC), `browserBackedChat.ts` (~270 LOC), `grokClearance.ts` (~84 LOC) into `packages/browser-pool/` +3. Replace core files with thin stubs that try `import('../../../packages/browser-pool')` with graceful fallback +4. Keep `poolTools.ts` importing the core stub (unchanged from consumer perspective) +5. Make Playwright an optional dependency (not in root `package.json`) +6. Typecheck core passes with and without the package installed +7. All existing tests pass (with plugin installed) + +**Phase 2 — Turnstile solver extraction (future):** + +- Extract `claudeTurnstileSolver.ts` (~212 LOC) — has static Playwright type imports, needs type interface +- Move `claudeWebAutoRefresh.ts` (depends on turnstile solver) + +**Phase 3 — Standalone Playwright instances (future):** + +- Extract `inAppLoginService.ts` (~257 LOC) +- Refactor `gemini-web.ts` executor's own Playwright path (~553 LOC) + +### Interface Design (Phase 1) + +```typescript +// open-sse/interfaces/browserPool.ts +export interface BrowserPoolProvider { + acquireBrowserContext(options?: BrowserPoolContextOptions): Promise; + releaseBrowserContext(ctx: PooledContext): Promise; + getBrowserPoolMetrics(): BrowserPoolMetrics; + shutdownPool(): Promise; + isPoolEnabled(): boolean; + openPage(url: string, ctx?: PooledContext): Promise<{ page: any }>; + readPageResponseBody(page: any): Promise; + getBrowserPoolStatus(): BrowserPoolStatus; +} +``` + +### Stub Pattern + +```typescript +// open-sse/services/browserPool.ts — thin stub +let _impl: BrowserPoolProvider | null = null; + +async function getImpl(): Promise { + if (!_impl) { + try { + const { createBrowserPoolProvider } = await import("../../packages/browser-pool"); + _impl = createBrowserPoolProvider(); + } catch { + // Graceful fallback — disabled + _impl = createNullBrowserPoolProvider(); + } + } + return _impl; +} + +export async function acquireBrowserContext(...args) { + return (await getImpl()).acquireBrowserContext(...args); +} +``` + +## Alternatives Considered + +1. **Existing hook-based PluginManager**: Rejected. The current PluginManager operates via child-process IPC and request-pipeline hooks (`onRequest`, `onResponse`, `onError`). A browser pool is an in-process runtime service with composable lifecycle — not a request pipeline hook. Forcing it through IPC would add ~50ms+ per browser operation and break the existing synchronous pool pattern. + +2. **Keep as-is, just lazy-load the import**: Minimal improvement — the dependency tree still references Playwright types, requiring it to be available. Doesn't reduce bundle size or simplify CI. + +3. **Replace Playwright with a protocol-level abstraction**: Too ambitious and would change the behavior of the pool. Playwright's CDP capabilities (context isolation, cookies, screenshots) are fundamental to how the pool works. + +4. **Monorepo workspace**: Too heavy for this scope. A simple extracted package avoids workspace tooling changes. + +## Acceptance Criteria + +1. `open-sse/interfaces/browserPool.ts` exists and exports `BrowserPoolProvider`, `PooledContext`, `BrowserPoolMetrics` types +2. `open-sse/services/browserPool.ts` becomes a thin stub with zero Playwright imports +3. `packages/browser-pool/` contains all extracted implementation (browserPool, browserBackedChat, grokClearance) +4. Core typecheck (`npm run typecheck:core`) passes with 0 errors **without** the browser-pool package installed +5. Core typecheck passes with the package installed +6. All existing tests pass when the browser-pool package is installed +7. `poolTools.ts` `omniroute_browser_pool_status` tool works end-to-end when the package is installed +8. Graceful degradation: when the package is absent, `getBrowserPoolStatus()` returns `{ enabled: false }` without crashing +9. Playwright is moved from root `dependencies` to optional/peer in the extracted package +10. Documentation updated in `docs/reference/ENVIRONMENT.md` + +## Expected Test Plan + +- Unit tests for the stub fallback path (simulate import failure, verify graceful degradation) +- Unit tests moved to the extracted package +- Verify `tests/unit/browser-pool-optional-import.test.ts` passes (still validates cloakbrowser isn't statically resolved) +- Verify `tests/unit/browserPool-proxy.test.ts` passes +- Verify `tests/unit/browserBackedChat-matcher.test.ts` passes +- E2E: `npm run typecheck:core` without the package installed → 0 errors +- E2E: `npm run test:coverage` (with package installed) → existing coverage gates pass + +## Additional Context + +Current dependency graph (simplified): + +``` +open-sse/services/browserPool.ts (502 LOC, singleton Playwright/CloakBrowser pool) + ├── open-sse/services/browserBackedChat.ts (270 LOC, browser-backed chat runner) + │ ├── open-sse/executors/claude-web.ts (imports tryBackedChat) + │ └── open-sse/executors/duckduckgo-web.ts (imports tryBackedChat) + ├── open-sse/services/grokClearance.ts (84 LOC, CF clearance via browser) + └── open-sse/mcp-server/tools/poolTools.ts (imports getBrowserPoolMetrics) + +Standalone Playwright users (separate, future phases): + ├── open-sse/services/claudeTurnstileSolver.ts (212 LOC, static Playwright type imports) + ├── open-sse/services/inAppLoginService.ts (257 LOC, own browser lifecycle) + └── open-sse/executors/gemini-web.ts (553 LOC, private Playwright path) + +Kill switches: OMNIROUTE_BROWSER_POOL, WEB_COOKIE_USE_BROWSER (both env vars) +``` + +Total extracted in Phase 1: ~856 LOC, 3 files. +Total deferred to Phase 2/3: ~1022 LOC, 4 files. + +This is the first pluginization step. Future targets (separate issues): memory/compression plugin, additional provider support extraction. + +## Related References + +- PR #8219 (model catalog connection filter + cache TTL) — same baseline `release/v3.8.49` +- `docs/reference/ENVIRONMENT.md` — browser pool env vars documentation +- Plugin system docs at `docs/PLUGINS.md` — existing PluginManager (not used here, referenced for contrast) diff --git a/open-sse/services/browserBackedChat.ts b/open-sse/services/browserBackedChat.ts index 8ec46270a5..96002b6767 100644 --- a/open-sse/services/browserBackedChat.ts +++ b/open-sse/services/browserBackedChat.ts @@ -61,15 +61,6 @@ const COOKIE_POLL_TIMEOUT_MS = 5 * 1000; // ===================== MODULE PROXY ===================== -let modPromise: Promise | null = null; - -function getMod(): Promise { - if (!modPromise) { - modPromise = import("@omniroute/browser-pool").catch(() => null); - } - return modPromise; -} - // ===================== COOKIE CACHE (DELEGATED) ===================== async function getCachedCookies(domain: string | undefined): Promise { @@ -91,11 +82,13 @@ export async function clearCookieCache(): Promise { // ===================== TEST OVERRIDES ===================== -let browserBackedChatOverride: ((req: BrowserBackedChatRequest) => Promise) | null = null; -let httpBackedChatOverride: ((req: BrowserBackedChatRequest) => Promise) | null = null; +let browserBackedChatOverride: + ((req: BrowserBackedChatRequest) => Promise) | null = null; +let httpBackedChatOverride: + ((req: BrowserBackedChatRequest) => Promise) | null = null; export function __setBrowserBackedChatOverrideForTesting( - fn: ((req: BrowserBackedChatRequest) => Promise) | null, + fn: ((req: BrowserBackedChatRequest) => Promise) | null ): void { browserBackedChatOverride = fn; } @@ -105,7 +98,7 @@ export function __resetBrowserBackedChatOverrideForTesting(): void { } export function __setHttpBackedChatOverrideForTesting( - fn: ((req: BrowserBackedChatRequest) => Promise) | null, + fn: ((req: BrowserBackedChatRequest) => Promise) | null ): void { httpBackedChatOverride = fn; } @@ -149,7 +142,9 @@ export function isChallengeResponse(status: number): boolean { // ===================== HTTP-BACKED CHAT (INLINE) ===================== -export async function httpBackedChat(req: BrowserBackedChatRequest): Promise { +export async function httpBackedChat( + req: BrowserBackedChatRequest +): Promise { if (httpBackedChatOverride) return httpBackedChatOverride(req); const startTime = Date.now(); @@ -185,7 +180,13 @@ export async function httpBackedChat(req: BrowserBackedChatRequest): Promise { +export async function tryBackedChat( + req: BrowserBackedChatRequest +): Promise { const startTime = Date.now(); // Background-load the package module @@ -291,7 +303,7 @@ export async function tryBackedChat(req: BrowserBackedChatRequest): Promise { +export async function browserBackedChat( + req: BrowserBackedChatRequest +): Promise { if (browserBackedChatOverride) return browserBackedChatOverride(req); const mod = await getMod(); if (!mod) throw new Error("Browser pool package not available"); diff --git a/open-sse/tsconfig.json b/open-sse/tsconfig.json index f64585be7e..0b3db7a0e4 100644 --- a/open-sse/tsconfig.json +++ b/open-sse/tsconfig.json @@ -18,7 +18,8 @@ "paths": { "@/*": ["./src/*"], "@omniroute/open-sse": ["./open-sse"], - "@omniroute/open-sse/*": ["./open-sse/*"] + "@omniroute/open-sse/*": ["./open-sse/*"], + "@omniroute/browser-pool": ["./packages/browser-pool/src"] } }, "include": ["**/*.ts", "**/*.js"] diff --git a/package-lock.json b/package-lock.json index 950aa1f372..230b4c5ad4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "hasInstallScript": true, "license": "MIT", "workspaces": [ - "open-sse" + "open-sse", + "packages/browser-pool" ], "dependencies": { "@aws-sdk/client-bedrock-runtime": "^3.1073.0", @@ -3709,9 +3710,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3728,9 +3726,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3747,9 +3742,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3766,9 +3758,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3785,9 +3774,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3804,9 +3790,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3823,9 +3806,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3842,9 +3822,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3861,9 +3838,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -3886,9 +3860,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -3911,9 +3882,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -3936,9 +3904,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -3961,9 +3926,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -3986,9 +3948,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -4011,9 +3970,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -4036,9 +3992,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -6335,6 +6288,10 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/@omniroute/browser-pool": { + "resolved": "packages/browser-pool", + "link": true + }, "node_modules/@omniroute/open-sse": { "resolved": "open-sse", "link": true @@ -10723,9 +10680,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -10743,9 +10697,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -10763,9 +10714,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -10783,9 +10731,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -12878,9 +12823,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "optional": true, "os": [ "linux" @@ -12894,9 +12836,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "optional": true, "os": [ "linux" @@ -12910,9 +12849,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "optional": true, "os": [ "linux" @@ -12926,9 +12862,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "optional": true, "os": [ "linux" @@ -12942,9 +12875,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "optional": true, "os": [ "linux" @@ -12958,9 +12888,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "optional": true, "os": [ "linux" @@ -37032,6 +36959,33 @@ "safe-regex": "^2.1.1", "smol-toml": "1.7.0" } + }, + "packages/browser-pool": { + "name": "@omniroute/browser-pool", + "version": "0.1.0", + "dependencies": { + "playwright": "1.61.1" + }, + "devDependencies": { + "@types/node": "^22" + } + }, + "packages/browser-pool/node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "packages/browser-pool/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" } } } diff --git a/package.json b/package.json index de8fb48691..2ff43acfde 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,8 @@ "!**/*.spec.tsx" ], "workspaces": [ - "open-sse" + "open-sse", + "packages/browser-pool" ], "engines": { "node": ">=22.22.2 <23 || >=24.0.0 <27" diff --git a/packages/browser-pool/package.json b/packages/browser-pool/package.json new file mode 100644 index 0000000000..6e773eb7ab --- /dev/null +++ b/packages/browser-pool/package.json @@ -0,0 +1,15 @@ +{ + "name": "@omniroute/browser-pool", + "version": "0.1.0", + "private": true, + "description": "Optional browser pool service for OmniRoute — CloakBrowser and Playwright-backed chat", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "dependencies": { + "playwright": "1.61.1" + }, + "devDependencies": { + "@types/node": "^22" + } +} diff --git a/packages/browser-pool/src/index.ts b/packages/browser-pool/src/index.ts new file mode 100644 index 0000000000..923e8a03c9 --- /dev/null +++ b/packages/browser-pool/src/index.ts @@ -0,0 +1,37 @@ +/** + * @omniroute/browser-pool — Optional browser pool for Playwright-backed + * executor support (claude-web, duckduckgo-web, grok). + * + * Core stubs dynamically import this package at runtime. When the package + * is not installed, the stubs degrade gracefully (fallback or error). + */ + +// ── Re-exports from browserPool ────────────────────────────────────────── +export { + acquireBrowserContext, + releaseBrowserContext, + getBrowserPoolMetrics, + readPageResponseBody, + openPage, + shutdownPool, + setProxyResolver, + __resetBrowserPoolMetricsForTest, +} from "./services/browserPool.ts"; + +export type { BrowserPoolContextOptions, BrowserPoolMetrics, PooledContext } from "./interfaces.ts"; + +// ── Re-exports from browserBackedChat ──────────────────────────────────── +export { + browserBackedChat, + startBrowserWarmup, + getFreshCookiesWithWarmup, +} from "./services/browserBackedChat.ts"; + +// ── Re-exports from grokClearance ───────────────────────────────────────── +export { + getCachedCookies, + setCachedCookies, + clearCookieCache, +} from "./services/browserBackedChat.ts"; + +export { shouldUseGrokBrowserBacked, acquireFreshGrokClearance } from "./services/grokClearance.ts"; diff --git a/packages/browser-pool/src/interfaces.ts b/packages/browser-pool/src/interfaces.ts new file mode 100644 index 0000000000..d11f092bc1 --- /dev/null +++ b/packages/browser-pool/src/interfaces.ts @@ -0,0 +1,94 @@ +/** + * interfaces.ts — Shared type definitions for @omniroute/browser-pool. + * + * These types are used by both the package entry and the core stubs. + * The core stubs re-export them so existing import paths remain stable. + */ + +import type { BrowserContext, Page } from "playwright"; + +// ── Browser pool ─────────────────────────────────────── + +export interface BrowserPoolContextOptions { + cookieDomain: string; + cookieString?: string | null; + warmupUrl?: string | null; + userAgent?: string; + locale?: string; + timezone?: string; + preferCloakbrowser?: boolean; + /** Time (ms) to wait for the warmup page to be ready. */ + waitFor?: number; +} + +export interface PooledContext { + id: string; + context: BrowserContext; + warmupPage: Page | null; + lastUsed: number; + isStealth: boolean; +} + +export interface BrowserPoolMetrics { + browserLaunches: number; + browserLaunchFailures: number; + contextsCreated: number; + contextsReused: number; + contextsEvicted: number; + contextsReleased: number; + contextCreateFailures: number; + shutdowns: number; + lastShutdownReason: string | null; +} + +// ── Browser-backed chat ──────────────────────────────── + +export interface BrowserBackedChatRequest { + /** Pool key — typically a provider id like "duckduckgo-web" or + * "claude-web", optionally suffixed by user/account id. */ + poolKey: string; + /** Chat URL the page should submit to (captured via waitForResponse). */ + chatUrl: string; + /** Chat page URL to navigate to before typing. */ + chatPageUrl: string; + /** The text the user wants to send. */ + userMessage: string; + /** Cookie string (raw) to inject into the browser context. */ + cookieString?: string | null; + /** Cookie domain (used together with cookieString). */ + cookieDomain?: string; + /** Domain for the page's fetch to identify the chat endpoint. */ + chatUrlMatchDomain: string; + /** User-Agent string for the browser context. */ + userAgent?: string; + /** Locale (BCP 47). Defaults to en-US. */ + locale?: string; + /** IANA timezone. Defaults to America/New_York. */ + timezone?: string; + /** Selector for the chat input. */ + inputSelector: string; + /** Selector for the submit button (optional — falls back to Enter). */ + submitButtonSelector?: string; + /** Wait after submit for SSE/JSON to arrive. Default 15 seconds. */ + postSubmitWaitMs?: number; + /** Optional AbortSignal. Cancels navigation/submit. */ + signal?: AbortSignal | null; + /** Reuse the same context across requests. Default true. */ + reuseContext?: boolean; +} + +export interface BrowserBackedChatTiming { + acquireContextMs: number; + navigateMs: number; + submitMs: number; + captureResponseMs: number; + totalMs: number; +} + +export interface BrowserBackedChatResult { + status: number; + contentType: string | null; + body: Buffer; + isStealth: boolean; + timing: BrowserBackedChatTiming; +} diff --git a/packages/browser-pool/src/services/browserBackedChat.ts b/packages/browser-pool/src/services/browserBackedChat.ts new file mode 100644 index 0000000000..143f7a0833 --- /dev/null +++ b/packages/browser-pool/src/services/browserBackedChat.ts @@ -0,0 +1,461 @@ +/** + * browserBackedChat.ts — Full browser-backed chat interaction for @omniroute/browser-pool. + * + * Opens a page on a shared browser context, navigates to the provider's + * chat page, types the user's message, clicks Send, and returns the + * upstream SSE/JSON response body as a structured result. + * + * Providers using this path: duckduckgo-web, claude-web. + * + * The browser solves the provider's challenge natively (VQD, Cloudflare + * Turnstile, etc.) by computing real DOM measurement values. The + * Node-side challenge solver still runs as a first-line best-effort; + * this module is the fallback. + */ + +import { Buffer } from "node:buffer"; +import { + acquireBrowserContext, + openPage, + readPageResponseBody, + releaseBrowserContext, +} from "./browserPool.ts"; +import type { + PooledContext, + BrowserBackedChatRequest, + BrowserBackedChatResult, +} from "../interfaces.ts"; + +// Safety constants +const MAX_RESPONSE_BYTES = 10 * 1024 * 1024; // 10 MB + +// Cookie cache constants +const COOKIE_CACHE_TTL_MS = 5 * 60 * 1000; // Cache fresh cookies for 5 minutes +const COOKIE_POLL_INTERVAL_MS = 500; // Poll for cookies every 500ms +const COOKIE_POLL_TIMEOUT_MS = 5000; // Max poll time for cookies + +// Cookie cache — avoids repeated browser launches when cookies are still valid +interface CachedCookies { + cookieString: string; + expiresAt: number; + domain: string; +} +const cookieCache = new Map(); + +export function getCachedCookies(domain: string): string | null { + const cached = cookieCache.get(domain); + if (cached && Date.now() < cached.expiresAt) return cached.cookieString; + cookieCache.delete(domain); + return null; +} + +export function setCachedCookies(domain: string, cookieString: string, ttlMs?: number): void { + cookieCache.set(domain, { + cookieString, + expiresAt: Date.now() + (ttlMs ?? COOKIE_CACHE_TTL_MS), + domain, + }); +} + +export function clearCookieCache(): void { + cookieCache.clear(); +} + +// Dedup pending cookie refreshes per pool key +const pendingRefreshes = new Map>(); + +/** Sanitize an error message for safe JSON transport. */ +const MAX_ERROR_LEN = 512; +function sanitizeErrorMessage(message: unknown): string { + let str = typeof message === "string" ? message : String(message ?? ""); + if (str.length > MAX_ERROR_LEN) str = str.slice(0, MAX_ERROR_LEN); + const nl = str.indexOf("\n"); + if (nl >= 0) str = str.slice(0, nl); + return str.replace(/[^ -~]/g, "").trim(); +} + +/** Wait N milliseconds, abortable via signal. */ +async function waitWithSignal(ms: number, signal?: AbortSignal | null): Promise { + if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); + return new Promise((resolve, reject) => { + const onAbort = () => { + clearTimeout(timer); + reject(new DOMException("Aborted", "AbortError")); + }; + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +/** + * waitForCookiesWithPolling — Poll for cookies every 500ms up to 5s. + * Returns as soon as challenge cookies appear, instead of always + * waiting the full timeout. Saves 1-4s when anti-bot resolves quickly. + */ +async function waitForCookiesWithPolling( + context: import("playwright").BrowserContext, + cookieDomain: string, + signal: AbortSignal | null +): Promise { + const deadline = Date.now() + COOKIE_POLL_TIMEOUT_MS; + while (Date.now() < deadline) { + if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); + const cookies = await context.cookies(cookieDomain); + const cookieString = cookies.map((c) => `${c.name}=${c.value}`).join("; "); + if (cookieString) return cookieString; + const remaining = deadline - Date.now(); + if (remaining <= 0) break; + await waitWithSignal(Math.min(COOKIE_POLL_INTERVAL_MS, remaining), signal); + } + return null; +} + +/** + * doCookieRefreshOnContext — Run cookie extraction on an already-acquired + * browser context. Opens a temporary page, navigates to the chat URL, + * polls for cookies, and returns the result. + * NOTE: Does NOT pass AbortSignal to Playwright methods — signals are + * handled via waitWithSignal wrapping instead. + */ +async function doCookieRefreshOnContext( + pooled: PooledContext, + chatPageUrl: string, + cookieDomain: string, + signal: AbortSignal | null +): Promise { + const page = await openPage(pooled); + try { + await page.goto(chatPageUrl, { + waitUntil: "domcontentloaded", + timeout: 60000, + }); + return await waitForCookiesWithPolling(pooled.context, cookieDomain, signal); + } catch (err) { + if (err instanceof DOMException && err.name === "AbortError") throw err; + return null; + } finally { + await page.close().catch(() => {}); + } +} + +/** + * Match a URL against a chat URL template, allowing a single dynamic + * id segment (PLACEHOLDER) in the template. + */ +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; + 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; +} + +/** Resolve a unique pool key; when reuseContext is false, create a unique key. */ +async function settlePoolKey( + requestedKey: string, + reuseContext: boolean +): Promise<{ key: string; acquired: boolean }> { + if (reuseContext) return { key: requestedKey, acquired: true }; + return { + key: `${requestedKey}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + acquired: false, + }; +} + +// ── Cookie refresh helpers ────────────────────────── + +/** + * doRefresh — Acquire a fresh browser context, navigate to the + * chat page, and poll for cookies. Returns the cookie string + * or null on failure. + * NOTE: Does NOT pass AbortSignal to Playwright methods — signals + * are handled via waitWithSignal wrapping instead. + */ +async function doRefresh(options: { + chatPageUrl: string; + cookieDomain: string; + poolKey: string; + signal: AbortSignal | null; +}): Promise { + const pooled = await acquireBrowserContext(options.poolKey + "-refresh", { + cookieDomain: options.cookieDomain, + cookieString: null, + warmupUrl: options.chatPageUrl, + }); + const page = await openPage(pooled); + try { + await page.goto(options.chatPageUrl, { + waitUntil: "domcontentloaded", + timeout: 60000, + }); + return await waitForCookiesWithPolling(pooled.context, options.cookieDomain, options.signal); + } catch (err) { + if (err instanceof DOMException && err.name === "AbortError") throw err; + return null; + } finally { + await page.close().catch(() => {}); + // release context — we got the cookies + setTimeout(() => { + releaseBrowserContext(options.poolKey + "-refresh").catch(() => {}); + }, 1000); + } +} + +/** + * refreshCookiesViaBrowser — Refresh cookies using a browser context. + * Uses pendingRefreshes dedup so concurrent requests share one browser launch. + * NOTE: Override check (httpOverride) is handled in the core stub — this + * package version always attempts browser cookie refresh. + */ +async function refreshCookiesViaBrowser( + chatUrl: string, + chatPageUrl: string, + cookieDomain: string, + poolKey: string, + signal: AbortSignal | null +): Promise { + const pending = pendingRefreshes.get(poolKey); + if (pending) return pending; + const promise = doRefresh({ chatPageUrl, cookieDomain, poolKey, signal }); + pendingRefreshes.set(poolKey, promise); + try { + return await promise; + } finally { + pendingRefreshes.delete(poolKey); + } +} + +/** + * startBrowserWarmup — Pre-warm a browser context for the given pool key. + * This is a fire-and-forget operation: errors are caught and ignored. + * The warmup page serves as a readiness indicator — we open a page in the + * pooled context to force early navigation before the actual request. + */ +export async function startBrowserWarmup( + poolKey: string, + chatPageUrl: string, + cookieDomain: string, + signal: AbortSignal | null +): Promise { + if (process.env.OMNIROUTE_BROWSER_POOL === "off") return; + const pooled = await acquireBrowserContext(poolKey, { + cookieDomain, + cookieString: null, + warmupUrl: chatPageUrl, + waitFor: 2000, + }); + // Warmup: open a page in the pooled context — this can happen in parallel + openPage(pooled).catch(() => {}); +} + +/** + * getFreshCookiesWithWarmup — Try cached cookies first; if none, start + * a browser warmup in parallel with a cookie refresh. Returns cookie string + * or null. Caches successful results. + */ +export async function getFreshCookiesWithWarmup( + chatUrl: string, + chatPageUrl: string, + cookieDomain: string, + poolKey: string, + signal: AbortSignal | null +): Promise { + // Try cached cookies first + const cached = getCachedCookies(cookieDomain); + if (cached) return cached; + + // Start warmup in parallel with refresh + const warmup = startBrowserWarmup(poolKey, chatPageUrl, cookieDomain, signal); + const fresh = await refreshCookiesViaBrowser(chatUrl, chatPageUrl, cookieDomain, poolKey, signal); + // Await warmup (errors are non-fatal) + await warmup.catch(() => {}); + if (fresh) { + setCachedCookies(cookieDomain, fresh); + return fresh; + } + return null; +} + +// ── Main entry point ─────────────────────────────────── + +export async function browserBackedChat( + req: BrowserBackedChatRequest +): Promise { + const t0 = Date.now(); + const { + poolKey, + chatUrl, + chatPageUrl, + userMessage, + cookieString, + cookieDomain, + chatUrlMatchDomain, + userAgent, + locale, + timezone, + inputSelector, + submitButtonSelector, + postSubmitWaitMs = 15000, + signal, + reuseContext = true, + } = req; + + const { key, acquired: reuseAcquired } = await settlePoolKey(poolKey, reuseContext); + const tAcquireStart = Date.now(); + const pooled: PooledContext = await acquireBrowserContext(key, { + cookieDomain: cookieDomain || chatUrlMatchDomain, + cookieString: cookieString || null, + warmupUrl: chatPageUrl, + userAgent, + locale, + timezone, + }); + const acquireContextMs = Date.now() - tAcquireStart; + + const page = await openPage(pooled); + try { + const tNavStart = Date.now(); + await page.goto(chatPageUrl, { + waitUntil: "domcontentloaded", + timeout: 60000, + }); + const navigateMs = Date.now() - tNavStart; + + const inputLocator = page.locator(inputSelector).first(); + await inputLocator.waitFor({ state: "visible", timeout: 10000 }); + await waitWithSignal(800, signal); + + const responsePromise = page.waitForResponse( + (r) => + r.request().method() === "POST" && chatUrlMatcher(r.url(), chatUrlMatchDomain, chatUrl), + { timeout: 30000 } + ); + + let abortListener: (() => void) | undefined; + const signalPromise = signal + ? new Promise((_, reject) => { + if (signal.aborted) return reject(new DOMException("Aborted", "AbortError")); + abortListener = () => reject(new DOMException("Aborted", "AbortError")); + signal.addEventListener("abort", abortListener, { once: true }); + }) + : null; + + if (submitButtonSelector) { + const btn = page.locator(submitButtonSelector).first(); + if ((await btn.count()) > 0) { + try { + await btn.click({ timeout: 2000 }); + } catch { + await page.keyboard.press("Enter"); + } + } else { + await page.keyboard.press("Enter"); + } + } else { + await page.keyboard.press("Enter"); + } + const tCaptureStart = Date.now(); + const response = signalPromise + ? await Promise.race([responsePromise, signalPromise]).catch(() => null) + : await responsePromise.catch(() => null); + if (signal && abortListener) { + signal.removeEventListener("abort", abortListener); + } + if (response) { + await waitWithSignal(Math.min(postSubmitWaitMs, 30000), signal); + } else { + await waitWithSignal(postSubmitWaitMs, signal); + } + const captureResponseMs = Date.now() - tCaptureStart; + const submitMs = captureResponseMs; + + let status = 0; + let contentType: string | null = null; + let body: Buffer = Buffer.alloc(0); + if (response) { + const captured = await readPageResponseBody(response); + if (captured.body.length > MAX_RESPONSE_BYTES) { + body = Buffer.from( + JSON.stringify({ + error: { + message: "Response too large", + type: "upstream_error", + }, + }) + ); + status = 502; + contentType = "application/json"; + } else { + body = captured.body as unknown as Buffer; + contentType = captured.headers["content-type"] || null; + } + } + + return { + status, + contentType, + body, + isStealth: pooled.isStealth, + timing: { + acquireContextMs, + navigateMs, + submitMs, + captureResponseMs, + totalMs: Date.now() - t0, + }, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + const body = Buffer.from( + JSON.stringify({ + error: { + message: sanitizeErrorMessage(`browserBackedChat failed: ${msg}`), + type: "upstream_error", + }, + }) + ); + return { + status: 502, + contentType: "application/json", + body, + isStealth: pooled.isStealth, + timing: { + acquireContextMs, + navigateMs: 0, + submitMs: 0, + captureResponseMs: 0, + totalMs: Date.now() - t0, + }, + }; + } finally { + await page.close(); + if (!reuseAcquired) { + try { + await pooled.context.close(); + } catch { + /* ignore */ + } + } + } +} diff --git a/packages/browser-pool/src/services/browserPool.ts b/packages/browser-pool/src/services/browserPool.ts new file mode 100644 index 0000000000..f2566b1378 --- /dev/null +++ b/packages/browser-pool/src/services/browserPool.ts @@ -0,0 +1,440 @@ +/** + * browserPool.ts — Shared stealth browser pool for web-cookie providers. + * + * The DuckDuckGo VQD challenge and Claude web's Cloudflare Turnstile both + * validate values that only a real browser can produce (DOM layout + * measurements like offsetWidth/Height, getBoundingClientRect, + * getComputedStyle, iframe contentWindow probes). Plain Node fetch + a + * VM-stubs solver structurally runs the JS but cannot match those values, + * so the server rejects the request. + * + * This pool keeps one Chromium instance warm and serves "browser contexts" + * (one per provider) on demand. Each context owns one or more pages; the + * caller is expected to be polite (one page per request, close on done). + * + * The pool prefers `cloakbrowser` (npm) when available — its binary-level + * fingerprint patches (--fingerprint-timezone, --fingerprint-locale, and + * dozens more) are the only thing that gets past DuckDuckGo's anti-bot + * in this environment. Falls back to plain `playwright` if cloakbrowser + * is not installed; the fallback works for Claude web (which only needs + * valid cookies) but not for DDG's VQD challenge. + * + * Opt-in: pool only launches Chromium when an executor explicitly asks + * for a context, so users who never use the browser-backed path pay zero + * startup cost. Set OMNIROUTE_BROWSER_POOL=off to fully disable. + */ + +import { Buffer } from "node:buffer"; +import type { + BrowserPoolContextOptions, + BrowserPoolMetrics, + PooledContext, +} from "../interfaces.ts"; + +type Browser = import("playwright").Browser; +type BrowserContext = import("playwright").BrowserContext; +type Page = import("playwright").Page; + +/** Proxy resolver injected by the core stub after dynamic import. */ +type ProxyResolverFn = ( + providerKey: string +) => Promise; + +let injectedProxyResolver: ProxyResolverFn | null = null; + +export function setProxyResolver(fn: ProxyResolverFn): void { + injectedProxyResolver = fn; +} + +function createBrowserPoolMetrics(): BrowserPoolMetrics { + return { + browserLaunches: 0, + browserLaunchFailures: 0, + contextsCreated: 0, + contextsReused: 0, + contextsEvicted: 0, + contextsReleased: 0, + contextCreateFailures: 0, + shutdowns: 0, + lastShutdownReason: null, + }; +} + +interface PoolState { + browser: Browser | null; + contexts: Map; + pendingContexts: Map>; + launching: Promise | null; + lastActivity: number; + idleTimer: NodeJS.Timeout | null; + evictTimer: NodeJS.Timeout | null; + cloakLaunch: ((opts: unknown) => Promise) | null; + cloakLaunchResolved: boolean; + metrics: BrowserPoolMetrics; +} + +const POOL_IDLE_TIMEOUT_MS = 5 * 60 * 1000; +const CONTEXT_TTL_MS = 10 * 60 * 1000; // 10 min — evict stale contexts +const EVICT_INTERVAL_MS = 60 * 1000; // check every 60s +const DEFAULT_USER_AGENT = + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; + +const state: PoolState = { + browser: null, + contexts: new Map(), + pendingContexts: new Map(), + launching: null, + lastActivity: 0, + idleTimer: null, + evictTimer: null, + cloakLaunch: null, + cloakLaunchResolved: false, + metrics: createBrowserPoolMetrics(), +}; + +function getCloakbrowserModuleId(): string { + // Keep this computed: cloakbrowser is an optional runtime enhancer, and a literal + // dynamic import with the package name makes Turbopack resolve it during route compilation. + return ["cloak", "browser"].join(""); +} + +async function resolveCloakLaunch(): Promise<((opts: unknown) => Promise) | null> { + if (state.cloakLaunchResolved) return state.cloakLaunch; + state.cloakLaunchResolved = true; + try { + const mod = (await import(getCloakbrowserModuleId())) as unknown as { + launch?: (opts: unknown) => Promise; + }; + state.cloakLaunch = mod.launch ?? null; + } catch { + state.cloakLaunch = null; + } + return state.cloakLaunch; +} + +function isPoolEnabled(): boolean { + const flag = process.env.OMNIROUTE_BROWSER_POOL; + if (flag === undefined) return true; + return flag !== "off" && flag !== "0" && flag !== "false"; +} + +function resetIdleTimer(): void { + if (state.idleTimer) clearTimeout(state.idleTimer); + state.idleTimer = setTimeout(() => { + void shutdownPool("idle-timeout"); + }, POOL_IDLE_TIMEOUT_MS); + state.idleTimer.unref?.(); +} + +function evictStaleContexts(): void { + const now = Date.now(); + for (const [key, pooled] of state.contexts) { + if (now - pooled.lastUsed > CONTEXT_TTL_MS) { + console.log( + "[BrowserPool] Evicted stale context:", + key, + "(idle", + ((now - pooled.lastUsed) / 1000).toFixed(0) + "s)" + ); + state.contexts.delete(key); + state.metrics.contextsEvicted++; + pooled.context.close().catch(() => {}); + } + } + if (state.contexts.size === 0 && !state.launching) { + void shutdownPool("all-contexts-evicted"); + } +} + +function startEvictTimer(): void { + if (state.evictTimer) clearInterval(state.evictTimer); + state.evictTimer = setInterval(() => evictStaleContexts(), EVICT_INTERVAL_MS); + state.evictTimer.unref?.(); +} + +async function launchBrowser(): Promise { + if (state.browser) return state.browser; + if (state.launching) return state.launching; + state.launching = (async () => { + const cloakLaunch = await resolveCloakLaunch(); + let browser: Browser; + if (cloakLaunch) { + browser = await cloakLaunch({ + headless: true, + args: ["--no-sandbox", "--disable-dev-shm-usage"], + }); + } else { + // Fallback: plain Playwright. Works for Claude web (cookie-only + // auth) but DDG's VQD challenge will detect this Chromium build. + const { chromium } = await import("playwright"); + browser = await chromium.launch({ + headless: true, + args: [ + "--no-sandbox", + "--disable-dev-shm-usage", + "--disable-blink-features=AutomationControlled", + ], + }); + } + state.browser = browser; + state.launching = null; + state.metrics.browserLaunches++; + return browser; + })(); + try { + return await state.launching; + } catch (err) { + state.launching = null; + state.metrics.browserLaunchFailures++; + throw err; + } +} + +function parseCookieString( + raw: string, + domain: string +): Array<{ + name: string; + value: string; + domain: string; + path: string; + expires: number; + httpOnly: boolean; + secure: boolean; + sameSite: "Lax" | "Strict" | "None"; +}> { + return raw + .split(";") + .map((p) => p.trim()) + .filter(Boolean) + .map((pair) => { + const eq = pair.indexOf("="); + if (eq < 0) return null; + const name = pair.slice(0, eq).trim(); + const value = pair.slice(eq + 1).trim(); + if (!name || !value) return null; + return { + name, + value, + domain: domain.startsWith(".") ? domain : `.${domain}`, + path: "/", + expires: -1, + httpOnly: false, + secure: true, + sameSite: "Lax" as const, + }; + }) + .filter(Boolean) as Array<{ + name: string; + value: string; + domain: string; + path: string; + expires: number; + httpOnly: boolean; + secure: boolean; + sameSite: "Lax" | "Strict" | "None"; + }>; +} + +// Clear a key from the pending-creation map once its promise settles, counting +// failures. Kept as a leaf helper so acquireBrowserContext stays under the +// function-length ceiling (#3368 PR7 metrics). +function settlePendingContext(key: string, failed: boolean): void { + if (failed) state.metrics.contextCreateFailures++; + state.pendingContexts.delete(key); +} + +export async function acquireBrowserContext( + key: string, + options: BrowserPoolContextOptions +): Promise { + if (!isPoolEnabled()) { + throw new Error( + "browserPool: OMNIROUTE_BROWSER_POOL=off — context requested but pool is disabled" + ); + } + const existing = state.contexts.get(key); + if (existing) { + existing.lastUsed = Date.now(); + state.lastActivity = Date.now(); + state.metrics.contextsReused++; + resetIdleTimer(); + return existing; + } + + // Dedup concurrent creations for the same key + const pending = state.pendingContexts.get(key); + if (pending) return pending; + + const createPromise = (async (): Promise => { + const proxy = injectedProxyResolver ? await injectedProxyResolver(key) : undefined; + const [browser] = await Promise.all([launchBrowser()]); + const isStealth = state.cloakLaunch !== null; + const context = await browser.newContext({ + userAgent: options.userAgent || DEFAULT_USER_AGENT, + locale: options.locale || "en-US", + timezoneId: options.timezone || "America/New_York", + viewport: { width: 1280, height: 800 }, + ...(proxy ? { proxy } : {}), + }); + + if (options.cookieString) { + const cookies = parseCookieString(options.cookieString, options.cookieDomain); + if (cookies.length > 0) { + await context.addCookies(cookies); + } + } + + let warmupPage: Page | null = null; + if (options.warmupUrl) { + try { + warmupPage = await context.newPage(); + await warmupPage.goto(options.warmupUrl, { + waitUntil: "domcontentloaded", + timeout: 30000, + }); + // Give the warmup a moment for the upstream's status/auth/country + // JSON endpoints to fire. Without this, the first chat request would + // pay the warmup cost on the hot path. + await new Promise((r) => setTimeout(r, 1500)); + } catch (err) { + try { + await warmupPage?.close(); + } catch { + /* ignore */ + } + warmupPage = null; + void err; + } + } + + // Guard: if shutdownPool() ran while we were creating this context, + // the browser we obtained is now closed. Close our temp context and + // throw so the caller knows to retry. + if (state.browser !== browser) { + await context.close().catch(() => {}); + if (warmupPage) { + await warmupPage.close().catch(() => {}); + } + throw new Error("Pool shut down during context creation"); + } + + const pooled: PooledContext = { + id: key, + context, + warmupPage, + lastUsed: Date.now(), + isStealth, + }; + state.contexts.set(key, pooled); + state.metrics.contextsCreated++; + state.lastActivity = Date.now(); + resetIdleTimer(); + startEvictTimer(); + return pooled; + })(); + + state.pendingContexts.set(key, createPromise); + createPromise + .then(() => settlePendingContext(key, false)) + .catch(() => settlePendingContext(key, true)); + + return createPromise; +} + +export async function openPage(pooled: PooledContext): Promise { + return pooled.context.newPage(); +} + +export async function releaseBrowserContext(key: string): Promise { + const pooled = state.contexts.get(key); + if (!pooled) return; + state.contexts.delete(key); + state.metrics.contextsReleased++; + try { + await pooled.context.close(); + } catch { + /* ignore */ + } + if (state.contexts.size === 0) { + await shutdownPool("last-context-closed"); + } +} + +export async function shutdownPool(reason: string): Promise { + state.metrics.shutdowns++; + state.metrics.lastShutdownReason = reason; + if (state.idleTimer) { + clearTimeout(state.idleTimer); + state.idleTimer = null; + } + if (state.evictTimer) { + clearInterval(state.evictTimer); + state.evictTimer = null; + } + state.pendingContexts.clear(); + for (const [key, pooled] of state.contexts) { + try { + await pooled.context.close(); + } catch { + /* ignore */ + } + state.contexts.delete(key); + } + if (state.browser) { + try { + await state.browser.close(); + } catch { + /* ignore */ + } + state.browser = null; + } + state.lastActivity = Date.now(); + // Avoid unused-parameter lint: log reason via debug if anyone hooks + // process.on('exit') and prints state. + void reason; +} + +function getBrowserPoolStatus(): { + enabled: boolean; + contexts: number; + browserRunning: boolean; + stealthAvailable: boolean; + lastActivityAgoMs: number; +} { + return { + enabled: isPoolEnabled(), + contexts: state.contexts.size, + browserRunning: state.browser !== null, + stealthAvailable: state.cloakLaunch !== null, + lastActivityAgoMs: state.lastActivity === 0 ? -1 : Date.now() - state.lastActivity, + }; +} + +/** + * #3368 PR7 — browser-pool observability. Returns live status plus cumulative + * lifecycle telemetry (launches, context create/reuse/evict/release counts, + * failures, shutdowns). Surfaced via the omniroute_browser_pool_status MCP tool. + */ +export function getBrowserPoolMetrics(): { + status: ReturnType; + metrics: BrowserPoolMetrics; +} { + return { status: getBrowserPoolStatus(), metrics: { ...state.metrics } }; +} + +/** Test-only: reset cumulative metrics so assertions start from a clean slate. */ +export function __resetBrowserPoolMetricsForTest(): void { + state.metrics = createBrowserPoolMetrics(); +} + +export async function readPageResponseBody( + response: import("playwright").Response +): Promise<{ status: number; headers: Record; body: Buffer }> { + const headers: Record = {}; + for (const [name, value] of Object.entries(response.headers())) { + headers[name] = value; + } + const body = await response.body(); + return { status: response.status(), headers, body: Buffer.from(body) }; +} diff --git a/packages/browser-pool/src/services/grokClearance.ts b/packages/browser-pool/src/services/grokClearance.ts new file mode 100644 index 0000000000..4b7838ea1b --- /dev/null +++ b/packages/browser-pool/src/services/grokClearance.ts @@ -0,0 +1,73 @@ +/** + * grokClearance.ts — gated browser-backed cf_clearance acquisition for + * grok-web (#8019). + * + * grok.com sits behind Cloudflare Enterprise, which pins `cf_clearance` to + * the client's IP+TLS+UA fingerprint. Pure cookie-replay from + * `grokTlsClient.ts` (TLS-impersonating fetch) cannot forge a fresh + * clearance from a datacenter egress that Cloudflare has already flagged — + * only a real browser solving the challenge natively can mint one bound to + * that egress's own fingerprint. + * + * This module reuses the EXISTING provider-agnostic browser pool + * (`browserPool.ts`, already live for claude-web + duckduckgo-web) rather + * than adding a new Turnstile solver — `claudeTurnstileSolver.ts` is + * claude.ai-specific and does not apply here. + * + * Opt-in only: gated behind `OMNIROUTE_BROWSER_POOL` / `WEB_COOKIE_USE_BROWSER` + * (the same env gate already used by claude-web.ts / duckduckgo-web.ts). + * With the gate off, `acquireFreshGrokClearance` is never called — the + * executor stays on the Step-1 `cloudflare_challenge` classification. + */ + +import { acquireBrowserContext } from "./browserPool.ts"; +import type { PooledContext } from "../interfaces.ts"; + +const GROK_WARMUP_URL = "https://grok.com/"; +const GROK_COOKIE_DOMAIN = ".grok.com"; +const GROK_POOL_KEY = "grok-web"; + +/** + * Reads the same opt-in gate as claude-web/duckduckgo-web + * (`WEB_COOKIE_USE_BROWSER` or `OMNIROUTE_BROWSER_POOL`). Off by default. + */ +export function shouldUseGrokBrowserBacked(): boolean { + const flag = process.env.WEB_COOKIE_USE_BROWSER; + if (flag === "1" || flag === "true" || flag === "on") return true; + const poolFlag = process.env.OMNIROUTE_BROWSER_POOL; + return poolFlag === "on" || poolFlag === "1" || poolFlag === "true"; +} + +async function readCfClearanceFromContext(pooled: PooledContext): Promise { + const cookies = await pooled.context.cookies(GROK_WARMUP_URL); + const match = cookies.find((c) => c.name === "cf_clearance"); + return match?.value || null; +} + +async function acquireViaPool(): Promise { + try { + const pooled = await acquireBrowserContext(GROK_POOL_KEY, { + cookieDomain: GROK_COOKIE_DOMAIN, + cookieString: null, + warmupUrl: GROK_WARMUP_URL, + }); + return await readCfClearanceFromContext(pooled); + } catch { + return null; + } +} + +/** + * Acquire a fresh `.grok.com` cf_clearance via the shared browser pool. + * Never throws — resolves to `null` on any failure so callers can fall + * through to the Cloudflare-challenge error rather than crash the request. + */ +export async function acquireFreshGrokClearance( + signal?: AbortSignal | null +): Promise { + try { + return await acquireViaPool(); + } catch { + return null; + } +} diff --git a/packages/browser-pool/tsconfig.json b/packages/browser-pool/tsconfig.json new file mode 100644 index 0000000000..02444e43a6 --- /dev/null +++ b/packages/browser-pool/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "esnext", + "moduleResolution": "bundler", + "noEmit": true, + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "esModuleInterop": true, + "strict": false, + "lib": ["esnext"], + "types": ["node"], + "allowJs": false + }, + "include": ["src/**/*.ts"] +} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 72766ea810..565bce2e98 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,5 @@ packages: + - "packages/*" - "open-sse" allowBuilds: "@parcel/watcher": true