From 9417c4a91274f1bce981ef14ea64f523c52b5bdf Mon Sep 17 00:00:00 2001 From: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 8 Oct 2026 02:46:08 -0300 Subject: [PATCH] fix(browser): apply gemini-web provider proxy and start Xvfb for headed zai-web (#15076, #15300) --- Dockerfile | 3 + .../fixes/15076-gemini-web-proxy-zai-xvfb.md | 1 + open-sse/executors/browserExecutableCheck.ts | 15 +++ open-sse/executors/gemini-web.ts | 9 +- open-sse/executors/zai-web.ts | 16 ++- open-sse/services/browserPool.ts | 9 +- open-sse/services/virtualDisplay.ts | 113 ++++++++++++++++++ .../unit/issue-15076-gemini-web-proxy.test.ts | 84 +++++++++++++ tests/unit/virtual-display-15300.test.ts | 82 +++++++++++++ .../webpack-create-require-warning.test.ts | 1 + .../zai-web-headed-no-display-15300.test.ts | 57 +++++++++ 11 files changed, 387 insertions(+), 3 deletions(-) create mode 100644 changelog.d/fixes/15076-gemini-web-proxy-zai-xvfb.md create mode 100644 open-sse/services/virtualDisplay.ts create mode 100644 tests/unit/issue-15076-gemini-web-proxy.test.ts create mode 100644 tests/unit/virtual-display-15300.test.ts create mode 100644 tests/unit/zai-web-headed-no-display-15300.test.ts diff --git a/Dockerfile b/Dockerfile index 911e9760c13a..b92758cae8c0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -318,6 +318,8 @@ USER root COPY --from=builder /app/node_modules/playwright-core ./node_modules/playwright-core COPY --from=builder /app/node_modules/playwright ./node_modules/playwright +# xvfb is installed explicitly (#15300): zai-web needs a headed Chromium and the browser pool +# starts a private Xvfb when the container has no DISPLAY. # Install Playwright browser binaries + OS dependencies under root, then hand # ownership of the browsers cache to the node user. # PLAYWRIGHT_BROWSERS_PATH overrides the default ~/.cache/ms-playwright so the @@ -328,6 +330,7 @@ RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-cache,targe --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-lists,target=/var/lib/apt/lists,sharing=locked \ apt-get update \ && node node_modules/playwright/cli.js install chromium --with-deps \ + && apt-get install -y --no-install-recommends xvfb \ && chown -R node:node /home/node/.cache \ && rm -rf /var/lib/apt/lists/* diff --git a/changelog.d/fixes/15076-gemini-web-proxy-zai-xvfb.md b/changelog.d/fixes/15076-gemini-web-proxy-zai-xvfb.md new file mode 100644 index 000000000000..49580429c523 --- /dev/null +++ b/changelog.d/fixes/15076-gemini-web-proxy-zai-xvfb.md @@ -0,0 +1 @@ +- fix(browser): gemini-web now applies the configured provider proxy to its Playwright context, and headed browsers (zai-web) start a private Xvfb when the host has no display (Docker -web image now installs xvfb), with a clear 503 instead of a bare 502 when none can be started (#15076, #15300) diff --git a/open-sse/executors/browserExecutableCheck.ts b/open-sse/executors/browserExecutableCheck.ts index fadd5b5a0df2..4697c2032670 100644 --- a/open-sse/executors/browserExecutableCheck.ts +++ b/open-sse/executors/browserExecutableCheck.ts @@ -16,3 +16,18 @@ export function isMissingBrowserExecutable(message: string): boolean { (lower.includes("chromium") && lower.includes("download")) ); } + +/** + * #15300: a headed Chromium launched on a host with no X server (Docker -web image, bare VPS) + * dies at launch. Host/config problem like a missing binary — not a transient upstream fault. + */ +export function isMissingDisplay(message: string): boolean { + if (!message) return false; + const lower = message.toLowerCase(); + return ( + lower.includes("xserver") || + lower.includes("x server") || + lower.includes("headed browser without") || + lower.includes("$display") + ); +} diff --git a/open-sse/executors/gemini-web.ts b/open-sse/executors/gemini-web.ts index 5bd4efd76612..d753e491b841 100644 --- a/open-sse/executors/gemini-web.ts +++ b/open-sse/executors/gemini-web.ts @@ -575,7 +575,14 @@ export class GeminiWebExecutor extends BaseExecutor { }; signal?.addEventListener("abort", abortBrowser, { once: true }); - context = await browser.newContext({ userAgent: GEMINI_USER_AGENT }); + // #15076: apply the proxy configured for gemini-web (provider/global scope). Resolved per + // context so the shared leased browser needs no invalidation when the proxy changes. + const { resolvePlaywrightProxy } = await import("../services/browserPool.ts"); + const proxy = await resolvePlaywrightProxy("gemini-web"); + context = await browser.newContext({ + userAgent: GEMINI_USER_AGENT, + ...(proxy ? { proxy } : {}), + }); // Parse cookies — strips attributes like Path, Domain, Expires const cookiePairs = parseCookies(cookie); diff --git a/open-sse/executors/zai-web.ts b/open-sse/executors/zai-web.ts index dafed25c63a2..236943166d94 100644 --- a/open-sse/executors/zai-web.ts +++ b/open-sse/executors/zai-web.ts @@ -51,7 +51,7 @@ import { makeZaiChunkEmitter, } from "./zai-web/stream.ts"; import { browserBackedChat } from "../services/browserBackedChat.ts"; -import { isMissingBrowserExecutable } from "./browserExecutableCheck.ts"; +import { isMissingBrowserExecutable, isMissingDisplay } from "./browserExecutableCheck.ts"; import { CursorImageError, resolveCursorImages } from "../utils/cursorImages.ts"; import { makeExecutorErrorResult as makeErrorResult, @@ -444,6 +444,20 @@ export class ZaiWebExecutor extends BaseExecutor { ), }; } + // #15300: headed Chromium needs a display; Xvfb could not be provided. Same class of + // host/config fault as the missing binary above. + if (isMissingDisplay(rawMessage)) { + return { + errorResult: makeErrorResult( + 503, + "Z.ai needs a display for its headed browser, and none is available. " + + "Install Xvfb (apt-get install xvfb) on the host or use the -web Docker image.", + input.body, + ZAI_CHAT_URL, + { "X-Omni-Fallback-Hint": "connection_cooldown" } + ), + }; + } const message = sanitizeErrorMessage(rawMessage); return { errorResult: makeErrorResult( diff --git a/open-sse/services/browserPool.ts b/open-sse/services/browserPool.ts index 09f1d4086626..fd74c1f3faee 100644 --- a/open-sse/services/browserPool.ts +++ b/open-sse/services/browserPool.ts @@ -27,6 +27,7 @@ import { Buffer } from "node:buffer"; import { connectObscuraBrowser } from "./obscura.ts"; +import { ensureVirtualDisplay, stopVirtualDisplay } from "./virtualDisplay.ts"; type Browser = import("playwright").Browser; type BrowserContext = import("playwright").BrowserContext; @@ -313,7 +314,12 @@ async function launchBrowserInstance( // preference below applies to the headless path only. if (!headless) { const { chromium } = await import("playwright"); - return chromium.launch(resolvePlainBrowserLaunchOptions(options)); + // #15300: no X server (Docker -web image / bare VPS) → start a private Xvfb. + const display = await ensureVirtualDisplay(); + return chromium.launch({ + ...resolvePlainBrowserLaunchOptions(options), + ...(display ? { env: { ...process.env, DISPLAY: display } as Record } : {}), + }); } // #12274: prefer Obscura (lightweight, browser-grade CDP) over a full @@ -615,6 +621,7 @@ export async function shutdownPool(reason: string): Promise { } state.launching = null; state.headedLaunching = null; + stopVirtualDisplay(); // #12274: the shared Obscura server is owned by ./obscura.ts and reused by // executors (cloudflare-playground), so closing the pool's CDP connection is // enough — never kill the server here. diff --git a/open-sse/services/virtualDisplay.ts b/open-sse/services/virtualDisplay.ts new file mode 100644 index 000000000000..5cc75624559c --- /dev/null +++ b/open-sse/services/virtualDisplay.ts @@ -0,0 +1,113 @@ +// #15300: headed Chromium (zai-web needs a real window — Z.ai's CAPTCHA rejects headless) +// exits at launch on a host with no X server (Docker -web image, bare VPS). When a headed launch +// has no DISPLAY/WAYLAND_DISPLAY on Linux we start one private Xvfb per process and hand its +// DISPLAY to Chromium. Hosts that already have a display are left untouched. +import { spawn as nodeSpawn, type ChildProcess } from "node:child_process"; + +export const MISSING_DISPLAY_MESSAGE = + "Missing X server or $DISPLAY: a headed browser needs a display. " + + "Install Xvfb (apt-get install xvfb) or run with xvfb-run / the -web Docker image."; + +export class MissingDisplayError extends Error { + readonly code = "OMNI_NO_DISPLAY"; + constructor(detail?: string) { + super(detail ? `${MISSING_DISPLAY_MESSAGE} (${detail})` : MISSING_DISPLAY_MESSAGE); + this.name = "MissingDisplayError"; + } +} + +type SpawnFn = typeof nodeSpawn; + +interface VirtualDisplayDeps { + spawn?: SpawnFn; + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; + timeoutMs?: number; +} + +let current: { child: ChildProcess; display: string } | null = null; +let starting: Promise | null = null; +let exitHookInstalled = false; + +export function hasDisplay(env: NodeJS.ProcessEnv = process.env): boolean { + return Boolean(env.DISPLAY || env.WAYLAND_DISPLAY); +} + +function startXvfb(spawn: SpawnFn, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + // `-displayfd 3` makes Xvfb pick a free display number and write it to fd 3. + // Args array, no shell (hard rule #13). + const child = spawn( + "Xvfb", + ["-displayfd", "3", "-screen", "0", "1280x1024x24", "-nolisten", "tcp"], + { stdio: ["ignore", "ignore", "ignore", "pipe"] } + ); + let settled = false; + let out = ""; + const fail = (err: Error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + try { + child.kill("SIGTERM"); + } catch { + /* ignore */ + } + reject(err); + }; + const timer = setTimeout(() => fail(new MissingDisplayError("Xvfb start timeout")), timeoutMs); + timer.unref?.(); + child.once("error", (err: NodeJS.ErrnoException) => + fail(new MissingDisplayError(err.code === "ENOENT" ? "Xvfb not found" : err.message)) + ); + child.once("exit", () => { + if (current?.child === child) current = null; + fail(new MissingDisplayError("Xvfb exited")); + }); + const pipe = child.stdio?.[3] as NodeJS.ReadableStream | null | undefined; + pipe?.on("data", (chunk: Buffer | string) => { + out += chunk.toString(); + const m = /^(\d+)\n/.exec(out); + if (!m || settled) return; + settled = true; + clearTimeout(timer); + const display = `:${m[1]}`; + current = { child, display }; + child.unref?.(); + resolve(display); + }); + }); +} + +/** + * Returns the DISPLAY value Chromium must be launched with, or undefined when the host already + * has a display (or is not Linux). Throws MissingDisplayError when Xvfb cannot be started. + */ +export async function ensureVirtualDisplay( + deps: VirtualDisplayDeps = {} +): Promise { + const env = deps.env ?? process.env; + if ((deps.platform ?? process.platform) !== "linux" || hasDisplay(env)) return undefined; + if (current && current.child.exitCode === null && !current.child.killed) return current.display; + current = null; + if (!starting) { + starting = startXvfb(deps.spawn ?? nodeSpawn, deps.timeoutMs ?? 5000).finally(() => { + starting = null; + }); + if (!exitHookInstalled && !deps.spawn) { + exitHookInstalled = true; + process.once("exit", () => stopVirtualDisplay()); + } + } + return starting; +} + +export function stopVirtualDisplay(): void { + const child = current?.child; + current = null; + try { + child?.kill("SIGTERM"); + } catch { + /* ignore */ + } +} diff --git a/tests/unit/issue-15076-gemini-web-proxy.test.ts b/tests/unit/issue-15076-gemini-web-proxy.test.ts new file mode 100644 index 000000000000..c4fc1e58bd64 --- /dev/null +++ b/tests/unit/issue-15076-gemini-web-proxy.test.ts @@ -0,0 +1,84 @@ +// #15076: gemini-web launches Chromium without applying the configured provider proxy. +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-15076-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const proxiesDb = await import("../../src/lib/db/proxies.ts"); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +test("#15076 gemini-web passes the provider proxy to Chromium (launch or context)", async () => { + const created = await proxiesDb.createProxy({ + name: "gemini proxy", + type: "http", + host: "proxy.example.test", + port: 7890, + }); + await proxiesDb.assignProxyToScope("provider", "gemini-web", created.id); + // sanity: the registry DOES resolve a proxy for this provider (what browserPool uses) + const resolved = (await proxiesDb.resolveProxyForProvider("gemini-web")) as { + host?: string; + } | null; + assert.equal(resolved?.host, "proxy.example.test"); + + const playwright = await import("playwright"); + const originalLaunch = playwright.chromium.launch; + const launchOptions: unknown[] = []; + const contextOptions: unknown[] = []; + playwright.chromium.launch = (async (opts: unknown) => { + launchOptions.push(opts); + return { + newContext: async (o: unknown) => { + contextOptions.push(o); + return { + addCookies: async () => undefined, + close: async () => undefined, + newPage: async () => ({ + on: () => undefined, + goto: async () => undefined, + waitForTimeout: async () => undefined, + waitForSelector: async (s: string) => + s.includes(".ql-editor") ? { click: async () => undefined } : null, + keyboard: { insertText: async () => undefined, press: async () => undefined }, + }), + cookies: async () => [], + }; + }, + close: async () => undefined, + }; + }) as never; + + try { + const { GeminiWebExecutor } = await import("../../open-sse/executors/gemini-web.ts"); + const { resetGeminiBrowserLeaseForTests } = + await import("../../open-sse/executors/gemini-web/browserLease.ts"); + await resetGeminiBrowserLeaseForTests(); + await new GeminiWebExecutor().execute({ + model: "gemini-3.1-pro", + body: { messages: [{ role: "user", content: "hello" }], stream: false }, + stream: false, + credentials: { apiKey: "fake-cookie=abc" }, + signal: AbortSignal.timeout(5000), + log: null, + } as never); + await resetGeminiBrowserLeaseForTests(); + } finally { + playwright.chromium.launch = originalLaunch; + } + + const all = JSON.stringify({ launchOptions, contextOptions }); + assert.ok( + all.includes("proxy.example.test"), + `configured proxy never reached Playwright: ${all}` + ); +}); diff --git a/tests/unit/virtual-display-15300.test.ts b/tests/unit/virtual-display-15300.test.ts new file mode 100644 index 000000000000..8b99879df033 --- /dev/null +++ b/tests/unit/virtual-display-15300.test.ts @@ -0,0 +1,82 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; + +import { + ensureVirtualDisplay, + stopVirtualDisplay, + MissingDisplayError, +} from "../../open-sse/services/virtualDisplay.ts"; +import { isMissingDisplay } from "../../open-sse/executors/browserExecutableCheck.ts"; + +function fakeSpawn(behavior: "ok" | "enoent") { + const calls: Array<{ cmd: string; args: string[] }> = []; + const spawn = ((cmd: string, args: string[]) => { + calls.push({ cmd, args }); + const child = new EventEmitter() as EventEmitter & Record; + child.exitCode = null; + child.killed = false; + child.kill = () => { + child.killed = true; + return true; + }; + child.unref = () => undefined; + const pipe = new PassThrough(); + child.stdio = [null, null, null, pipe]; + setImmediate(() => { + if (behavior === "enoent") { + child.emit("error", Object.assign(new Error("spawn Xvfb ENOENT"), { code: "ENOENT" })); + } else { + pipe.write("99\n"); + } + }); + return child; + }) as never; + return { spawn, calls }; +} + +test.afterEach(() => stopVirtualDisplay()); + +test("no Xvfb when the host already has a display or is not linux", async () => { + const { spawn, calls } = fakeSpawn("ok"); + assert.equal( + await ensureVirtualDisplay({ spawn, env: { DISPLAY: ":0" }, platform: "linux" }), + undefined + ); + assert.equal( + await ensureVirtualDisplay({ spawn, env: { WAYLAND_DISPLAY: "w" }, platform: "linux" }), + undefined + ); + assert.equal(await ensureVirtualDisplay({ spawn, env: {}, platform: "darwin" }), undefined); + assert.equal(calls.length, 0); +}); + +test("starts one private Xvfb (args array, no shell) and reuses it", async () => { + const { spawn, calls } = fakeSpawn("ok"); + const a = await ensureVirtualDisplay({ spawn, env: {}, platform: "linux" }); + const b = await ensureVirtualDisplay({ spawn, env: {}, platform: "linux" }); + assert.equal(a, ":99"); + assert.equal(b, ":99"); + assert.equal(calls.length, 1); + assert.equal(calls[0].cmd, "Xvfb"); + assert.ok(calls[0].args.includes("-displayfd")); +}); + +test("missing Xvfb binary throws a typed error that isMissingDisplay recognises", async () => { + const { spawn } = fakeSpawn("enoent"); + await assert.rejects( + ensureVirtualDisplay({ spawn, env: {}, platform: "linux" }), + (err: Error) => err instanceof MissingDisplayError && isMissingDisplay(err.message) + ); +}); + +test("isMissingDisplay matches Playwright's headed-without-XServer text only", () => { + assert.equal( + isMissingDisplay("Looks like you launched a headed browser without having a XServer running."), + true + ); + assert.equal(isMissingDisplay("Missing X server or $DISPLAY"), true); + assert.equal(isMissingDisplay("net::ERR_CONNECTION_RESET"), false); + assert.equal(isMissingDisplay(""), false); +}); diff --git a/tests/unit/webpack-create-require-warning.test.ts b/tests/unit/webpack-create-require-warning.test.ts index a47be0011c10..21eb556ce91b 100644 --- a/tests/unit/webpack-create-require-warning.test.ts +++ b/tests/unit/webpack-create-require-warning.test.ts @@ -79,6 +79,7 @@ async function compileRuntimeRequireModules(): Promise { // has no repo tree, so treat the sibling as external instead of // erroring "Can't resolve './obscura.ts'". "./obscura.ts", + "./virtualDisplay.ts", "./tlsFirstByteWatchdog.ts", // machineToken.ts imports `./dataPaths` since #13909 (random per-install // CLI token salt reads the data dir). Same isolated-compile reason. diff --git a/tests/unit/zai-web-headed-no-display-15300.test.ts b/tests/unit/zai-web-headed-no-display-15300.test.ts new file mode 100644 index 000000000000..b66a1dc9a018 --- /dev/null +++ b/tests/unit/zai-web-headed-no-display-15300.test.ts @@ -0,0 +1,57 @@ +/** + * Regression probe for #15300 — zai-web 502 "browserType.launch: Target page, context or + * browser has been closed" in the Docker `-web` image. + * + * zai-web requests a HEADED Chromium (headless: false, off-screen window). Docker runner-web + * has no X server and nothing starts Xvfb, so Chromium exits at launch and Playwright throws + * "browserType.launch: Target page, context or browser has been closed" (browser log: + * "Missing X server or $DISPLAY"). That is surfaced as a bare 502 with no actionable hint. + */ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { Buffer } from "node:buffer"; + +const TEST_TOKEN = `e30.${Buffer.from(JSON.stringify({ id: "user-123" })).toString("base64url")}.sig`; + +describe("issue #15300 — zai-web headed launch without a display", () => { + const saved: Record = {}; + before(() => { + for (const k of ["DISPLAY", "WAYLAND_DISPLAY"]) { + saved[k] = process.env[k]; + delete process.env[k]; + } + }); + after(async () => { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + const pool = await import("../../open-sse/services/browserPool.ts"); + await (pool as { shutdownBrowserPool?: () => Promise }).shutdownBrowserPool?.(); + }); + + it("does not surface a bare 502 'browserType.launch' failure (must self-provide a display or return an actionable 503 + cooldown hint)", async () => { + const mod = await import("../../open-sse/executors/zai-web.ts"); + const executor = new mod.ZaiWebExecutor(); + const result = await executor.execute({ + model: "glm-5.3-flash", + body: { model: "glm-5.3-flash", messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: TEST_TOKEN }, + signal: null, + }); + const response = (result as { response: Response }).response; + const payload = (await response.json()) as { error?: { message?: string } }; + const msg = payload.error?.message ?? ""; + console.log( + "STATUS", + response.status, + "HINT", + response.headers.get("X-Omni-Fallback-Hint"), + "MSG", + msg.slice(0, 300) + ); + const bareLaunchFailure = response.status === 502 && /browserType\.launch/.test(msg); + assert.equal(bareLaunchFailure, false, `bare 502 launch failure: ${msg}`); + }); +});