Skip to content

Claude Code OAuth expiry: classify authorization failures and recover sign-in inside BB #3337

Description

@t1mdurden

Consolidated recovery scope

This issue now also tracks #838's request to recover from expired Claude credentials inside BB. It covers both authorization-error classification and recovery; closing #838 does not mean automatic login or recovery has shipped.

  • Preserve the provider's authentication-failure category and render an actionable authorization error.
  • Forward the recovery hint to the product UI and expose an equivalent SDK/CLI recovery path.
  • Attempt normal provider-supported credential refresh automatically where it can complete without user input. If refresh fails or interactive login is required, present a user-triggered sign-in action on the execution host and show its outcome. Do not repeatedly prompt or retry failed authentication indefinitely.
  • After successful authentication, make the original thread usable without restarting the BB server; retain the existing provider-session restart behavior.
  • Verify ordinary expiry, revoked refresh credentials, cancelled/failed login, and successful continuation. A titled auth error alone does not satisfy recovery.

The original #838 screenshot already showed a titled authorization failure, so it is evidence for missing recovery rather than for this report's separate untitled-error defect:

Image

Summary

When the Claude Code CLI's OAuth session expires mid-thread, bb shows the CLI's apology text as an ordinary error row with no title and no action, and there is nothing in the app that lets you sign in again. The one machine-readable "this account is signed out" signal bb computes is written to a debug log and discarded. The user is left in a thread that keeps failing the same way with no route out of the app.

Expected: bb recognises the failure as an authorization failure and offers a way to re-authenticate from the thread.

Versions and environment

  • bb 0.42.1, desktop app
  • macOS 26.x (Darwin 25.3.0), Node 22.23.1 when running from source
  • Claude Code CLI 2.1.252
  • Local project checkout environment, primary machine
  • Verified against main at 4ae0a7c893e9922721e0e367c72aa3a1a2990e7c

Steps to reproduce

  1. Let the Claude Code CLI OAuth session expire (or revoke it) so claude cannot refresh it.
  2. Open an existing Claude Code thread in bb and send a turn.
  3. Observe the reply, then look for any way to re-authenticate inside bb.

Smallest faithful automated reproduction, at the exact code path — feed the Claude bridge the assistant error message the CLI actually emits, followed by its result:

// plugins/provider-claude-code/src/delta-translation.test.ts
harness.translate(sdkMessage({
  type: "assistant",
  error: "authentication_failed",
  isApiErrorMessage: true,
  message: { content: [{ type: "text", text: "Failed to authenticate: OAuth session expired and could not be refreshed" }], model: "<synthetic>", stop_reason: "stop_sequence", stop_sequence: "" },
}));
const events = harness.translate(sdkMessage({
  type: "result", subtype: "error_during_execution", is_error: true,
  errors: ["Failed to authenticate: OAuth session expired and could not be refreshed"],
  usage: {}, modelUsage: {},
}));
// actual:   errorInfo.category === "unknown",     providerCode === "error_during_execution"
// expected: errorInfo.category === "unauthorized", providerCode === "authentication_failed"

The exact wire shape above is taken from a real Claude Code transcript ("error": "authentication_failed", "isApiErrorMessage": true, CLI 2.1.252) and matches the fixture the repo's own bridge test already uses at bridge.test.ts#L464-L490.

Expected vs actual

Actual, in the timeline:

Failed to authenticate: OAuth session expired and could not be refreshed

as an untitled error row. No banner, no button, no hint that the account is the problem. The composer stays enabled, so every further message repeats the same failure. The only remedy is to leave bb, run claude /login in a terminal, and come back.

Expected: the row is classified as an authorization failure, and the thread offers a way to sign in again.

Evidence

Two independent places drop the auth signal.

1. The classification is lost between the assistant message and the turn result. The CLI reports the reason on the assistant message (error: "authentication_failed"); the failure category is computed from the result message alone:

2. The authRequired recovery hint is swallowed in the host daemon. The bridge classifies this case correctly and notifies (provider/recovery, kind: "authRequired") at bridge.ts#L1642-L1650. It reaches the runtime intact, and the only production consumer is runtime-manager.ts#L1248-L1259, whose whole body is one logger?.debug(...) call. The adjacent onProcessExit handler forwards through this.options.onEvent; onProviderRecovery never does. Nothing in apps/server or apps/app ever learns the account is signed out.

3. The remedy bb already knows about is never rendered. provider-maintenance.ts#L337 sets loginCommand: "claude /login" on every ProviderHealthResult, and it reaches the client on SystemProviderState. No component in apps/app/src reads it. The only surface that renders unauthenticated / expired at all is UsageLimitsSettingsSection.tsx, as inert prose ("Sign in again, then reload usage.").

What you ruled out

  • Not Claude threads reuse stale CLI process after terminal authentication failure #2097. That one was about a thread reusing a stale CLI process after the user re-authenticated in a terminal; its fix (arming restartBeforeNextTurn) is in and works. This report is about the user never being told what happened and having no way to re-authenticate from inside bb.
  • Not a wedged thread. The composer stays enabled — EmbeddedThreadChat gates on status === "stopping" and provisioning states only. The user can keep typing; every turn just fails the same way.
  • Not an infinite retry loop. The session restart is armed lazily and consumed only on the next user-initiated turn (bridge.ts#L1508-L1509), so the repeat is user-driven.
  • Not fixed by health polling alone. getClaudeProviderHealth reports expired from credentials.expiresAt. A refresh token revoked while expiresAt is still in the future leaves health at ready while every turn fails — which is exactly the "could not be refreshed" wording in the error.

Suggested priority and effort

Medium. It hits every user whose Claude subscription session lapses, which is routine; the workaround (leave the app, run claude /login) is discoverable only if you already know it. Effort is small for the classification fix plus a thread-level sign-in affordance; forwarding the swallowed authRequired hint is a separate, larger change because it is a host-daemon→server wire addition.

AGENT GENERATED

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    partial-reproBug partially reproduced; some claims unverified; see linked reportprovider-claude-codeBuilt-in plugin: provider-claude-codeprovidersCross-provider bridges, models, login

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions