diff --git a/src/agents/embedded-agent-error-observation.ts b/src/agents/embedded-agent-error-observation.ts index 4ea952e042f0..f43e6d3d1381 100644 --- a/src/agents/embedded-agent-error-observation.ts +++ b/src/agents/embedded-agent-error-observation.ts @@ -27,6 +27,7 @@ const RAW_ERROR_CONSOLE_SUPPRESSED_FAILURE_KINDS = new Set/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(""); + }); });