fix(embedded-agent): classify Cloudflare challenge HTML as upstream failure

* fix: #94432 classify Cloudflare challenge 403 as upstream_html instead of auth_html

* chore: trigger CI re-run for Real behavior proof validation

* fix: align Cloudflare challenge detector with shared challenge markers

Extend CLOUDFLARE_CHALLENGE_RE to also match cdn-cgi/challenge-platform
and challenge-error-text — patterns already recognized by the shared
STANDALONE_HTML_ERROR_HINT_RE in assistant-error-format.ts.

Add regression tests for both new marker variants to ensure coverage.

* fix(embedded-agent): suppress raw Cloudflare HTML in console after upstream_html reclassification

- Add upstream_html to RAW_ERROR_CONSOLE_SUPPRESSED_FAILURE_KINDS so
  Cloudflare challenge 403 pages classified as upstream_html do not
  leak raw HTML into lifecycle/failover console rawError= diagnostics
- Add regression test verifying upstream_html suppression in
  failover observation console messages

Re: #94432

---------

Co-authored-by: lzyyzznl <lzyyzznl@users.noreply.github.com>
Co-authored-by: lizeyu-xydt <lizeyu@xydigit.com>
This commit is contained in:
lizeyu
2026-07-01 18:58:09 +08:00
committed by GitHub
parent 2af2eb2dfb
commit daffe593aa
4 changed files with 83 additions and 1 deletions

View File

@@ -27,6 +27,7 @@ const RAW_ERROR_CONSOLE_SUPPRESSED_FAILURE_KINDS = new Set<ProviderRuntimeFailur
"auth_html",
"auth_refresh",
"auth_scope",
"upstream_html",
]);
function resolveConfiguredRedactPatterns(): string[] {

View File

@@ -453,6 +453,8 @@ const AUTH_INVALID_TOKEN_HINT_RE =
/\bunauthorized\b|\b(?:invalid|incorrect|expired|stale)[_\s-]?api[_\s-]?key\b|\b(?:invalid|incorrect|expired|stale)\s+(?:token|jwt|credential|api[_\s-]?key)\b|\b(?:token|jwt|credential|api[_\s-]?key)\s+(?:is\s+)?(?:invalid|incorrect|expired|stale)\b/i;
const HTML_BODY_RE = /^\s*(?:<!doctype\s+html\b|<html\b)/i;
const HTML_CLOSE_RE = /<\/html>/i;
const CLOUDFLARE_CHALLENGE_RE =
/Enable\s+JavaScript\s+and\s+cookies\s+to\s+continue|cf-browser-verification|__cf_challenge|cdn-cgi\/challenge-platform|challenge-error-text/i;
const PROXY_ERROR_RE =
/\bproxyconnect\b|\bhttps?_proxy\b|\b407\b|\bproxy authentication required\b|\btunnel connection failed\b|\bconnect tunnel\b|\bsocks proxy\b|\bproxy error\b/i;
const DNS_ERROR_RE = /\benotfound\b|\beai_again\b|\bgetaddrinfo\b|\bno such host\b|\bdns\b/i;
@@ -520,6 +522,10 @@ function isHtmlErrorResponse(raw: string, status?: number): boolean {
return HTML_BODY_RE.test(rest) && HTML_CLOSE_RE.test(rest);
}
function isCloudflareChallengeResponse(message: string): boolean {
return CLOUDFLARE_CHALLENGE_RE.test(message);
}
function isTransportHtmlErrorStatus(status: number | undefined): boolean {
return (
status === 408 ||
@@ -1265,6 +1271,13 @@ export function classifyProviderRuntimeFailureKind(
return "proxy";
}
if (message && isHtmlErrorResponse(message, status)) {
// Cloudflare challenge pages block programmatic requests at the CDN layer.
// These are upstream gateway blocks, not authentication failures — surface
// the more accurate "upstream_html" message, which already mentions
// "CDN or gateway (e.g. Cloudflare) blocked the request".
if (status === 403 && isCloudflareChallengeResponse(message)) {
return "upstream_html";
}
return status === 401 || status === 403 ? "auth_html" : "upstream_html";
}
const failoverClassification = classifyFailoverSignal({

View File

@@ -172,6 +172,18 @@ describe("Cloudflare / CDN HTML error page classification (#67517)", () => {
const cloudflareHtml503 =
"<!doctype html><html><head><title>503</title></head>" +
"<body><h1>Service Unavailable</h1><p>Please try again. Rate limit exceeded.</p></body></html>";
const cloudflareChallengeHtml =
"<!doctype html><html><head><title>403 Forbidden</title></head>" +
"<body>Enable JavaScript and cookies to continue." +
"<p>Please stand by, while we are checking your browser...</p></body></html>";
const cloudflareChallengeCdnCgiHtml =
"<!doctype html><html><head><title>403 Forbidden</title></head>" +
'<body><script src="/cdn-cgi/challenge-platform/h/g/orchestrate/chl_page"></script>' +
"<p>Checking your browser...</p></body></html>";
const cloudflareChallengeErrorTextHtml =
"<!doctype html><html><head><title>403 Forbidden</title></head>" +
'<body><span id="challenge-error-text">Enable JavaScript and cookies to continue</span>' +
"<p>Please stand by...</p></body></html>";
const html401 =
"<!doctype html><html><head><title>401 Unauthorized</title></head>" +
"<body><h1>Unauthorized</h1></body></html>";
@@ -226,7 +238,30 @@ describe("Cloudflare / CDN HTML error page classification (#67517)", () => {
);
});
it("classifies 403 HTML runtime failures as auth_html", () => {
it("classifies Cloudflare challenge 403 as upstream_html", () => {
// Cloudflare browser-challenge pages are CDN blocks, not auth failures.
expect(
classifyProviderRuntimeFailureKind({ status: 403, message: cloudflareChallengeHtml }),
).toBe("upstream_html");
});
it("classifies Cloudflare challenge 403 with cdn-cgi/challenge-platform as upstream_html", () => {
// Challenge pages with the challenge platform script path are also CDN blocks.
expect(
classifyProviderRuntimeFailureKind({ status: 403, message: cloudflareChallengeCdnCgiHtml }),
).toBe("upstream_html");
});
it("classifies Cloudflare challenge 403 with challenge-error-text as upstream_html", () => {
// Challenge pages with the challenge-error-text element are also CDN blocks.
expect(
classifyProviderRuntimeFailureKind({
status: 403,
message: cloudflareChallengeErrorTextHtml,
}),
).toBe("upstream_html");
});
it("classifies generic 403 HTML runtime failures as auth_html", () => {
expect(classifyProviderRuntimeFailureKind({ status: 403, message: html403 })).toBe("auth_html");
});

View File

@@ -174,4 +174,37 @@ describe("createFailoverDecisionLogger", () => {
expect(observation.consoleMessage).not.toContain("rawError=");
expect(observation.consoleMessage).not.toContain("<html>");
});
it("omits raw HTML Cloudflare challenge bodies from consoleMessage for upstream_html 403", () => {
const warnSpy = vi.spyOn(log, "warn").mockImplementation(() => {});
const cfChallengeHtml = "403 <!DOCTYPE html><html><head><title>403 Forbidden</title></head>" +
"<body>Enable JavaScript and cookies to continue." +
"<p>Please stand by, while we are checking your browser...</p></body></html>";
const logDecision = createFailoverDecisionLogger({
stage: "assistant",
runId: "run:cf-challenge",
rawError: cfChallengeHtml,
failoverReason: "auth",
profileFailureReason: "auth",
provider: "openai",
model: "gpt-5.4",
sourceProvider: "openai",
sourceModel: "gpt-5.4",
profileId: "openai:p1",
fallbackConfigured: true,
timedOut: false,
aborted: false,
});
logDecision("rotate_profile");
const observation = firstWarnDetails(warnSpy);
// Cloudflare challenge 403 pages classified as upstream_html are CDN
// blocks, not auth failures. Their raw HTML must stay out of console
// failover diagnostics just like auth_html bodies.
expect(observation.providerRuntimeFailureKind).toBe("upstream_html");
expect(observation.rawErrorPreview).toBe(cfChallengeHtml);
expect(observation.consoleMessage).not.toContain("rawError=");
expect(observation.consoleMessage).not.toContain("<html>");
});
});