Compare commits

..

3 Commits

Author SHA1 Message Date
Diego Rodrigues de Sa e Souza
9bd058824b chore: sync release/v3.8.51 into fix/13232-zai-web-missing-browser-executable (base-red fix #13747) 2026-09-15 23:24:21 -03:00
diegosouzapw
a24562ece3 Merge commit '8f55d85d221e8df0b788eab0e598935a1514536a' into fix/13232-zai-web-missing-browser-executable 2026-09-15 23:18:45 -03:00
diegosouzapw
f0d2d34ee5 fix(sse): classify missing Chromium as a Z.ai host/config cooldown (#13232)
The Z.ai web transport drives a real headed Chromium browser (Playwright)
to get past Z.ai's CAPTCHA. When the local Chromium binary is missing,
chromium.launch() throws "Executable doesn't exist at ...", which
zai-web.ts's fetchThroughBrowser catch block wrapped as a plain 502 with
no fallback hint — a status that trips the whole-provider circuit breaker
as if the upstream itself were failing.

gemini-web.ts already classifies this exact failure class for issue
#3516 (isMissingBrowserExecutable). Extracted that helper into a shared
open-sse/executors/browserExecutableCheck.ts (re-exported from
gemini-web.ts for backward compatibility) and applied it to zai-web.ts:
a missing browser now returns 503 + X-Omni-Fallback-Hint:
connection_cooldown with an actionable remediation message, mirroring
the Gemini Web precedent.

Regression test: tests/unit/zai-web-missing-browser-executable-13232.test.ts
2026-09-15 15:10:01 -03:00
12 changed files with 161 additions and 247 deletions

View File

@@ -1 +0,0 @@
- **fix(providers):** GitLab Duo Retest and chat requests now fall back to the public Code Suggestions endpoint for ANY `direct_access` 403 (not only the "direct connections are disabled" tenant-config message), and surface the real upstream error body instead of a generic "Access denied" when both endpoints reject the token (#12958) — thanks @Rahulsharma0810

View File

@@ -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

View 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"))
);
}

View File

@@ -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";

View File

@@ -599,17 +599,12 @@ export class GitlabExecutor extends BaseExecutor {
};
}
// #12958: any direct_access 403 (not only GitLab's exact "direct connections
// are disabled" tenant-config message) is recoverable via the public
// completions fallback — mirrors the 401 branch above and the connection-test
// path's shouldFallbackToPublicCodeSuggestions() contract.
if (response.status === 403 && input.log) {
input.log.warn(
"GITLAB-DUO",
isGitLabDirectAccessDisabled(response.status, bodyText)
? "direct_access exchange rejected (403, direct connections disabled); falling back to public completions endpoint"
: `direct_access exchange rejected (403); falling back to public completions endpoint. Body: ${bodyText.slice(0, 500)}`
);
if (response.status === 403 && !isGitLabDirectAccessDisabled(response.status, bodyText)) {
return {
target: null,
credentials,
errorResponse: toOpenAIError(403, "GitLab Duo direct access scope is unavailable"),
};
}
return {

View File

@@ -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,

View File

@@ -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>,

View File

@@ -239,16 +239,6 @@ function isTokenExpired(connection: any) {
return expiresAt <= Date.now() + buffer;
}
// #12958: GitLab's own `direct_access` 403 JSON body (e.g. `{"error":"insufficient_scope"}`)
// is safe operator-facing diagnostic text — it is not a stack trace and does not echo the
// token — but is capped and stripped of control characters defensively before it reaches
// the stored/surfaced error message, per docs/security/ERROR_SANITIZATION.md.
function sanitizeUpstreamBodyText(bodyText: string): string {
const collapsed = bodyText.replace(/[\r\n\t-]+/g, " ").trim();
const MAX_LENGTH = 300;
return collapsed.length > MAX_LENGTH ? `${collapsed.slice(0, MAX_LENGTH)}` : collapsed;
}
/**
* #10365 / #10499: the real chat path (open-sse/executors/gitlab.ts) treats a rejected
* `direct_access` exchange (401) or an explicitly disabled direct-connections tenant
@@ -654,14 +644,9 @@ export async function testOAuthConnection(
};
}
// #12958: `res.text()` can only be read once — capture it here in the outer
// function scope so the generic bodyText selection below (which used to call
// `res.text()` a second time and silently get "" back, discarding the real
// GitLab error) can reuse the same string instead of re-reading a drained body.
let gitlabDuoDirectAccessBodyText: string | null = null;
if (connection.provider === "gitlab-duo") {
gitlabDuoDirectAccessBodyText = await res.text();
if (shouldFallbackToPublicCodeSuggestions(res.status, gitlabDuoDirectAccessBodyText)) {
const gitlabText = await res.text();
if (shouldFallbackToPublicCodeSuggestions(res.status, gitlabText)) {
const fallbackOk = await probeGitLabDuoPublicFallback(connection, accessToken, timeoutMs);
if (fallbackOk) {
return {
@@ -803,26 +788,14 @@ export async function testOAuthConnection(
// revoked token. (The body is unread here for non-gitlab providers; the guard keeps
// it safe if it was already consumed.) antigravity/agy read any failure body so a
// geo-blocked egress location is labeled with an actionable message instead of a
// generic "API returned 400". gitlab-duo already consumed the body above (`res.text()`
// is single-read) — reuse it instead of re-reading a drained stream (#12958).
// generic "API returned 400".
const bodyText =
connection.provider === "gitlab-duo"
? (gitlabDuoDirectAccessBodyText ?? "")
: res.status === 401 ||
res.status === 403 ||
connection.provider === "antigravity" ||
connection.provider === "agy"
? await res.text().catch(() => "")
: "";
// #12958: surface the real upstream body for a gitlab-duo 403 that also fails the
// public-fallback probe, instead of a generic "Access denied" — the operator needs
// to tell an entitlement/scope failure apart from an instance-config or revoked-token
// one. Trimmed/truncated per docs/security/ERROR_SANITIZATION.md (no stack traces are
// involved; this is GitLab's own JSON error body, capped defensively).
const gitlabDuoAccessDeniedMessage =
connection.provider === "gitlab-duo" && res.status === 403
? `Access denied: ${sanitizeUpstreamBodyText(bodyText)}`
: "Access denied";
res.status === 401 ||
res.status === 403 ||
connection.provider === "antigravity" ||
connection.provider === "agy"
? await res.text().catch(() => "")
: "";
const error = isGeoBlockedError(bodyText)
? "Egress location blocked by Google (User location is not supported). The Cloud Code API is not offered from this server's proxy exit region — route antigravity/agy through a proxy in a supported region (e.g. US/EU) or use a different provider. This is NOT an account problem."
: isAccountDeactivatedMessage(bodyText)
@@ -830,7 +803,7 @@ export async function testOAuthConnection(
: res.status === 401
? "Token invalid or revoked"
: res.status === 403
? gitlabDuoAccessDeniedMessage
? "Access denied"
: `API returned ${res.status}`;
return {

View File

@@ -103,19 +103,16 @@ export function isGitLabDirectAccessDisabled(status: number, bodyText: string):
}
/**
* #10365 / #10499 / #12958: same predicate the chat-path executor
* (open-sse/executors/gitlab.ts) uses to decide whether a failed `direct_access`
* exchange should fall back to the public Code Suggestions completions endpoint
* instead of surfacing a hard error. A rejected exchange (401 — invalid/expired
* direct_access grant) or ANY 403 (an explicitly disabled direct-connections tenant,
* or an entitlement/scope-resolution failure GitLab does not document a distinct
* status for — #12958) both mean "direct mode unavailable, but the public monolith
* endpoint may still work" — never a definitive "the token itself is bad" signal on
* their own. `isGitLabDirectAccessDisabled()` stays available for log/diagnostic
* labeling; it no longer gates this decision.
* #10365 / #10499: same predicate the chat-path executor (open-sse/executors/gitlab.ts)
* uses to decide whether a failed `direct_access` exchange should fall back to the
* public Code Suggestions completions endpoint instead of surfacing a hard error.
* A rejected exchange (401 — invalid/expired direct_access grant) or an explicitly
* disabled direct-connections tenant (403 with the GitLab-specific message) both mean
* "direct mode unavailable, but the public monolith endpoint may still work" — never a
* definitive "the token itself is bad" signal on their own.
*/
export function shouldFallbackToPublicCodeSuggestions(status: number, _bodyText: string): boolean {
return status === 401 || status === 403;
export function shouldFallbackToPublicCodeSuggestions(status: number, bodyText: string): boolean {
return status === 401 || isGitLabDirectAccessDisabled(status, bodyText);
}
/** Headers for a public Code Suggestions completions probe (chat path and connection test). */

View File

@@ -320,55 +320,3 @@ test("GitlabExecutor falls back to the public Code Suggestions endpoint when dir
globalThis.fetch = originalFetch;
}
});
// #12958: an entitlement/scope-resolution 403 (NOT the "direct connections are
// disabled" tenant-config message) must ALSO fall back to the public Code Suggestions
// completions endpoint — previously only that exact message recovered; any other 403
// hard-failed the request even when the same token was accepted by the public endpoint.
test("GitlabExecutor falls back to the public Code Suggestions endpoint on an entitlement-flavored 403 (#12958)", async () => {
const executor = (await getExecutor("gitlab-duo")) as GitlabExecutor;
const originalFetch = globalThis.fetch;
const calls: string[] = [];
globalThis.fetch = async (url) => {
calls.push(String(url));
if (String(url) === "https://gitlab.example.com/api/v4/code_suggestions/direct_access") {
return jsonResponse({ error: "insufficient_scope", scope: "ai_features" }, 403);
}
return jsonResponse({
model: { name: "code-gecko" },
choices: [{ text: "fallback path works" }],
});
};
try {
const result = await executor.execute({
model: "gitlab-duo-code-suggestions",
body: {
messages: [{ role: "user", content: "Say hello" }],
},
stream: false,
credentials: {
accessToken: "oauth-access",
providerSpecificData: {
baseUrl: "https://gitlab.example.com",
},
},
signal: AbortSignal.timeout(10_000),
log: null,
});
assert.deepEqual(calls, [
"https://gitlab.example.com/api/v4/code_suggestions/direct_access",
"https://gitlab.example.com/api/v4/code_suggestions/completions",
]);
const body = (await result.response.json()) as GitLabResponseBody;
assert.equal(body.model, "code-gecko");
assert.match(body.choices[0].message.content, /fallback path/i);
} finally {
globalThis.fetch = originalFetch;
}
});

View File

@@ -1,113 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { testOAuthConnection } from "../../src/app/api/providers/[id]/test/route";
// #12958: the reporter has a valid Duo seat and a configured default namespace, but
// gitlab.com returns an entitlement/scope-resolution 403 from `direct_access` for their
// API-only client. That 403 is NOT the "direct connections are disabled" tenant-config
// message the #10365/#10499 fallback guard recognizes, so the connection test never tries
// the public Code Suggestions fallback (which the reporter proved works with the same
// token) and instead reports the connection unhealthy with a generic "Access denied" that
// discards the real upstream body. These tests lock in the corrected contract: ANY
// direct_access 403 is recoverable via the fallback probe (same as 401 already is), and
// when both endpoints genuinely reject the token, the real upstream body is surfaced.
const DIRECT_ACCESS_URL = "https://gitlab.example.com/api/v4/code_suggestions/direct_access";
const PUBLIC_COMPLETIONS_URL = "https://gitlab.example.com/api/v4/code_suggestions/completions";
function futureExpiresAt(): string {
return new Date(Date.now() + 60 * 60 * 1000).toISOString();
}
function baseConnection(overrides: Record<string, unknown> = {}) {
return {
provider: "gitlab-duo",
authType: "oauth",
accessToken: "oauth-access",
refreshToken: "oauth-refresh",
expiresAt: futureExpiresAt(),
providerSpecificData: { baseUrl: "https://gitlab.example.com" },
...overrides,
};
}
function mockFetch(handler: (url: string, init?: RequestInit) => Response) {
const calls: Array<{ url: string; init?: RequestInit }> = [];
const fn = (async (url: RequestInfo | URL, init?: RequestInit) => {
const u = typeof url === "string" ? url : url instanceof URL ? url.toString() : String(url);
calls.push({ url: u, init });
return handler(u, init);
}) as typeof fetch;
return { fn, calls };
}
test("gitlab-duo Retest does NOT fall back on an entitlement-flavored 403 (#12958)", async (t) => {
const original = globalThis.fetch;
const { fn, calls } = mockFetch((url) => {
if (url === DIRECT_ACCESS_URL) {
return new Response(JSON.stringify({ message: "Access denied" }), {
status: 403,
headers: { "content-type": "application/json" },
});
}
if (url === PUBLIC_COMPLETIONS_URL) {
return new Response(JSON.stringify({ model: { name: "code-gecko" }, choices: [] }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
throw new Error(`Unexpected fetch to ${url}`);
});
globalThis.fetch = fn;
t.after(() => {
globalThis.fetch = original;
});
const result = await testOAuthConnection(baseConnection(), 5000);
assert.equal(
result.valid,
true,
"an entitlement-flavored direct_access 403 must also be verified against the public " +
"completions fallback before declaring the connection unhealthy — same contract as " +
"401 and the 'direct connections are disabled' 403"
);
assert.deepEqual(
calls.map((c) => c.url),
[DIRECT_ACCESS_URL, PUBLIC_COMPLETIONS_URL],
"the fallback probe must be attempted for ANY direct_access 403, not only the exact " +
"'direct connections are disabled' tenant-config message"
);
});
test("gitlab-duo Retest surfaces the real upstream 403 body when BOTH endpoints reject (#12958)", async (t) => {
const original = globalThis.fetch;
const { fn } = mockFetch((url) => {
if (url === DIRECT_ACCESS_URL) {
return new Response(JSON.stringify({ error: "insufficient_scope", scope: "ai_features" }), {
status: 403,
headers: { "content-type": "application/json" },
});
}
if (url === PUBLIC_COMPLETIONS_URL) {
return new Response(JSON.stringify({ message: "Access denied" }), {
status: 403,
headers: { "content-type": "application/json" },
});
}
throw new Error(`Unexpected fetch to ${url}`);
});
globalThis.fetch = fn;
t.after(() => {
globalThis.fetch = original;
});
const result = await testOAuthConnection(baseConnection(), 5000);
assert.equal(result.valid, false);
assert.ok(
result.error && result.error.includes("insufficient_scope"),
`expected the real upstream direct_access body to be surfaced, got: ${JSON.stringify(result.error)}`
);
});

View 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
);
}
);
});