/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({
diff --git a/src/agents/embedded-agent-helpers/provider-error-patterns.test.ts b/src/agents/embedded-agent-helpers/provider-error-patterns.test.ts
index 562e96037b62..70ad6aeceef4 100644
--- a/src/agents/embedded-agent-helpers/provider-error-patterns.test.ts
+++ b/src/agents/embedded-agent-helpers/provider-error-patterns.test.ts
@@ -172,6 +172,18 @@ describe("Cloudflare / CDN HTML error page classification (#67517)", () => {
const cloudflareHtml503 =
"503" +
"Service Unavailable
Please try again. Rate limit exceeded.
";
+ const cloudflareChallengeHtml =
+ "403 Forbidden" +
+ "Enable JavaScript and cookies to continue." +
+ "Please stand by, while we are checking your browser...
";
+ const cloudflareChallengeCdnCgiHtml =
+ "403 Forbidden" +
+ '' +
+ "Checking your browser...
";
+ const cloudflareChallengeErrorTextHtml =
+ "403 Forbidden" +
+ 'Enable JavaScript and cookies to continue' +
+ "Please stand by...
";
const html401 =
"401 Unauthorized" +
"Unauthorized
";
@@ -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");
});
diff --git a/src/agents/embedded-agent-runner/run/failover-observation.test.ts b/src/agents/embedded-agent-runner/run/failover-observation.test.ts
index 44cca6d056bf..730627c76ae2 100644
--- a/src/agents/embedded-agent-runner/run/failover-observation.test.ts
+++ b/src/agents/embedded-agent-runner/run/failover-observation.test.ts
@@ -174,4 +174,37 @@ describe("createFailoverDecisionLogger", () => {
expect(observation.consoleMessage).not.toContain("rawError=");
expect(observation.consoleMessage).not.toContain("");
});
+
+ it("omits raw HTML Cloudflare challenge bodies from consoleMessage for upstream_html 403", () => {
+ const warnSpy = vi.spyOn(log, "warn").mockImplementation(() => {});
+ const cfChallengeHtml = "403 403 Forbidden" +
+ "Enable JavaScript and cookies to continue." +
+ "Please stand by, while we are checking your browser...
";
+ 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("");
+ });
});