diff --git a/open-sse/services/browserBackedChat.ts b/open-sse/services/browserBackedChat.ts index 96002b6767..ca71adbbbd 100644 --- a/open-sse/services/browserBackedChat.ts +++ b/open-sse/services/browserBackedChat.ts @@ -48,7 +48,26 @@ export interface BrowserPoolModule { setProxyResolver(fn: ProxyResolver): void; } let modPromise: Promise | 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 { + if (modOverride !== undefined) return Promise.resolve(modOverride); if (!modPromise) { modPromise = import("@omniroute/browser-pool").catch(() => null); } @@ -276,50 +295,62 @@ export async function tryBackedChat( // Need browser-backed path — await the module const loaded = await mod; - // Get fresh cookies via browser - if (loaded) { - 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; - } + // 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." + ); } - return httpResult; + // 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 { diff --git a/open-sse/services/browserPool.ts b/open-sse/services/browserPool.ts index f2651299fd..b8c73047ae 100644 --- a/open-sse/services/browserPool.ts +++ b/open-sse/services/browserPool.ts @@ -146,6 +146,21 @@ export async function resolvePlaywrightProxy( } } +/** + * Resolve the proxy for a browser-pool context key. Scoped context keys + * (e.g. "claude-web:account-scope") carry a stable `proxyProviderKey` so the + * proxy lookup always targets the underlying provider, not the scoped key. + * Kept inline alongside resolvePlaywrightProxy — trivial wrapper, no + * playwright/browser-pool dependency. + */ +export async function resolveBrowserContextProxy( + contextKey: string, + options: Pick, + deps?: ResolvePlaywrightProxyDeps +): Promise { + return resolvePlaywrightProxy(options.proxyProviderKey ?? contextKey, deps); +} + // --------------------------------------------------------------------------- // getBrowserPoolStatus — INLINE (returns disabled status) // --------------------------------------------------------------------------- diff --git a/open-sse/services/grokClearance.ts b/open-sse/services/grokClearance.ts index 093316f4fc..2e787e165a 100644 --- a/open-sse/services/grokClearance.ts +++ b/open-sse/services/grokClearance.ts @@ -1,5 +1,3 @@ -import type { PooledContext } from "@omniroute/browser-pool"; - export function shouldUseGrokBrowserBacked(): boolean { const flag = process.env.WEB_COOKIE_USE_BROWSER; if (flag === "1" || flag === "true" || flag === "on") return true; @@ -7,16 +5,20 @@ export function shouldUseGrokBrowserBacked(): boolean { return poolFlag === "on" || poolFlag === "1" || poolFlag === "true"; } -let grokClearanceAcquireOverride: ((prompt: string) => Promise) | null = null; +let grokClearanceAcquireOverride: + ((signal?: AbortSignal | null) => Promise) + | null = null; export function __setGrokClearanceAcquireOverrideForTesting( - fn: ((prompt: string) => Promise) | null, + fn: ((signal?: AbortSignal | null) => Promise) | null, ): void { grokClearanceAcquireOverride = fn; } -export async function acquireFreshGrokClearance(prompt: string): Promise { - if (grokClearanceAcquireOverride) return grokClearanceAcquireOverride(prompt); +export async function acquireFreshGrokClearance( + signal?: AbortSignal | null, +): Promise { + if (grokClearanceAcquireOverride) return grokClearanceAcquireOverride(signal); const mod = await import("@omniroute/browser-pool"); - return mod.acquireFreshGrokClearance(prompt); + return mod.acquireFreshGrokClearance(signal); } diff --git a/tests/unit/browserBackedChat-optional-package-absent.test.ts b/tests/unit/browserBackedChat-optional-package-absent.test.ts new file mode 100644 index 0000000000..60fc3b8e5c --- /dev/null +++ b/tests/unit/browserBackedChat-optional-package-absent.test.ts @@ -0,0 +1,92 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + __resetBrowserPoolModOverrideForTesting, + __resetHttpBackedChatOverrideForTesting, + __setBrowserPoolModOverrideForTesting, + __setHttpBackedChatOverrideForTesting, + tryBackedChat, +} from "../../open-sse/services/browserBackedChat.ts"; + +// Regression test for the "optional @omniroute/browser-pool package absent" +// path in tryBackedChat(). Before the fix, when the upstream returned a +// challenge response (e.g. 403) and the optional browser-pool package was +// not installed (getMod() resolves to null — the same shape a real failed +// `import("@omniroute/browser-pool")` produces), tryBackedChat() silently +// returned the stale challenge result instead of surfacing a clear error — +// callers (claude-web, duckduckgo-web) would then treat the unsolved +// challenge as a definitive upstream response. +// +// __setBrowserPoolModOverrideForTesting(null) simulates the module-absent +// case deterministically (same as getMod() catching a failed dynamic +// import), without depending on a real failing dynamic import of a +// nonexistent package. +describe("tryBackedChat — optional @omniroute/browser-pool package absent", () => { + it("throws a descriptive error instead of silently returning the stale challenge response", async () => { + __setBrowserPoolModOverrideForTesting(null); + __setHttpBackedChatOverrideForTesting(async () => ({ + status: 403, + contentType: "text/html", + body: Buffer.from("Just a moment..."), + isStealth: true, + timing: { + acquireContextMs: 0, + navigateMs: 0, + submitMs: 0, + captureResponseMs: 0, + totalMs: 0, + }, + })); + + try { + await assert.rejects( + () => + tryBackedChat({ + poolKey: "duckduckgo-web", + chatUrl: "https://duck.ai/duckchat/v1/chat", + userMessage: "hello", + }), + (err: unknown) => { + assert.ok(err instanceof Error); + // Must NOT resolve with the stale challenge body — must throw with + // enough context to diagnose (challenge status + missing package). + assert.match(err.message, /challenge/i); + assert.match(err.message, /@omniroute\/browser-pool/); + assert.match(err.message, /403/); + return true; + } + ); + } finally { + __resetHttpBackedChatOverrideForTesting(); + __resetBrowserPoolModOverrideForTesting(); + } + }); + + it("returns the httpResult directly when it is not a challenge response (2xx path unaffected)", async () => { + __setHttpBackedChatOverrideForTesting(async () => ({ + status: 200, + contentType: "application/json", + body: Buffer.from(JSON.stringify({ ok: true })), + isStealth: true, + timing: { + acquireContextMs: 0, + navigateMs: 0, + submitMs: 0, + captureResponseMs: 0, + totalMs: 0, + }, + })); + + try { + const result = await tryBackedChat({ + poolKey: "duckduckgo-web", + chatUrl: "https://duck.ai/duckchat/v1/chat", + userMessage: "hello", + }); + assert.equal(result.status, 200); + } finally { + __resetHttpBackedChatOverrideForTesting(); + } + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 5a405d3256..f47c2d2985 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,7 +19,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"] }, "plugins": [ {