From d765d03cb7f980794c409c38e6ea5e25d89fde33 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:35:52 +0530 Subject: [PATCH] fix(server): a Claude 403 no longer tells you to log in again (#15872) Co-authored-by: PR Batch Tester Co-authored-by: Claude Opus 5.5 (1M context) --- .../Adapters/ClaudeAdapterV2.test.ts | 43 +++++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 15 ++++--- 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 8b8eabe8fc9df6796761b51dcde21608d47a3c31..427e69dcc56aee8335bb95f30c4e464a9b0f8f80 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -3197,6 +3197,49 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect("shows a 403 permission error instead of the login hint", () => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-permission-denied"), + text: "Continue.", + attachments: [], + }), + ); + // Claude CLI 2.1.288 labels a 403 authentication_failed too. + const cliText = + "Failed to authenticate. API Error: 403 Your organization does not have access to this model."; + yield* Queue.offerAll(harness.sdkMessages, [ + makeAssistantErrorFrame({ + uuid: "00000000-0000-4000-8000-000000000608", + error: "authentication_failed", + }), + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000609", + result: cliText, + isError: true, + apiErrorStatus: 403, + terminalReason: "api_error", + }), + ]); + + const terminal = yield* Queue.take(harness.terminalReceipts); + assert.equal(terminal.status, "failed"); + if (terminal.status !== "failed") return; + assert.equal(terminal.failure.message, cliText); + assert.equal(terminal.failure.code, "api_error_403"); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + it.effect.each([ { recovered: false, expected: "Claude usage limit reached" }, { recovered: true, expected: "Claude gave up after repeated API errors" }, diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index b27e060fb4b32d7f7cfcfcddd05715fa8c46502f..aa80b43ad9b8f0cbf72743af82726f373b7cd786 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -7676,11 +7676,16 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( (message.terminal_reason == null || message.terminal_reason === "api_error" || message.terminal_reason === "blocking_limit"); - const failureHint = - context.authenticationFailureMessage ?? - (usageLimited - ? "Claude usage limit reached. Send the message again once the limit resets." - : undefined); + // The CLI also labels a 403 authentication_failed. Logging in again + // can't grant a permission, so show the CLI's own text instead. + const permissionDenied = + message.subtype === "success" && message.api_error_status === 403; + const failureHint = permissionDenied + ? message.result + : (context.authenticationFailureMessage ?? + (usageLimited + ? "Claude usage limit reached. Send the message again once the limit resets." + : undefined)); const resetTimes = Array.from(context.rateLimitResetTimes.values()); const resetAt = resetTimes.length > 0 && resetTimes.every((time) => time !== null) -- 2.54.0