Skip to content

Commit 1a5dffd

Browse files
committed
fix(agents): require positive 401 evidence before auth_invalid_token (review feedback from #77394)
1 parent 45f4674 commit 1a5dffd

2 files changed

Lines changed: 77 additions & 5 deletions

File tree

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

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,53 @@ describe("formatAssistantErrorText", () => {
404404
);
405405
});
406406

407+
it("does not claim HTTP 401 for plain 403 errors that fall through to the generic auth reason (#77394 review)", () => {
408+
// `classifyFailoverClassificationFromHttpStatus` returns the same
409+
// `auth` reason for both `status === 401` AND `status === 403` after
410+
// the billing/`auth_permanent` precedence at line 661 of errors.ts.
411+
// A real 403 (key revoked, scope-missing) without an HTML body or
412+
// `permission_error` JSON falls through to that generic `auth` branch.
413+
// Before the 401-evidence gate, my new `auth_invalid_token` branch
414+
// would pick those up and tell the user "HTTP 401" when the provider
415+
// actually returned 403. The gate now requires `status === 401` or
416+
// (status unknown AND message embeds 401 but not 403); a plain 403
417+
// satisfies neither leg and falls through to the existing copy.
418+
// Pin both directions: the 403 must NOT get the new "HTTP 401" copy,
419+
// and it must still produce a non-empty user-facing message.
420+
const plain403 = makeAssistantError("403 Forbidden");
421+
const friendly = formatAssistantErrorText(plain403);
422+
expect(friendly).toBeDefined();
423+
expect(friendly).not.toContain("HTTP 401");
424+
expect(friendly).not.toBe(
425+
"Authentication failed (provider returned HTTP 401). " +
426+
"Your provider token may have expired — try the request again in a moment. " +
427+
"If the failure persists, re-authenticate this provider.",
428+
);
429+
});
430+
431+
it("does not claim HTTP 401 for message-only auth errors with no HTTP status prefix (#77394 review)", () => {
432+
// `classifyFailoverSignal`'s message-only path at line 848 of errors.ts
433+
// returns the `auth` reason via `isAuthErrorMessage(raw)` for payloads
434+
// that have no leading HTTP status at all — for example a plain
435+
// `{"error":{"code":"invalid_api_key"}}` envelope. Before the 401-
436+
// evidence gate, the new `auth_invalid_token` branch would catch those
437+
// too and surface "HTTP 401" copy when no HTTP status was ever present.
438+
// `status` is `undefined` here and the message contains no `401`, so
439+
// the second leg of the gate (`messageMentions401 && !messageMentions403`)
440+
// is false and the gate falls through. Pin the negative behavior so a
441+
// future widening that drops the gate fails this test rather than
442+
// silently shipping the regression.
443+
const messageOnly = makeAssistantError('{"error":{"code":"invalid_api_key"}}');
444+
const friendly = formatAssistantErrorText(messageOnly);
445+
expect(friendly).toBeDefined();
446+
expect(friendly).not.toContain("HTTP 401");
447+
expect(friendly).not.toBe(
448+
"Authentication failed (provider returned HTTP 401). " +
449+
"Your provider token may have expired — try the request again in a moment. " +
450+
"If the failure persists, re-authenticate this provider.",
451+
);
452+
});
453+
407454
it("returns a proxy-specific message for proxy misroutes", () => {
408455
const msg = makeAssistantError("407 Proxy Authentication Required");
409456
expect(formatAssistantErrorText(msg)).toBe(

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

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -990,14 +990,39 @@ export function classifyProviderRuntimeFailureKind(
990990
// Plain HTTP 401 / "Invalid token" / "Unauthorized" — surface a friendly
991991
// re-auth hint rather than the raw provider error. Distinct from
992992
// `auth_refresh` (explicit OAuth-refresh failure messages) and
993-
// `auth_html_403` (HTML 403 bodies). The `classifyFailoverSignal` path
994-
// at status 401/403 already returns the `auth` reason for non-permanent /
995-
// non-billing 401s, so we reuse it here instead of duplicating the
993+
// `auth_html_403` (HTML 403 bodies). Reuses the existing
994+
// `classifyFailoverSignal` `auth` reason instead of duplicating the
996995
// message regex set. Placed AFTER replay-invalid / schema so 401s with
997996
// structurally-meaningful bodies (e.g. "401 input item ID does not belong
998997
// to this conversation" → `replay_invalid`) keep their existing copy.
999-
// See [Github #56197].
1000-
if (failoverClassification?.kind === "reason" && failoverClassification.reason === "auth") {
998+
//
999+
// The 401-evidence gate is load-bearing: `classifyFailoverSignal` returns
1000+
// the same `auth` reason for two non-401 shapes that must NOT get the
1001+
// "HTTP 401" copy:
1002+
// 1. Plain HTTP 403 fall-through (key revoked, scope-missing) without
1003+
// an HTML body or `permission_error` JSON — same branch at status
1004+
// 401/403 in `classifyFailoverClassificationFromHttpStatus`.
1005+
// 2. Message-only auth errors with no HTTP status prefix at all
1006+
// (e.g. `{"error":{"code":"invalid_api_key"}}`) classified by
1007+
// `isAuthErrorMessage(raw)`.
1008+
// We require positive 401 evidence: either `status === 401` (parsed from
1009+
// a leading HTTP status), or `status` is unknown AND the message embeds
1010+
// `401` at a word boundary while not also embedding `403`. The latter
1011+
// covers real-world variants like `'status code: 401, message: ...'`
1012+
// where `extractLeadingHttpStatus` cannot pick up the code because it
1013+
// is not at the start. Billing 401s (e.g. `{"code":401,"message":"Key
1014+
// limit exceeded"}`) are already caught upstream by the billing reason
1015+
// taking precedence over `auth` in the 401/403 branch, so they never
1016+
// reach this gate. See [Github #56197] and PR #77394 review.
1017+
const messageMentions401 = /\b401\b/.test(message);
1018+
const messageMentions403 = /\b403\b/.test(message);
1019+
const has401Evidence =
1020+
status === 401 || (status === undefined && messageMentions401 && !messageMentions403);
1021+
if (
1022+
failoverClassification?.kind === "reason" &&
1023+
failoverClassification.reason === "auth" &&
1024+
has401Evidence
1025+
) {
10011026
return "auth_invalid_token";
10021027
}
10031028
if (

0 commit comments

Comments
 (0)