mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-23 07:12:29 +03:00
Compare commits
3 Commits
fix/13679f
...
fix/13232-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bd058824b | ||
|
|
a24562ece3 | ||
|
|
f0d2d34ee5 |
@@ -3043,13 +3043,6 @@ QUOTA_STORE_DRIVER=sqlite
|
||||
# CHATGPT_WEB_CODEX_CHROME_PATH=/usr/bin/chromium
|
||||
# CHROME_PATH=/usr/bin/chromium
|
||||
# CHATGPT_WEB_CODEX_CDP_URL=http://chatgpt-web-codex-browser:9223
|
||||
# CDP_PROXY_TOKEN required by docker/chatgpt-web-codex-browser/cdp-proxy.mjs (#13679):
|
||||
# when set, every request to the CDP proxy sidecar must present it as an
|
||||
# `X-Omni-Cdp-Token` header. Left unset, the proxy keeps forwarding requests
|
||||
# unauthenticated (network isolation via docker-compose.yml's dedicated
|
||||
# `chatgpt-web-codex-net` is the default mitigation). Generate with:
|
||||
# `openssl rand -hex 32`
|
||||
# CDP_PROXY_TOKEN=
|
||||
# CHATGPT_WEB_CODEX_TUNNEL_ID=tunnel_0123456789abcdef0123456789abcdef
|
||||
# CHATGPT_WEB_CODEX_RUNTIME_KEY=
|
||||
# CHATGPT_WEB_CODEX_CONNECTOR_NAME=OmniRoute Codex v2
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(sse):** classify a missing Playwright Chromium install on the Z.ai web transport as an actionable 503 host/config cooldown instead of a generic 502 that trips the provider circuit breaker (#13232) — thanks @oleksandr1811
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(docker):** isolate the ChatGPT Web (Codex) CDP proxy sidecar onto its own Compose network, add an opt-in `CDP_PROXY_TOKEN` auth gate to `cdp-proxy.mjs`, and stop the VNC browser-login CDP bridge from starting when no token is configured (#13679)
|
||||
@@ -142,24 +142,11 @@ services:
|
||||
- "${APP_BIND_HOST:-127.0.0.1}:${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}"
|
||||
- "${APP_BIND_HOST:-127.0.0.1}:${API_PORT:-20129}:${API_PORT:-20129}"
|
||||
- "${APP_BIND_HOST:-127.0.0.1}:${LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}"
|
||||
# SECURITY (#13679): joins BOTH `default` (to keep reaching redis and the
|
||||
# other sidecars) AND the dedicated `chatgpt-web-codex-net` (the one
|
||||
# legitimate consumer of the CDP proxy below).
|
||||
networks:
|
||||
- default
|
||||
- chatgpt-web-codex-net
|
||||
profiles:
|
||||
- web
|
||||
|
||||
# Internal-only Chromium runtime for ChatGPT Web (Codex). No CDP or browser
|
||||
# UI port is published to the host.
|
||||
#
|
||||
# SECURITY (#13679): isolated onto its own `chatgpt-web-codex-net` network
|
||||
# instead of the shared implicit default bridge — its cdp-proxy.mjs
|
||||
# sidecar republishes Chromium's CDP on 0.0.0.0:9223, and CDP grants full
|
||||
# control over a live browser session. Without this isolation, any
|
||||
# compromised sibling container (redis, qdrant, bifrost, cliproxyapi,
|
||||
# codex-app-server, ...) on the default network could reach it.
|
||||
chatgpt-web-codex-browser:
|
||||
build:
|
||||
context: .
|
||||
@@ -167,12 +154,8 @@ services:
|
||||
image: omniroute:chatgpt-web-codex-browser
|
||||
restart: unless-stopped
|
||||
shm_size: "2gb"
|
||||
environment:
|
||||
- CDP_PROXY_TOKEN=${CDP_PROXY_TOKEN:-}
|
||||
volumes:
|
||||
- chatgpt-web-codex-browser-data:/browser-profile
|
||||
networks:
|
||||
- chatgpt-web-codex-net
|
||||
profiles:
|
||||
- web
|
||||
|
||||
@@ -387,12 +370,7 @@ services:
|
||||
# compose network by the omniroute app.
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"require('http').get('http://127.0.0.1:1456/readyz',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))",
|
||||
]
|
||||
["CMD", "node", "-e", "require('http').get('http://127.0.0.1:1456/readyz',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
@@ -400,13 +378,6 @@ services:
|
||||
profiles:
|
||||
- codex-app-server
|
||||
|
||||
networks:
|
||||
# SECURITY (#13679): dedicated network for the unauthenticated-by-default
|
||||
# CDP proxy sidecar (docker/chatgpt-web-codex-browser/cdp-proxy.mjs) —
|
||||
# shared only with omniroute-web, not with redis/qdrant/bifrost/cliproxyapi/
|
||||
# codex-app-server or any other sibling on the implicit default network.
|
||||
chatgpt-web-codex-net: {}
|
||||
|
||||
volumes:
|
||||
chatgpt-web-codex-browser-data:
|
||||
name: omniroute-chatgpt-web-codex-browser-data
|
||||
|
||||
@@ -5,31 +5,6 @@ const listenPort = 9223;
|
||||
const upstreamHost = "127.0.0.1";
|
||||
const upstreamPort = 9222;
|
||||
|
||||
// SECURITY (#13679): this proxy republishes Chromium's loopback CDP onto
|
||||
// 0.0.0.0:9223 with no auth of its own — CDP grants full control over a
|
||||
// live browser session (Runtime.evaluate, cookie theft, etc). When the
|
||||
// operator sets CDP_PROXY_TOKEN, every request/WS-upgrade MUST present it as
|
||||
// an `X-Omni-Cdp-Token: <token>` header before a single byte is forwarded
|
||||
// upstream, mirroring the gate docker/vnc-browser/chromium/cdp-bridge.py
|
||||
// already has (#12571). Left unset, the proxy keeps its historical
|
||||
// zero-config behavior — the primary mitigation for the shared-bridge risk
|
||||
// is docker-compose.yml isolating this service onto its own network so no
|
||||
// unrelated sibling container can reach it at all.
|
||||
const TOKEN = process.env.CDP_PROXY_TOKEN || "";
|
||||
const TOKEN_HEADER = "x-omni-cdp-token";
|
||||
|
||||
if (!TOKEN) {
|
||||
console.error(
|
||||
"[cdp-proxy] WARNING: running without CDP_PROXY_TOKEN — every request is forwarded " +
|
||||
"unauthenticated. Set CDP_PROXY_TOKEN to require an X-Omni-Cdp-Token header (#13679)."
|
||||
);
|
||||
}
|
||||
|
||||
function hasValidToken(headers) {
|
||||
if (!TOKEN) return true;
|
||||
return headers[TOKEN_HEADER] === TOKEN;
|
||||
}
|
||||
|
||||
function proxyHeaders(headers) {
|
||||
const next = { ...headers, host: `${upstreamHost}:${upstreamPort}` };
|
||||
delete next.connection;
|
||||
@@ -38,11 +13,6 @@ function proxyHeaders(headers) {
|
||||
}
|
||||
|
||||
const server = http.createServer((request, response) => {
|
||||
if (!hasValidToken(request.headers)) {
|
||||
response.writeHead(403, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ error: "missing or invalid X-Omni-Cdp-Token" }));
|
||||
return;
|
||||
}
|
||||
const upstream = http.request(
|
||||
{
|
||||
host: upstreamHost,
|
||||
@@ -78,10 +48,6 @@ const server = http.createServer((request, response) => {
|
||||
});
|
||||
|
||||
server.on("upgrade", (request, socket, head) => {
|
||||
if (!hasValidToken(request.headers)) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
const upstream = net.connect(upstreamPort, upstreamHost, () => {
|
||||
const upgradeHeaders = {
|
||||
...request.headers,
|
||||
@@ -103,6 +69,4 @@ server.on("upgrade", (request, socket, head) => {
|
||||
upstream.on("error", () => socket.destroy());
|
||||
});
|
||||
|
||||
server.listen(listenPort, "0.0.0.0", () => {
|
||||
console.error(`[cdp-proxy] listening on 0.0.0.0:${listenPort}`);
|
||||
});
|
||||
server.listen(listenPort, "0.0.0.0");
|
||||
|
||||
@@ -10,13 +10,7 @@ if [[ "${PIXELFLUX_WAYLAND,,}" == "true" ]]; then
|
||||
echo "[svc-de] ${SOCKET_PATH} found launching de"
|
||||
cd $HOME
|
||||
# OmniRoute: bridge Chromium DevTools (127.0.0.1:9222) to 0.0.0.0:9223.
|
||||
# SECURITY (#13679): only start the bridge when CDP_BRIDGE_TOKEN is
|
||||
# configured — cdp-bridge.py already fails closed for every caller when
|
||||
# it is unset (#12571), so an unconfigured container gains nothing by
|
||||
# running an always-listening 0.0.0.0:9223 process anyway.
|
||||
if [ -n "${CDP_BRIDGE_TOKEN:-}" ]; then
|
||||
( sleep 8; python3 /usr/local/bin/cdp-bridge.py >/proc/1/fd/2 2>&1 ) &
|
||||
fi
|
||||
( sleep 8; python3 /usr/local/bin/cdp-bridge.py >/proc/1/fd/2 2>&1 ) &
|
||||
exec s6-setuidgid abc \
|
||||
/bin/bash /defaults/startwm_wayland.sh &
|
||||
PID=$!
|
||||
@@ -63,13 +57,7 @@ chmod 777 /tmp/selkies*
|
||||
# run
|
||||
cd $HOME
|
||||
# OmniRoute: bridge Chromium DevTools (127.0.0.1:9222) to 0.0.0.0:9223.
|
||||
# SECURITY (#13679): only start the bridge when CDP_BRIDGE_TOKEN is
|
||||
# configured — cdp-bridge.py already fails closed for every caller when it
|
||||
# is unset (#12571), so an unconfigured container gains nothing by running
|
||||
# an always-listening 0.0.0.0:9223 process anyway.
|
||||
if [ -n "${CDP_BRIDGE_TOKEN:-}" ]; then
|
||||
( sleep 8; python3 /usr/local/bin/cdp-bridge.py >/proc/1/fd/2 2>&1 ) &
|
||||
fi
|
||||
( sleep 8; python3 /usr/local/bin/cdp-bridge.py >/proc/1/fd/2 2>&1 ) &
|
||||
exec s6-setuidgid abc \
|
||||
/bin/bash /defaults/startwm.sh &
|
||||
PID=$!
|
||||
|
||||
18
open-sse/executors/browserExecutableCheck.ts
Normal file
18
open-sse/executors/browserExecutableCheck.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Shared classification for browser-backed executors: distinguishes a missing Playwright
|
||||
* Chromium binary (`chromium.launch: Executable doesn't exist at ...`) from a transient upstream
|
||||
* fault. This is a host/config problem, not something a retry loop can fix, so executors must
|
||||
* NOT surface it as a plain retryable 5xx (which marks the account unavailable / trips the
|
||||
* provider circuit breaker). Originally added for `gemini-web.ts` (#3516); extracted here so
|
||||
* every browser-backed executor (Gemini Web, Z.ai Web, ...) can share the same detection.
|
||||
*/
|
||||
export function isMissingBrowserExecutable(message: string): boolean {
|
||||
if (!message) return false;
|
||||
const lower = message.toLowerCase();
|
||||
return (
|
||||
lower.includes("executable doesn't exist") ||
|
||||
lower.includes("executablenotfound") ||
|
||||
lower.includes("playwright install") ||
|
||||
(lower.includes("chromium") && lower.includes("download"))
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
import { BaseExecutor, type ExecuteInput } from "./base.ts";
|
||||
import { buildErrorBody, sanitizeErrorMessage } from "../utils/error.ts";
|
||||
import { isMissingBrowserExecutable } from "./browserExecutableCheck.ts";
|
||||
import { normalizeGeminiCookieInput } from "../utils/geminiCookies.ts";
|
||||
import { prepareToolMessages } from "../translator/webTools.ts";
|
||||
import { buildToolModeResponse } from "./chatgptWebTools.ts";
|
||||
@@ -27,22 +28,12 @@ import {
|
||||
|
||||
const GEMINI_URL = "https://gemini.google.com/app";
|
||||
|
||||
/**
|
||||
* Whether an error came from Playwright failing to launch because the browser binary is not
|
||||
* installed (`chromium.launch: Executable doesn't exist at ...`). This is a host/config
|
||||
* problem, not a transient upstream fault, so the executor must NOT surface it as a retryable
|
||||
* 500 (which marks the account unavailable and loops / trips the provider breaker). See #3516.
|
||||
*/
|
||||
export function isMissingBrowserExecutable(message: string): boolean {
|
||||
if (!message) return false;
|
||||
const lower = message.toLowerCase();
|
||||
return (
|
||||
lower.includes("executable doesn't exist") ||
|
||||
lower.includes("executablenotfound") ||
|
||||
lower.includes("playwright install") ||
|
||||
(lower.includes("chromium") && lower.includes("download"))
|
||||
);
|
||||
}
|
||||
// Re-exported for backward compatibility: some tests/callers import this classification helper
|
||||
// from gemini-web.ts, its original home (#3516). The implementation now lives in
|
||||
// browserExecutableCheck.ts so other browser-backed executors (e.g. zai-web.ts, #13232) can
|
||||
// share it without importing this whole executor module.
|
||||
export { isMissingBrowserExecutable } from "./browserExecutableCheck.ts";
|
||||
|
||||
const GEMINI_USER_AGENT =
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
makeZaiChunkEmitter,
|
||||
} from "./zai-web/stream.ts";
|
||||
import { browserBackedChat } from "../services/browserBackedChat.ts";
|
||||
import { isMissingBrowserExecutable } from "./browserExecutableCheck.ts";
|
||||
import { CursorImageError, resolveCursorImages } from "../utils/cursorImages.ts";
|
||||
import {
|
||||
makeExecutorErrorResult as makeErrorResult,
|
||||
@@ -424,9 +425,26 @@ export class ZaiWebExecutor extends BaseExecutor {
|
||||
try {
|
||||
result = await browserBackedChat(buildZaiBrowserChatOptions({ ...input, attachments }));
|
||||
} catch (error) {
|
||||
const message = sanitizeErrorMessage(
|
||||
error instanceof Error ? error.message : "browser transport unavailable"
|
||||
);
|
||||
const rawMessage = error instanceof Error ? error.message : "browser transport unavailable";
|
||||
// #13232: a missing Playwright browser binary is a host/config problem, not a transient
|
||||
// upstream fault (same class as #3516 in gemini-web.ts). Surface an actionable message and
|
||||
// tag it with the connection-cooldown hint so accountFallback skips the whole-provider
|
||||
// circuit breaker (502/500 would trip it) and applies a short, non-exponential cooldown
|
||||
// instead.
|
||||
if (isMissingBrowserExecutable(rawMessage)) {
|
||||
return {
|
||||
errorResult: makeErrorResult(
|
||||
503,
|
||||
"Z.ai requires the Playwright Chromium browser, which is not installed. " +
|
||||
"Run `npx playwright install chromium` on the host (or rebuild the Docker image " +
|
||||
"with browsers).",
|
||||
input.body,
|
||||
ZAI_CHAT_URL,
|
||||
{ "X-Omni-Fallback-Hint": "connection_cooldown" }
|
||||
),
|
||||
};
|
||||
}
|
||||
const message = sanitizeErrorMessage(rawMessage);
|
||||
return {
|
||||
errorResult: makeErrorResult(
|
||||
502,
|
||||
|
||||
@@ -1134,7 +1134,8 @@ export function makeExecutorErrorResult(
|
||||
status: number,
|
||||
message: string,
|
||||
body: unknown,
|
||||
url: string
|
||||
url: string,
|
||||
extraResponseHeaders?: Record<string, string>
|
||||
) {
|
||||
return {
|
||||
response: new Response(
|
||||
@@ -1145,7 +1146,10 @@ export function makeExecutorErrorResult(
|
||||
code: `HTTP_${status}`,
|
||||
},
|
||||
}),
|
||||
{ status, headers: { "Content-Type": "application/json" } }
|
||||
{
|
||||
status,
|
||||
headers: { "Content-Type": "application/json", ...extraResponseHeaders },
|
||||
}
|
||||
),
|
||||
url,
|
||||
headers: {} as Record<string, string>,
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import http from "node:http";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PROXY_SCRIPT = path.resolve(
|
||||
__dirname,
|
||||
"../../docker/chatgpt-web-codex-browser/cdp-proxy.mjs"
|
||||
);
|
||||
const UPSTREAM_PORT = 9222; // upstreamPort in cdp-proxy.mjs
|
||||
const PROXY_PORT = 9223; // listenPort in cdp-proxy.mjs
|
||||
const TOKEN = "test-secret-token-13679";
|
||||
const TOKEN_HEADER = "X-Omni-Cdp-Token";
|
||||
|
||||
// #13679 item #9: docker/chatgpt-web-codex-browser/cdp-proxy.mjs republishes
|
||||
// Chromium's loopback CDP (127.0.0.1:9222) onto 0.0.0.0:9223 with NO auth
|
||||
// check at all — unlike the sibling docker/vnc-browser/chromium/cdp-bridge.py,
|
||||
// which requires an `X-Omni-Cdp-Token` header once CDP_BRIDGE_TOKEN is set
|
||||
// (#12571). This proves cdp-proxy.mjs must gate requests the same way once an
|
||||
// operator opts in via CDP_PROXY_TOKEN.
|
||||
|
||||
function waitForListening(server: http.Server): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once("listening", () => resolve());
|
||||
server.once("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function waitForProxyReady(child: ChildProcess): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() => reject(new Error("cdp-proxy.mjs did not report ready in time")),
|
||||
5000
|
||||
);
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
if (chunk.toString("utf8").includes("listening on")) {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
child.once("error", (err) => {
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
});
|
||||
child.once("exit", (code) => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error(`cdp-proxy.mjs exited early with code ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function startUpstream(): Promise<{ server: http.Server; receivedAnyRequest: () => boolean }> {
|
||||
let received = false;
|
||||
const server = http.createServer((_req, res) => {
|
||||
received = true;
|
||||
// Real Chromium CDP responses always carry Content-Length (never
|
||||
// chunked) — match that here, since cdp-proxy.mjs blindly re-adds a
|
||||
// computed content-length on top of whatever the upstream sent.
|
||||
const body = "{}";
|
||||
res.writeHead(200, {
|
||||
"content-type": "application/json",
|
||||
"content-length": String(body.length),
|
||||
});
|
||||
res.end(body);
|
||||
});
|
||||
return waitForListening(server.listen(UPSTREAM_PORT, "127.0.0.1")).then(() => ({
|
||||
server,
|
||||
receivedAnyRequest: () => received,
|
||||
}));
|
||||
}
|
||||
|
||||
function startProxy(): ChildProcess {
|
||||
return spawn(process.execPath, [PROXY_SCRIPT], {
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
env: { ...process.env, CDP_PROXY_TOKEN: TOKEN },
|
||||
});
|
||||
}
|
||||
|
||||
function requestProxy(headers: Record<string, string>): Promise<number | null> {
|
||||
return new Promise((resolve) => {
|
||||
const req = http.request(
|
||||
{ host: "127.0.0.1", port: PROXY_PORT, path: "/json/version", method: "GET", headers },
|
||||
(res) => {
|
||||
res.resume();
|
||||
resolve(res.statusCode ?? null);
|
||||
}
|
||||
);
|
||||
req.on("error", () => resolve(null));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
test("cdp-proxy.mjs must reject a request with no CDP token once CDP_PROXY_TOKEN is set (#13679)", async () => {
|
||||
const upstream = await startUpstream();
|
||||
const proxy = startProxy();
|
||||
|
||||
try {
|
||||
await waitForProxyReady(proxy);
|
||||
const status = await requestProxy({});
|
||||
|
||||
assert.notEqual(
|
||||
status,
|
||||
200,
|
||||
"cdp-proxy.mjs forwarded an unauthenticated request straight through to Chromium's CDP " +
|
||||
"port even though CDP_PROXY_TOKEN was set — the proxy has no auth gate at all"
|
||||
);
|
||||
assert.equal(
|
||||
upstream.receivedAnyRequest(),
|
||||
false,
|
||||
"cdp-proxy.mjs must not forward the request upstream before checking the CDP token"
|
||||
);
|
||||
} finally {
|
||||
proxy.kill("SIGKILL");
|
||||
await new Promise<void>((resolve) => upstream.server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
test("cdp-proxy.mjs forwards the request once the caller presents the configured token (#13679)", async () => {
|
||||
const upstream = await startUpstream();
|
||||
const proxy = startProxy();
|
||||
|
||||
try {
|
||||
await waitForProxyReady(proxy);
|
||||
const status = await requestProxy({ [TOKEN_HEADER]: TOKEN });
|
||||
|
||||
assert.equal(
|
||||
status,
|
||||
200,
|
||||
"cdp-proxy.mjs should forward the request once the caller presents the correct " +
|
||||
"CDP_PROXY_TOKEN"
|
||||
);
|
||||
assert.equal(upstream.receivedAnyRequest(), true);
|
||||
} finally {
|
||||
proxy.kill("SIGKILL");
|
||||
await new Promise<void>((resolve) => upstream.server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
@@ -1,99 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const REPO_ROOT = path.resolve(import.meta.dirname, "../..");
|
||||
|
||||
// #13679 item #9: docker-compose.yml has no top-level `networks:` key, so
|
||||
// Compose puts every service (redis, qdrant, bifrost, cliproxyapi,
|
||||
// codex-app-server, chatgpt-web-codex-browser, ...) on the same implicit
|
||||
// default bridge network. The chatgpt-web-codex-browser sidecar exposes an
|
||||
// UNAUTHENTICATED CDP proxy on 9223 (docker/chatgpt-web-codex-browser/cdp-proxy.mjs)
|
||||
// — any compromised sibling container on that shared bridge can reach it and
|
||||
// take full control of the live browser session. It must be isolated onto a
|
||||
// dedicated network shared only with the one legitimate consumer
|
||||
// (omniroute-web).
|
||||
|
||||
function readCompose(): string {
|
||||
return fs.readFileSync(path.join(REPO_ROOT, "docker-compose.yml"), "utf8");
|
||||
}
|
||||
|
||||
function serviceBlock(compose: string, serviceName: string): string {
|
||||
const lines = compose.split("\n");
|
||||
const startIndex = lines.findIndex((line) => new RegExp(`^ ${serviceName}:\\s*$`).test(line));
|
||||
assert.notEqual(startIndex, -1, `service '${serviceName}' not found in docker-compose.yml`);
|
||||
const rest = lines.slice(startIndex + 1);
|
||||
const endOffset = rest.findIndex((line) => /^ \S/.test(line) || /^\S/.test(line));
|
||||
const block = endOffset === -1 ? rest : rest.slice(0, endOffset);
|
||||
return block.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* The service's `networks:` entries, read line by line. A regex over the whole
|
||||
* block would need nested quantifiers (`(\s*-\s*.*\n)*`), which CodeQL flags as
|
||||
* a ReDoS risk (js/redos) — and the line walk is easier to read anyway.
|
||||
*/
|
||||
function listedNetworks(serviceYaml: string): string[] {
|
||||
const lines = serviceYaml.split("\n");
|
||||
const start = lines.findIndex((line) => /^\s*networks:\s*$/.test(line));
|
||||
if (start === -1) return [];
|
||||
const names: string[] = [];
|
||||
for (const line of lines.slice(start + 1)) {
|
||||
const item = line.match(/^\s*-\s*(\S+)\s*$/);
|
||||
if (!item) break;
|
||||
names.push(item[1]);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
test("docker-compose.yml declares a dedicated network for the CDP proxy sidecar", () => {
|
||||
const compose = readCompose();
|
||||
assert.match(
|
||||
compose,
|
||||
/^networks:\s*$/m,
|
||||
"docker-compose.yml must declare a top-level `networks:` key — without it every " +
|
||||
"service shares the implicit default bridge, so any sibling container can reach " +
|
||||
"the unauthenticated chatgpt-web-codex-browser CDP proxy on 9223"
|
||||
);
|
||||
});
|
||||
|
||||
test("chatgpt-web-codex-browser is isolated off the shared default network", () => {
|
||||
const compose = readCompose();
|
||||
const block = serviceBlock(compose, "chatgpt-web-codex-browser");
|
||||
assert.match(
|
||||
block,
|
||||
/networks:/,
|
||||
"chatgpt-web-codex-browser must declare an explicit `networks:` list — otherwise it " +
|
||||
"attaches to the implicit default network shared with redis/qdrant/bifrost/etc."
|
||||
);
|
||||
assert.ok(
|
||||
!listedNetworks(block).includes("default"),
|
||||
"chatgpt-web-codex-browser must not also list `default` — that would put it right back " +
|
||||
"on the shared bridge with every unrelated sibling container"
|
||||
);
|
||||
});
|
||||
|
||||
test("omniroute-web (the one legitimate CDP consumer) stays reachable via the dedicated network", () => {
|
||||
const compose = readCompose();
|
||||
const block = serviceBlock(compose, "omniroute-web");
|
||||
assert.match(
|
||||
block,
|
||||
/networks:/,
|
||||
"omniroute-web must explicitly join the dedicated CDP-proxy network to keep reaching " +
|
||||
"chatgpt-web-codex-browser:9223 after the sidecar is isolated off the default network"
|
||||
);
|
||||
});
|
||||
|
||||
test("unrelated sidecars (redis, qdrant) are not put on the CDP-proxy network", () => {
|
||||
const compose = readCompose();
|
||||
for (const serviceName of ["redis", "qdrant", "bifrost"]) {
|
||||
const block = serviceBlock(compose, serviceName);
|
||||
assert.doesNotMatch(
|
||||
block,
|
||||
/chatgpt-web-codex/,
|
||||
`${serviceName} must not reference the chatgpt-web-codex-browser network — it has no ` +
|
||||
"legitimate reason to reach the CDP proxy sidecar"
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const REPO_ROOT = path.resolve(import.meta.dirname, "../..");
|
||||
const SVC_DE_RUN = path.join(REPO_ROOT, "docker/vnc-browser/chromium/svc-de-run");
|
||||
|
||||
// #13679 item #10 (residual gap): svc-de-run unconditionally spawns
|
||||
// cdp-bridge.py on every container start, even though CDP_BRIDGE_TOKEN may be
|
||||
// unset (in which case cdp-bridge.py's has_valid_token() fails closed for
|
||||
// every caller anyway, per #12571) — the process still binds 0.0.0.0:9223
|
||||
// and accepts+drops connections for no reason. Gate the spawn behind the
|
||||
// token actually being configured so an unconfigured container does not run
|
||||
// a debug listener at all.
|
||||
|
||||
function readScript(): string {
|
||||
return fs.readFileSync(SVC_DE_RUN, "utf8");
|
||||
}
|
||||
|
||||
test("svc-de-run only starts the CDP bridge when CDP_BRIDGE_TOKEN is configured", () => {
|
||||
const script = readScript();
|
||||
const lines = script.split("\n");
|
||||
const launchIndexes = lines
|
||||
.map((line, index) => ({ line, index }))
|
||||
.filter(({ line }) => !/^\s*#/.test(line) && line.includes("cdp-bridge.py"))
|
||||
.map(({ index }) => index);
|
||||
|
||||
assert.ok(
|
||||
launchIndexes.length >= 2,
|
||||
"expected the wayland and X11 branches to both still launch cdp-bridge.py"
|
||||
);
|
||||
|
||||
const GUARD_WINDOW = 5;
|
||||
for (const index of launchIndexes) {
|
||||
const precedingLines = lines.slice(Math.max(0, index - GUARD_WINDOW), index).join("\n");
|
||||
assert.match(
|
||||
precedingLines,
|
||||
/if\s*\[\s*-n\s*"\$\{CDP_BRIDGE_TOKEN/,
|
||||
`svc-de-run must only launch cdp-bridge.py inside an "if [ -n \\"\${CDP_BRIDGE_TOKEN...` +
|
||||
`\\" ]" guard, but found an unconditional launch at line ${index + 1}: ${lines[index].trim()}`
|
||||
);
|
||||
}
|
||||
});
|
||||
83
tests/unit/zai-web-missing-browser-executable-13232.test.ts
Normal file
83
tests/unit/zai-web-missing-browser-executable-13232.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Regression for GitHub issue #13232 — "[BUG] Z.ai web error".
|
||||
*
|
||||
* The Z.ai web transport drives a real headed Chromium browser (via Playwright) to get past
|
||||
* Z.ai's CAPTCHA. When the local Playwright Chromium binary is missing,
|
||||
* `browserType.launch()` throws "Executable doesn't exist at ...". Before this fix, zai-web.ts
|
||||
* had no classification for that failure and surfaced it as a plain 502 with no fallback hint —
|
||||
* a status that trips the whole-provider circuit breaker (`AGENTS.md` → "Provider Circuit
|
||||
* Breaker") as if the upstream itself were failing, instead of applying the intended
|
||||
* host/config connection cooldown. This mirrors the exact failure class already handled for
|
||||
* Gemini Web in #3516 (`isMissingBrowserExecutable`, now shared via
|
||||
* `open-sse/executors/browserExecutableCheck.ts`).
|
||||
*/
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
const mod = await import("../../open-sse/executors/zai-web.ts");
|
||||
|
||||
const TEST_TOKEN = `e30.${Buffer.from(JSON.stringify({ id: "user-123" })).toString("base64url")}.sig`;
|
||||
|
||||
describe("issue #13232 — Z.ai browser transport classifies a missing Chromium install", () => {
|
||||
let emptyBrowsersDir: string;
|
||||
let originalBrowsersPath: string | undefined;
|
||||
|
||||
before(() => {
|
||||
emptyBrowsersDir = fs.mkdtempSync(path.join(os.tmpdir(), "playwright-empty-"));
|
||||
originalBrowsersPath = process.env.PLAYWRIGHT_BROWSERS_PATH;
|
||||
// Force chromium.launch() to genuinely fail with the exact class of error the reporter hit
|
||||
// ("Executable doesn't exist at ..."), without touching any real ~/.cache/ms-playwright
|
||||
// install.
|
||||
process.env.PLAYWRIGHT_BROWSERS_PATH = emptyBrowsersDir;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
if (originalBrowsersPath === undefined) {
|
||||
delete process.env.PLAYWRIGHT_BROWSERS_PATH;
|
||||
} else {
|
||||
process.env.PLAYWRIGHT_BROWSERS_PATH = originalBrowsersPath;
|
||||
}
|
||||
fs.rmSync(emptyBrowsersDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it(
|
||||
"returns a classified 503 + X-Omni-Fallback-Hint: connection_cooldown instead of a bare " +
|
||||
"502 (contrast: gemini-web.ts isMissingBrowserExecutable, #3516)",
|
||||
async () => {
|
||||
const executor = new mod.ZaiWebExecutor();
|
||||
const body = { model: "glm-5.3-flash", messages: [{ role: "user", content: "hi" }] };
|
||||
const result = await executor.execute({
|
||||
model: "glm-5.3-flash",
|
||||
body,
|
||||
stream: false,
|
||||
credentials: { apiKey: TEST_TOKEN },
|
||||
signal: null,
|
||||
});
|
||||
|
||||
assert.ok("response" in result, "expected an error Response, not a stream result");
|
||||
const response = (result as { response: Response }).response;
|
||||
const payload = (await response.json()) as { error?: { message?: string } };
|
||||
|
||||
assert.equal(
|
||||
response.status,
|
||||
503,
|
||||
"zai-web must classify a missing local Chromium install as a host/config error (503), " +
|
||||
"not a generic retryable 502 that trips the whole-provider circuit breaker."
|
||||
);
|
||||
assert.equal(
|
||||
response.headers.get("X-Omni-Fallback-Hint"),
|
||||
"connection_cooldown",
|
||||
"the connection-cooldown hint must be set so accountFallback applies a short cooldown " +
|
||||
"instead of tripping the provider circuit breaker."
|
||||
);
|
||||
assert.match(
|
||||
payload.error?.message ?? "",
|
||||
/Playwright Chromium browser.*not installed.*npx playwright install chromium/s
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user