Skip to content

Commit 7f4462e

Browse files
fix(agents): classify auth HTML provider responses (#79900)
Merged via squash. Prepared head SHA: b005134 Co-authored-by: martingarramon <263922628+martingarramon@users.noreply.github.com> Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Reviewed-by: @altaywtf
1 parent 01d95b9 commit 7f4462e

11 files changed

Lines changed: 121 additions & 38 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ Docs: https://docs.openclaw.ai
5656
- 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.
5757
- Exec: keep configured `tools.exec.pathPrepend` entries ahead of user shell startup PATH changes on POSIX gateway runs. (#81403) Thanks @medns.
5858
- Gateway/sessions: allow shared-secret bearer callers to read and stream session history without an explicit scope header. (#81815) Thanks @medns.
59+
- 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.
5960

6061
## 2026.5.20
6162

src/agents/pi-embedded-error-observation.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
buildApiErrorObservationFields,
55
buildTextObservationFields,
66
sanitizeForConsole,
7+
shouldSuppressRawErrorConsoleSuffix,
78
} from "./pi-embedded-error-observation.js";
89

910
const OBSERVATION_BEARER_TOKEN = "sk-redact-test-token";
@@ -182,6 +183,14 @@ describe("buildApiErrorObservationFields", () => {
182183
expect(observed.httpCode).toBe("401");
183184
expect(observed.providerRuntimeFailureKind).toBe("unclassified");
184185
});
186+
187+
it("centralizes raw console suffix suppression for auth failures", () => {
188+
expect(shouldSuppressRawErrorConsoleSuffix("auth_html")).toBe(true);
189+
expect(shouldSuppressRawErrorConsoleSuffix("auth_scope")).toBe(true);
190+
expect(shouldSuppressRawErrorConsoleSuffix("auth_refresh")).toBe(true);
191+
expect(shouldSuppressRawErrorConsoleSuffix("timeout")).toBe(false);
192+
expect(shouldSuppressRawErrorConsoleSuffix(undefined)).toBe(false);
193+
});
185194
});
186195

187196
describe("sanitizeForConsole", () => {

src/agents/pi-embedded-error-observation.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ const OBSERVATION_EXTRA_REDACT_PATTERNS = [
2222
String.raw`"(?:api[-_]?key|api_key)"\s*:\s*"([^"]+)"`,
2323
String.raw`(?:\bCookie\b\s*[:=]\s*[^;=\s]+=|;\s*[^;=\s]+=)([^;\s\r\n]+)`,
2424
];
25+
const RAW_ERROR_CONSOLE_SUPPRESSED_FAILURE_KINDS = new Set<ProviderRuntimeFailureKind>([
26+
"auth_html",
27+
"auth_refresh",
28+
"auth_scope",
29+
]);
2530

2631
function resolveConfiguredRedactPatterns(): string[] {
2732
const configured = readLoggingConfig()?.redactPatterns;
@@ -76,6 +81,14 @@ function redactObservationText(text: string | undefined): string | undefined {
7681
});
7782
}
7883

84+
export function shouldSuppressRawErrorConsoleSuffix(
85+
providerRuntimeFailureKind?: ProviderRuntimeFailureKind,
86+
): boolean {
87+
return providerRuntimeFailureKind
88+
? RAW_ERROR_CONSOLE_SUPPRESSED_FAILURE_KINDS.has(providerRuntimeFailureKind)
89+
: false;
90+
}
91+
7992
function buildObservationFingerprint(params: {
8093
raw: string;
8194
requestId?: string;

src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -342,10 +342,17 @@ describe("formatAssistantErrorText", () => {
342342
);
343343
});
344344

345+
it("returns re-authentication copy for HTML provider 401 auth failures", () => {
346+
const msg = makeAssistantError("401 <!DOCTYPE html><html><body>Unauthorized</body></html>");
347+
expect(formatAssistantErrorText(msg)).toBe(
348+
"Authentication failed at the provider. Re-authenticate and verify your provider credentials and account access.",
349+
);
350+
});
351+
345352
it("returns an HTML-403 auth message for HTML provider auth failures", () => {
346353
const msg = makeAssistantError("403 <!DOCTYPE html><html><body>Access denied</body></html>");
347354
expect(formatAssistantErrorText(msg)).toBe(
348-
"Authentication failed with an HTML 403 response from the provider. Re-authenticate and verify your provider account access.",
355+
"Authentication failed at the provider. Re-authenticate and verify your provider credentials and account access.",
349356
);
350357
});
351358

src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1510,7 +1510,15 @@ describe("classifyProviderRuntimeFailureKind", () => {
15101510
classifyProviderRuntimeFailureKind(
15111511
"403 <!DOCTYPE html><html><body>Access denied</body></html>",
15121512
),
1513-
).toBe("auth_html_403");
1513+
).toBe("auth_html");
1514+
});
1515+
1516+
it("classifies HTML 401 auth failures", () => {
1517+
expect(
1518+
classifyProviderRuntimeFailureKind(
1519+
"401 <!DOCTYPE html><html><body>Unauthorized</body></html>",
1520+
),
1521+
).toBe("auth_html");
15141522
});
15151523

15161524
it("classifies proxy, dns, timeout, schema, sandbox, and replay failures", () => {

src/agents/pi-embedded-helpers/errors.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,7 @@ export type ProviderRuntimeFailureKind =
259259
| "refresh_contention"
260260
| "callback_timeout"
261261
| "callback_validation"
262-
| "auth_html_403"
262+
| "auth_html"
263263
| "upstream_html"
264264
| "proxy"
265265
| "rate_limit"
@@ -976,7 +976,7 @@ export function classifyProviderRuntimeFailureKind(
976976
return "proxy";
977977
}
978978
if (message && isHtmlErrorResponse(message, status)) {
979-
return status === 403 ? "auth_html_403" : "upstream_html";
979+
return status === 401 || status === 403 ? "auth_html" : "upstream_html";
980980
}
981981
const failoverClassification = classifyFailoverSignal({
982982
...normalizedSignal,
@@ -1090,10 +1090,10 @@ export function formatAssistantErrorText(
10901090
);
10911091
}
10921092

1093-
if (providerRuntimeFailureKind === "auth_html_403") {
1093+
if (providerRuntimeFailureKind === "auth_html") {
10941094
return (
1095-
"Authentication failed with an HTML 403 response from the provider. " +
1096-
"Re-authenticate and verify your provider account access."
1095+
"Authentication failed at the provider. " +
1096+
"Re-authenticate and verify your provider credentials and account access."
10971097
);
10981098
}
10991099

src/agents/pi-embedded-helpers/provider-error-patterns.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -210,9 +210,9 @@ describe("Cloudflare / CDN HTML error page classification (#67517)", () => {
210210
);
211211
});
212212

213-
it("classifies 403 HTML runtime failures as auth_html_403", () => {
213+
it("classifies 403 HTML runtime failures as auth_html", () => {
214214
expect(classifyProviderRuntimeFailureKind({ status: 403, message: html403 })).toBe(
215-
"auth_html_403",
215+
"auth_html",
216216
);
217217
});
218218

src/agents/pi-embedded-runner/run/failover-observation.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,17 @@ function firstWarnDetails(warnSpy: { mock: { calls: unknown[][] } }): {
3636
consoleMessage?: string;
3737
model?: string;
3838
provider?: string;
39+
providerRuntimeFailureKind?: string;
40+
rawErrorPreview?: string;
3941
sourceModel?: string;
4042
sourceProvider?: string;
4143
} {
4244
return firstWarnCall(warnSpy)[1] as {
4345
consoleMessage?: string;
4446
model?: string;
4547
provider?: string;
48+
providerRuntimeFailureKind?: string;
49+
rawErrorPreview?: string;
4650
sourceModel?: string;
4751
sourceProvider?: string;
4852
};
@@ -133,4 +137,33 @@ describe("createFailoverDecisionLogger", () => {
133137
expect(firstWarnDetails(warnSpy).consoleMessage).toContain("from=openai/gpt-5.4");
134138
expect(firstWarnDetails(warnSpy).consoleMessage).not.toContain("to=openai/gpt-5.4");
135139
});
140+
141+
it("omits raw HTML auth bodies from consoleMessage for HTML 401 auth failures", () => {
142+
const warnSpy = vi.spyOn(log, "warn").mockImplementation(() => {});
143+
const logDecision = createFailoverDecisionLogger({
144+
stage: "assistant",
145+
runId: "run:auth-html",
146+
rawError: "401 <!DOCTYPE html><html><body>Unauthorized</body></html>",
147+
failoverReason: "auth",
148+
profileFailureReason: "auth",
149+
provider: "openai-codex",
150+
model: "gpt-5.4",
151+
sourceProvider: "openai-codex",
152+
sourceModel: "gpt-5.4",
153+
profileId: "openai-codex:p1",
154+
fallbackConfigured: true,
155+
timedOut: false,
156+
aborted: false,
157+
});
158+
159+
logDecision("rotate_profile");
160+
161+
const observation = firstWarnDetails(warnSpy);
162+
expect(observation.providerRuntimeFailureKind).toBe("auth_html");
163+
expect(observation.rawErrorPreview).toBe(
164+
"401 <!DOCTYPE html><html><body>Unauthorized</body></html>",
165+
);
166+
expect(observation.consoleMessage).not.toContain("rawError=");
167+
expect(observation.consoleMessage).not.toContain("<html>");
168+
});
136169
});

src/agents/pi-embedded-runner/run/failover-observation.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { AuthProfileFailureReason } from "../../auth-profiles.js";
33
import {
44
buildApiErrorObservationFields,
55
sanitizeForConsole,
6+
shouldSuppressRawErrorConsoleSuffix,
67
} from "../../pi-embedded-error-observation.js";
78
import type { FailoverReason } from "../../pi-embedded-helpers.js";
89
import { log } from "../logger.js";
@@ -58,12 +59,9 @@ export function createFailoverDecisionLogger(
5859
return (decision, extra) => {
5960
const observedError = buildApiErrorObservationFields(normalizedBase.rawError);
6061
const safeRawErrorPreview = sanitizeForConsole(observedError.rawErrorPreview);
61-
const shouldSuppressRawErrorConsoleSuffix =
62-
observedError.providerRuntimeFailureKind === "auth_html_403" ||
63-
observedError.providerRuntimeFailureKind === "auth_scope" ||
64-
observedError.providerRuntimeFailureKind === "auth_refresh";
6562
const rawErrorConsoleSuffix =
66-
safeRawErrorPreview && !shouldSuppressRawErrorConsoleSuffix
63+
safeRawErrorPreview &&
64+
!shouldSuppressRawErrorConsoleSuffix(observedError.providerRuntimeFailureKind)
6765
? ` rawError=${safeRawErrorPreview}`
6866
: "";
6967
log.warn("embedded run failover decision", {

src/agents/pi-embedded-subscribe.handlers.lifecycle.test.ts

Lines changed: 35 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -206,28 +206,44 @@ describe("handleAgentEnd", () => {
206206
expect(meta.httpCode).toBe("401");
207207
});
208208

209-
it("omits raw HTML auth bodies from consoleMessage for HTML 403 auth failures", async () => {
210-
const ctx = createContext({
211-
role: "assistant",
212-
stopReason: "error",
213-
provider: "openai-codex",
214-
model: "gpt-5.4",
209+
it.each([
210+
{
215211
errorMessage: "403 <!DOCTYPE html><html><body>Access denied</body></html>",
216-
content: [{ type: "text", text: "" }],
217-
});
212+
expectedError:
213+
"Authentication failed at the provider. Re-authenticate and verify your provider credentials and account access.",
214+
expectedKind: "auth_html",
215+
expectedPreview: "403 <!DOCTYPE html><html><body>Access denied</body></html>",
216+
},
217+
{
218+
errorMessage: "401 <!DOCTYPE html><html><body>Unauthorized</body></html>",
219+
expectedError:
220+
"Authentication failed at the provider. Re-authenticate and verify your provider credentials and account access.",
221+
expectedKind: "auth_html",
222+
expectedPreview: "401 <!DOCTYPE html><html><body>Unauthorized</body></html>",
223+
},
224+
])(
225+
"omits raw HTML auth bodies from consoleMessage for $expectedKind failures",
226+
async ({ errorMessage, expectedError, expectedKind, expectedPreview }) => {
227+
const ctx = createContext({
228+
role: "assistant",
229+
stopReason: "error",
230+
provider: "openai-codex",
231+
model: "gpt-5.4",
232+
errorMessage,
233+
content: [{ type: "text", text: "" }],
234+
});
218235

219-
await handleAgentEnd(ctx);
236+
await handleAgentEnd(ctx);
220237

221-
const meta = firstWarnMeta(ctx);
222-
expect(meta.providerRuntimeFailureKind).toBe("auth_html_403");
223-
expect(meta.rawErrorPreview).toBe("403 <!DOCTYPE html><html><body>Access denied</body></html>");
224-
expect(meta.error).toBe(
225-
"Authentication failed with an HTML 403 response from the provider. Re-authenticate and verify your provider account access.",
226-
);
227-
const consoleMsg = typeof meta.consoleMessage === "string" ? meta.consoleMessage : "";
228-
expect(consoleMsg).not.toContain("rawError=");
229-
expect(consoleMsg).not.toContain("<html>");
230-
});
238+
const meta = firstWarnMeta(ctx);
239+
expect(meta.providerRuntimeFailureKind).toBe(expectedKind);
240+
expect(meta.rawErrorPreview).toBe(expectedPreview);
241+
expect(meta.error).toBe(expectedError);
242+
const consoleMsg = typeof meta.consoleMessage === "string" ? meta.consoleMessage : "";
243+
expect(consoleMsg).not.toContain("rawError=");
244+
expect(consoleMsg).not.toContain("<html>");
245+
},
246+
);
231247

232248
it("keeps non-error run-end logging on debug only", async () => {
233249
const ctx = createContext(undefined);

0 commit comments

Comments
 (0)