Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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/*

Expand Down
1 change: 1 addition & 0 deletions changelog.d/fixes/15076-gemini-web-proxy-zai-xvfb.md
Original file line number Diff line number Diff line change
@@ -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)
15 changes: 15 additions & 0 deletions open-sse/executors/browserExecutableCheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
);
}
9 changes: 8 additions & 1 deletion open-sse/executors/gemini-web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
16 changes: 15 additions & 1 deletion open-sse/executors/zai-web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
9 changes: 8 additions & 1 deletion open-sse/services/browserPool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, string> } : {}),
});
}

// #12274: prefer Obscura (lightweight, browser-grade CDP) over a full
Expand Down Expand Up @@ -615,6 +621,7 @@ export async function shutdownPool(reason: string): Promise<void> {
}
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.
Expand Down
113 changes: 113 additions & 0 deletions open-sse/services/virtualDisplay.ts
Original file line number Diff line number Diff line change
@@ -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<string> | 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<string> {
return new Promise<string>((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<string | undefined> {
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 */
}
}
84 changes: 84 additions & 0 deletions tests/unit/issue-15076-gemini-web-proxy.test.ts
Original file line number Diff line number Diff line change
@@ -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}`
);
});
82 changes: 82 additions & 0 deletions tests/unit/virtual-display-15300.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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);
});
Loading
Loading