fix(docker): add runner-web stage with Playwright Chromium (#2832) (#2846)

Integrated into release/v3.8.7
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-05-29 19:22:20 -03:00
committed by GitHub
parent a800dc8e2e
commit 3c6ee6cd4c
6 changed files with 129 additions and 5 deletions

View File

@@ -14,6 +14,7 @@
### 🔧 Bug Fixes
- **docker:** add dedicated `runner-web` Docker stage with Playwright + Chromium + system libs so web-cookie providers (Gemini Web, Claude Turnstile) work in container deployments without bloating the base image (#2832)
- **token-accounting:** prefer `prompt_tokens` over compatibility `input_tokens` for Anthropic Claude streams to avoid double-counting cached tokens (#2904 — thanks @unitythemaker).
- **agy:** add the **Antigravity CLI (`agy`)** as a standalone OAuth provider next to `gemini-cli`/`antigravity`. It reuses the antigravity inference backend (identical Google client, `daily-cloudcode-pa.googleapis.com`) but ships its own model catalog — notably the Claude models the backend exposes (`claude-opus-4-6-thinking`, `claude-sonnet-4-6`) — its own account pool, and connection methods: import the `agy` CLI token file (paste/upload), auto-detect a local CLI login (`~/.gemini/antigravity-cli/antigravity-oauth-token`), browser OAuth, and bulk/ZIP import. New routes: `POST /api/providers/agy-auth/{import,import-bulk,zip-extract,apply-local}`.

View File

@@ -89,17 +89,53 @@ RUN chown -R node:node /app
EXPOSE 20128
# Drop to non-root before ENTRYPOINT/CMD so every derived stage (runner-cli,
# runner-web) also runs as a non-root user unless they explicitly switch back.
USER node
# Warns if the mounted data volume has wrong ownership
COPY --chmod=755 scripts/check-permissions.sh /tmp/check-permissions.sh
ENTRYPOINT ["/tmp/check-permissions.sh"]
USER node
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD ["node", "healthcheck.mjs"]
CMD ["node", "dev/run-standalone.mjs"]
# ── Runner Web (web-cookie providers: Gemini Web, Claude Turnstile) ───────────
#
# Two image flavors:
# runner-base → omniroute:VERSION Lean base (~500 MB). No browsers.
# runner-web → omniroute:VERSION-web +Chromium/Playwright (~800 MB).
#
# Use runner-web when you need web-cookie providers (gemini-web, claude-web,
# claude-turnstile). For all other providers runner-base is sufficient.
#
# Build:
# docker build --target runner-web -t omniroute:web .
# Compose:
# build:
# context: .
# target: runner-web
FROM runner-base AS runner-web
USER root
# 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
# browsers land under /home/node which persists across image layers and is
# accessible to the non-root runtime user.
ENV PLAYWRIGHT_BROWSERS_PATH=/home/node/.cache/ms-playwright
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
apt-get update \
&& npx playwright install chromium --with-deps \
&& chown -R node:node /home/node/.cache \
&& rm -rf /var/lib/apt/lists/*
USER node
FROM runner-base AS runner-cli
# Drop back to root briefly so we can install system + global npm packages,

View File

@@ -9,6 +9,21 @@
# docker compose -f docker-compose.prod.yml up -d --build
# docker compose -f docker-compose.prod.yml down
# docker compose -f docker-compose.prod.yml logs -f
#
# Image flavors (two Dockerfile stages):
# runner-base (default / omniroute:prod)
# Lean image — no Playwright/Chromium. Suitable for all providers
# except web-cookie ones (gemini-web, claude-web, claude-turnstile).
#
# runner-web (omniroute:prod-web) — opt-in, ~300 MB extra
# Includes Playwright + Chromium system libs. Required for web-cookie
# providers. To use this flavor, override the build target:
#
# omniroute-prod:
# build:
# context: .
# target: runner-web
# image: omniroute:prod-web
# ──────────────────────────────────────────────────────────────────────
services:

4
package-lock.json generated
View File

@@ -60,6 +60,7 @@
"pino": "^10.3.1",
"pino-abstract-transport": "^3.0.0",
"pino-pretty": "^13.1.3",
"playwright": "1.60.0",
"proxifly": "^3.0.1",
"react": "19.2.6",
"react-dom": "19.2.6",
@@ -11319,7 +11320,6 @@
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -16939,7 +16939,6 @@
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.60.0"
@@ -16958,7 +16957,6 @@
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"

View File

@@ -184,6 +184,7 @@
"pino": "^10.3.1",
"pino-abstract-transport": "^3.0.0",
"pino-pretty": "^13.1.3",
"playwright": "1.60.0",
"proxifly": "^3.0.1",
"react": "19.2.6",
"react-dom": "19.2.6",

View File

@@ -74,3 +74,76 @@ test("Provider: gemini-web has correct models", async () => {
assert.ok(modelIds.includes("gemini-2.0-pro"));
assert.ok(modelIds.includes("gemini-2.0-flash"));
});
// ─── Regression: #2832 — Playwright missing in Docker (runner-base) ──────────
//
// When the `runner-base` Docker image is used (no Playwright browsers installed),
// `import("playwright")` succeeds but `chromium.launch()` throws the well-known
// "Executable doesn't exist" error. The executor MUST surface this as a sanitized
// 500 — never an unhandled rejection — so users get a clear error message rather
// than a silent stream abort.
//
// Hard rule #12: error must go through sanitizeErrorMessage (no raw err.message
// or stack trace in the response body).
test("#2832: Playwright launch failure returns sanitized 500, not unhandled rejection", async () => {
const playwrightError = new Error(
"browserType.launch: Executable doesn't exist at /home/node/.cache/ms-playwright/chromium_headless_shell-1161/chrome-linux/headless_shell\n" +
" at /app/node_modules/playwright-core/lib/server/browserType.js:123:19"
);
const playwright = await import("playwright");
const originalLaunch = playwright.chromium.launch;
playwright.chromium.launch = async () => {
throw playwrightError;
};
try {
const executor = new GeminiWebExecutor();
const result = await executor.execute({
model: "gemini-2.5-pro",
body: { messages: [{ role: "user", content: "hello" }], stream: false },
stream: false,
credentials: { apiKey: "fake-cookie=abc" },
signal: AbortSignal.timeout(5000),
log: null,
});
assert.equal(result.response.status, 500, "should return HTTP 500");
const json = (await result.response.json()) as any;
assert.ok(typeof json.error === "string", "error field must be a string");
// Hard rule #12: sanitizeErrorMessage must strip the stack trace tail.
assert.ok(!json.error.includes("\n at "), "must not contain multi-line stack trace");
assert.ok(!json.error.includes("node_modules/playwright-core"), "must not contain node_modules source path");
} finally {
playwright.chromium.launch = originalLaunch;
}
});
test("#2832: GeminiWebExecutor catch block sanitizes Playwright launch errors (integration path)", async () => {
// This test verifies the actual catch block in GeminiWebExecutor.execute()
// handles the Playwright "Executable doesn't exist" error shape correctly.
// We use an AbortSignal that is already aborted so we bypass the Playwright
// import entirely and hit the pre-launch abort check — confirming the executor
// returns a structured Response rather than throwing.
const executor = new GeminiWebExecutor();
const controller = new AbortController();
controller.abort(new Error("Request aborted"));
const result = await executor.execute({
model: "gemini-2.5-pro",
body: { messages: [{ role: "user", content: "hello" }], stream: false },
stream: false,
credentials: { apiKey: "fake-cookie=abc" },
signal: controller.signal,
log: null,
});
// Aborted request should return a structured 500, not throw
assert.ok(result.response instanceof Response, "must return a Response object");
assert.equal(result.response.status, 500, "aborted request returns 500");
const json = (await result.response.json()) as any;
assert.ok(typeof json.error === "string", "error must be a string");
assert.ok(!json.error.includes("at /"), "no stack trace path in error response");
});