mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-13 11:56:03 +00:00
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:
@@ -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[] {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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>");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user