mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-30 00:18:44 +00:00
fix(agents): classify auth HTML provider responses (#79900)
Merged via squash.
Prepared head SHA: b00513414d
Co-authored-by: martingarramon <263922628+martingarramon@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
This commit is contained in:
@@ -56,6 +56,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Codex/failover: classify `deactivated_workspace` as a permanent auth failure so configured fallback models can advance when a Codex workspace is deactivated. (#55893) Thanks @litang9.
|
||||
- Exec: keep configured `tools.exec.pathPrepend` entries ahead of user shell startup PATH changes on POSIX gateway runs. (#81403) Thanks @medns.
|
||||
- Gateway/sessions: allow shared-secret bearer callers to read and stream session history without an explicit scope header. (#81815) Thanks @medns.
|
||||
- Agents/embedded runner: classify HTML auth provider responses as `auth_html` and return a re-authentication hint instead of the CDN-blocked copy that `upstream_html` returns. Cloudflare Access login pages, nginx basic-auth challenges, and gateway login walls all produce HTML auth bodies that were previously misdiagnosed as transient CDN blocks. (#79900) Thanks @martingarramon.
|
||||
|
||||
## 2026.5.20
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
buildApiErrorObservationFields,
|
||||
buildTextObservationFields,
|
||||
sanitizeForConsole,
|
||||
shouldSuppressRawErrorConsoleSuffix,
|
||||
} from "./pi-embedded-error-observation.js";
|
||||
|
||||
const OBSERVATION_BEARER_TOKEN = "sk-redact-test-token";
|
||||
@@ -182,6 +183,14 @@ describe("buildApiErrorObservationFields", () => {
|
||||
expect(observed.httpCode).toBe("401");
|
||||
expect(observed.providerRuntimeFailureKind).toBe("unclassified");
|
||||
});
|
||||
|
||||
it("centralizes raw console suffix suppression for auth failures", () => {
|
||||
expect(shouldSuppressRawErrorConsoleSuffix("auth_html")).toBe(true);
|
||||
expect(shouldSuppressRawErrorConsoleSuffix("auth_scope")).toBe(true);
|
||||
expect(shouldSuppressRawErrorConsoleSuffix("auth_refresh")).toBe(true);
|
||||
expect(shouldSuppressRawErrorConsoleSuffix("timeout")).toBe(false);
|
||||
expect(shouldSuppressRawErrorConsoleSuffix(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeForConsole", () => {
|
||||
|
||||
@@ -22,6 +22,11 @@ const OBSERVATION_EXTRA_REDACT_PATTERNS = [
|
||||
String.raw`"(?:api[-_]?key|api_key)"\s*:\s*"([^"]+)"`,
|
||||
String.raw`(?:\bCookie\b\s*[:=]\s*[^;=\s]+=|;\s*[^;=\s]+=)([^;\s\r\n]+)`,
|
||||
];
|
||||
const RAW_ERROR_CONSOLE_SUPPRESSED_FAILURE_KINDS = new Set<ProviderRuntimeFailureKind>([
|
||||
"auth_html",
|
||||
"auth_refresh",
|
||||
"auth_scope",
|
||||
]);
|
||||
|
||||
function resolveConfiguredRedactPatterns(): string[] {
|
||||
const configured = readLoggingConfig()?.redactPatterns;
|
||||
@@ -76,6 +81,14 @@ function redactObservationText(text: string | undefined): string | undefined {
|
||||
});
|
||||
}
|
||||
|
||||
export function shouldSuppressRawErrorConsoleSuffix(
|
||||
providerRuntimeFailureKind?: ProviderRuntimeFailureKind,
|
||||
): boolean {
|
||||
return providerRuntimeFailureKind
|
||||
? RAW_ERROR_CONSOLE_SUPPRESSED_FAILURE_KINDS.has(providerRuntimeFailureKind)
|
||||
: false;
|
||||
}
|
||||
|
||||
function buildObservationFingerprint(params: {
|
||||
raw: string;
|
||||
requestId?: string;
|
||||
|
||||
@@ -342,10 +342,17 @@ describe("formatAssistantErrorText", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("returns re-authentication copy for HTML provider 401 auth failures", () => {
|
||||
const msg = makeAssistantError("401 <!DOCTYPE html><html><body>Unauthorized</body></html>");
|
||||
expect(formatAssistantErrorText(msg)).toBe(
|
||||
"Authentication failed at the provider. Re-authenticate and verify your provider credentials and account access.",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns an HTML-403 auth message for HTML provider auth failures", () => {
|
||||
const msg = makeAssistantError("403 <!DOCTYPE html><html><body>Access denied</body></html>");
|
||||
expect(formatAssistantErrorText(msg)).toBe(
|
||||
"Authentication failed with an HTML 403 response from the provider. Re-authenticate and verify your provider account access.",
|
||||
"Authentication failed at the provider. Re-authenticate and verify your provider credentials and account access.",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1510,7 +1510,15 @@ describe("classifyProviderRuntimeFailureKind", () => {
|
||||
classifyProviderRuntimeFailureKind(
|
||||
"403 <!DOCTYPE html><html><body>Access denied</body></html>",
|
||||
),
|
||||
).toBe("auth_html_403");
|
||||
).toBe("auth_html");
|
||||
});
|
||||
|
||||
it("classifies HTML 401 auth failures", () => {
|
||||
expect(
|
||||
classifyProviderRuntimeFailureKind(
|
||||
"401 <!DOCTYPE html><html><body>Unauthorized</body></html>",
|
||||
),
|
||||
).toBe("auth_html");
|
||||
});
|
||||
|
||||
it("classifies proxy, dns, timeout, schema, sandbox, and replay failures", () => {
|
||||
|
||||
@@ -259,7 +259,7 @@ export type ProviderRuntimeFailureKind =
|
||||
| "refresh_contention"
|
||||
| "callback_timeout"
|
||||
| "callback_validation"
|
||||
| "auth_html_403"
|
||||
| "auth_html"
|
||||
| "upstream_html"
|
||||
| "proxy"
|
||||
| "rate_limit"
|
||||
@@ -976,7 +976,7 @@ export function classifyProviderRuntimeFailureKind(
|
||||
return "proxy";
|
||||
}
|
||||
if (message && isHtmlErrorResponse(message, status)) {
|
||||
return status === 403 ? "auth_html_403" : "upstream_html";
|
||||
return status === 401 || status === 403 ? "auth_html" : "upstream_html";
|
||||
}
|
||||
const failoverClassification = classifyFailoverSignal({
|
||||
...normalizedSignal,
|
||||
@@ -1090,10 +1090,10 @@ export function formatAssistantErrorText(
|
||||
);
|
||||
}
|
||||
|
||||
if (providerRuntimeFailureKind === "auth_html_403") {
|
||||
if (providerRuntimeFailureKind === "auth_html") {
|
||||
return (
|
||||
"Authentication failed with an HTML 403 response from the provider. " +
|
||||
"Re-authenticate and verify your provider account access."
|
||||
"Authentication failed at the provider. " +
|
||||
"Re-authenticate and verify your provider credentials and account access."
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -210,9 +210,9 @@ describe("Cloudflare / CDN HTML error page classification (#67517)", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("classifies 403 HTML runtime failures as auth_html_403", () => {
|
||||
it("classifies 403 HTML runtime failures as auth_html", () => {
|
||||
expect(classifyProviderRuntimeFailureKind({ status: 403, message: html403 })).toBe(
|
||||
"auth_html_403",
|
||||
"auth_html",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ function firstWarnDetails(warnSpy: { mock: { calls: unknown[][] } }): {
|
||||
consoleMessage?: string;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
providerRuntimeFailureKind?: string;
|
||||
rawErrorPreview?: string;
|
||||
sourceModel?: string;
|
||||
sourceProvider?: string;
|
||||
} {
|
||||
@@ -43,6 +45,8 @@ function firstWarnDetails(warnSpy: { mock: { calls: unknown[][] } }): {
|
||||
consoleMessage?: string;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
providerRuntimeFailureKind?: string;
|
||||
rawErrorPreview?: string;
|
||||
sourceModel?: string;
|
||||
sourceProvider?: string;
|
||||
};
|
||||
@@ -133,4 +137,33 @@ describe("createFailoverDecisionLogger", () => {
|
||||
expect(firstWarnDetails(warnSpy).consoleMessage).toContain("from=openai/gpt-5.4");
|
||||
expect(firstWarnDetails(warnSpy).consoleMessage).not.toContain("to=openai/gpt-5.4");
|
||||
});
|
||||
|
||||
it("omits raw HTML auth bodies from consoleMessage for HTML 401 auth failures", () => {
|
||||
const warnSpy = vi.spyOn(log, "warn").mockImplementation(() => {});
|
||||
const logDecision = createFailoverDecisionLogger({
|
||||
stage: "assistant",
|
||||
runId: "run:auth-html",
|
||||
rawError: "401 <!DOCTYPE html><html><body>Unauthorized</body></html>",
|
||||
failoverReason: "auth",
|
||||
profileFailureReason: "auth",
|
||||
provider: "openai-codex",
|
||||
model: "gpt-5.4",
|
||||
sourceProvider: "openai-codex",
|
||||
sourceModel: "gpt-5.4",
|
||||
profileId: "openai-codex:p1",
|
||||
fallbackConfigured: true,
|
||||
timedOut: false,
|
||||
aborted: false,
|
||||
});
|
||||
|
||||
logDecision("rotate_profile");
|
||||
|
||||
const observation = firstWarnDetails(warnSpy);
|
||||
expect(observation.providerRuntimeFailureKind).toBe("auth_html");
|
||||
expect(observation.rawErrorPreview).toBe(
|
||||
"401 <!DOCTYPE html><html><body>Unauthorized</body></html>",
|
||||
);
|
||||
expect(observation.consoleMessage).not.toContain("rawError=");
|
||||
expect(observation.consoleMessage).not.toContain("<html>");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { AuthProfileFailureReason } from "../../auth-profiles.js";
|
||||
import {
|
||||
buildApiErrorObservationFields,
|
||||
sanitizeForConsole,
|
||||
shouldSuppressRawErrorConsoleSuffix,
|
||||
} from "../../pi-embedded-error-observation.js";
|
||||
import type { FailoverReason } from "../../pi-embedded-helpers.js";
|
||||
import { log } from "../logger.js";
|
||||
@@ -58,12 +59,9 @@ export function createFailoverDecisionLogger(
|
||||
return (decision, extra) => {
|
||||
const observedError = buildApiErrorObservationFields(normalizedBase.rawError);
|
||||
const safeRawErrorPreview = sanitizeForConsole(observedError.rawErrorPreview);
|
||||
const shouldSuppressRawErrorConsoleSuffix =
|
||||
observedError.providerRuntimeFailureKind === "auth_html_403" ||
|
||||
observedError.providerRuntimeFailureKind === "auth_scope" ||
|
||||
observedError.providerRuntimeFailureKind === "auth_refresh";
|
||||
const rawErrorConsoleSuffix =
|
||||
safeRawErrorPreview && !shouldSuppressRawErrorConsoleSuffix
|
||||
safeRawErrorPreview &&
|
||||
!shouldSuppressRawErrorConsoleSuffix(observedError.providerRuntimeFailureKind)
|
||||
? ` rawError=${safeRawErrorPreview}`
|
||||
: "";
|
||||
log.warn("embedded run failover decision", {
|
||||
|
||||
@@ -206,28 +206,44 @@ describe("handleAgentEnd", () => {
|
||||
expect(meta.httpCode).toBe("401");
|
||||
});
|
||||
|
||||
it("omits raw HTML auth bodies from consoleMessage for HTML 403 auth failures", async () => {
|
||||
const ctx = createContext({
|
||||
role: "assistant",
|
||||
stopReason: "error",
|
||||
provider: "openai-codex",
|
||||
model: "gpt-5.4",
|
||||
it.each([
|
||||
{
|
||||
errorMessage: "403 <!DOCTYPE html><html><body>Access denied</body></html>",
|
||||
content: [{ type: "text", text: "" }],
|
||||
});
|
||||
expectedError:
|
||||
"Authentication failed at the provider. Re-authenticate and verify your provider credentials and account access.",
|
||||
expectedKind: "auth_html",
|
||||
expectedPreview: "403 <!DOCTYPE html><html><body>Access denied</body></html>",
|
||||
},
|
||||
{
|
||||
errorMessage: "401 <!DOCTYPE html><html><body>Unauthorized</body></html>",
|
||||
expectedError:
|
||||
"Authentication failed at the provider. Re-authenticate and verify your provider credentials and account access.",
|
||||
expectedKind: "auth_html",
|
||||
expectedPreview: "401 <!DOCTYPE html><html><body>Unauthorized</body></html>",
|
||||
},
|
||||
])(
|
||||
"omits raw HTML auth bodies from consoleMessage for $expectedKind failures",
|
||||
async ({ errorMessage, expectedError, expectedKind, expectedPreview }) => {
|
||||
const ctx = createContext({
|
||||
role: "assistant",
|
||||
stopReason: "error",
|
||||
provider: "openai-codex",
|
||||
model: "gpt-5.4",
|
||||
errorMessage,
|
||||
content: [{ type: "text", text: "" }],
|
||||
});
|
||||
|
||||
await handleAgentEnd(ctx);
|
||||
await handleAgentEnd(ctx);
|
||||
|
||||
const meta = firstWarnMeta(ctx);
|
||||
expect(meta.providerRuntimeFailureKind).toBe("auth_html_403");
|
||||
expect(meta.rawErrorPreview).toBe("403 <!DOCTYPE html><html><body>Access denied</body></html>");
|
||||
expect(meta.error).toBe(
|
||||
"Authentication failed with an HTML 403 response from the provider. Re-authenticate and verify your provider account access.",
|
||||
);
|
||||
const consoleMsg = typeof meta.consoleMessage === "string" ? meta.consoleMessage : "";
|
||||
expect(consoleMsg).not.toContain("rawError=");
|
||||
expect(consoleMsg).not.toContain("<html>");
|
||||
});
|
||||
const meta = firstWarnMeta(ctx);
|
||||
expect(meta.providerRuntimeFailureKind).toBe(expectedKind);
|
||||
expect(meta.rawErrorPreview).toBe(expectedPreview);
|
||||
expect(meta.error).toBe(expectedError);
|
||||
const consoleMsg = typeof meta.consoleMessage === "string" ? meta.consoleMessage : "";
|
||||
expect(consoleMsg).not.toContain("rawError=");
|
||||
expect(consoleMsg).not.toContain("<html>");
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps non-error run-end logging on debug only", async () => {
|
||||
const ctx = createContext(undefined);
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
buildApiErrorObservationFields,
|
||||
buildTextObservationFields,
|
||||
sanitizeForConsole,
|
||||
shouldSuppressRawErrorConsoleSuffix,
|
||||
} from "./pi-embedded-error-observation.js";
|
||||
import { classifyFailoverReason, formatAssistantErrorText } from "./pi-embedded-helpers.js";
|
||||
import { hasCommittedMessagingToolDeliveryEvidence } from "./pi-embedded-runner/delivery-evidence.js";
|
||||
@@ -92,12 +93,9 @@ export function handleAgentEnd(ctx: EmbeddedPiSubscribeContext): void | Promise<
|
||||
const safeModel = sanitizeForConsole(lastAssistant.model) ?? "unknown";
|
||||
const safeProvider = sanitizeForConsole(lastAssistant.provider) ?? "unknown";
|
||||
const safeRawErrorPreview = sanitizeForConsole(observedError.rawErrorPreview);
|
||||
const shouldSuppressRawErrorConsoleSuffix =
|
||||
observedError.providerRuntimeFailureKind === "auth_html_403" ||
|
||||
observedError.providerRuntimeFailureKind === "auth_scope" ||
|
||||
observedError.providerRuntimeFailureKind === "auth_refresh";
|
||||
const rawErrorConsoleSuffix =
|
||||
safeRawErrorPreview && !shouldSuppressRawErrorConsoleSuffix
|
||||
safeRawErrorPreview &&
|
||||
!shouldSuppressRawErrorConsoleSuffix(observedError.providerRuntimeFailureKind)
|
||||
? ` rawError=${safeRawErrorPreview}`
|
||||
: "";
|
||||
ctx.log.warn("embedded run agent end", {
|
||||
|
||||
Reference in New Issue
Block a user