Skip to content

Classify Claude auth failures and offer sign-in from the thread - #3338

Open
t1mdurden wants to merge 3 commits into
get-bb:mainfrom
t1mdurden:fix/claude-auth-signin-affordance
Open

Classify Claude auth failures and offer sign-in from the thread#3338
t1mdurden wants to merge 3 commits into
get-bb:mainfrom
t1mdurden:fix/claude-auth-signin-affordance

Conversation

@t1mdurden

Copy link
Copy Markdown
Contributor

Human comments

What was wrong

When a Claude Code OAuth session expires mid-thread, the CLI reports the reason on the assistant message (error: "authentication_failed"), but bb builds the turn's ProviderErrorInfo from the result message alone (delta-translation.ts, the buildClaudeProviderErrorInfo({ resultSubtype }) call). subtype: "error_during_execution" maps to category "unknown", and error-display.ts only uses its titles table when the category is not "unknown" — so the existing string "Provider authorization failed" never fires for the most common authorization failure. The user gets the CLI's apology sentence as an untitled error row, the composer stays enabled, and every further message fails the same way. Nothing in the app reads ProviderHealth.loginCommand ("claude /login"), so there is no way to re-authenticate without leaving bb.

Fixes #3337

What changed

Classification (the bug). plugins/provider-claude-code: the assistant message's error code is now armed on the translator's per-turn state and consumed when the turn's terminal provider.error is built, exactly as armedHardRateLimitRejection already does for a deferred hard rate-limit rejection. It is segment-scoped and cleared on every turn open, turn close and result, so it can never leak into a later turn. authentication_failed therefore reaches ProviderErrorInfo as category: "unauthorized", providerCode: "authentication_failed", and the row title becomes "Provider authorization failed". A new narrow schema (claudeAssistantErrorMessageSchema) parses the code at the boundary; an unrecognised code simply does not arm, matching today's behavior.

Signal (thread-scoped, event-driven). packages/thread-view gains extractThreadTimelineProviderAuthRequired, built the same way as the existing model-fallback extraction: it reports the sequence of the latest terminal unauthorized provider error and clears on the next client/turn/requested. It rides the latest timeline page as providerAuthRequired, alongside modelFallback — a new nullable field on threadTimelineResponseSchema. No host-daemon or provider-bridge wire field changes, so no HOST_DAEMON_PROTOCOL_VERSION bump.

Affordance (the button). ProviderAuthBanner is a new prompt-stack card, a sibling of ProviderCliVersionBanner, rendered in ThreadDetailPromptArea next to ThreadModelFallbackCard. It appears only when the thread's last turn failed with unauthorized. Its data comes from the provider's own health report — useSystemProviderStates is enabled only while the banner is showing, so threads that are fine pay nothing. The button calls the thread's existing terminal path: handleStartTerminal now takes an optional CreateTerminalRequest["start"] and is invoked with { mode: "command", command: loginCommand }, so the user gets a real PTY already running the provider's own login command, in the thread's own environment, and finishes the browser flow there. The interactive part matters: provider.installation.run — the pipe the install/update button uses — has no stdin, so an OAuth code could never be pasted through it.

Because the command string comes from the provider plugin, Cursor's "cursor-agent login" works with no app-side changes. When no terminal can be opened (host disconnected, no environment) the banner drops the button and prints the command instead, rather than offering an action that cannot run.

No new CLI or SDK surface: the signal rides the existing threads.timeline response, and the action is bb terminal create --thread <id> --command "<loginCommand>", which already exists.

How you verified

Tests added; each fails before the change and passes after.

  • plugins/provider-claude-code/src/delta-translation.test.ts — "classifies an expired Claude OAuth session as unauthorized" (fails before: category "unknown"), plus a guard that an assistant error code does not carry into a later turn.
  • packages/thread-view/test/provider-auth-extraction.test.ts — 5 cases: reports, ignores other categories, ignores willRetry, clears on the next turn request, reports again on a repeat failure.
  • apps/app/src/components/promptbox/banner/ProviderAuthBanner.test.tsx — 3 cases: action fires, no-terminal fallback copy, disabled while opening.
  • apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx — banner renders and fires above the composer; absent when authorized.

Commands run:

pnpm exec turbo run typecheck                                    # whole repo, clean
pnpm exec turbo run lint                                         # clean
pnpm exec turbo run test --filter=bb-plugin-provider-claude-code # 362 passed
pnpm exec turbo run test --filter=@bb/thread-view                # 396 passed
pnpm exec turbo run test --filter=@bb/server                     # 2353 passed
pnpm exec turbo run test --filter=@bb/app

One @bb/server test, builtin-plugins.test.ts > hot-reloads a source-layout builtin server…, failed once under parallel load and passes on its own; it is a timing test unrelated to these files.

Not in this change

apps/host-daemon/src/runtime-manager.ts still swallows every provider/recovery hint in a logger.debug call, so the bridge's own authRequired classification never reaches the server. Forwarding it is a host-daemon→server wire addition and needs its own change and protocol bump; it is written up in the issue. Until then one hole remains: a refresh token revoked while expiresAt is still in the future leaves provider.health reporting ready, so the banner's button falls back to printing the login command rather than opening the terminal. The banner itself still appears, because it is driven by the turn failure rather than by health.

AGENT GENERATED

Timmy-Lane added 3 commits September 9, 2026 16:47
The Claude Code CLI reports an expired OAuth session on the assistant
message (error: "authentication_failed"), but the turn's ProviderErrorInfo
was built from the result message alone, whose "error_during_execution"
subtype maps to category "unknown". error-display only titles a row when
the category is known, so the user saw the CLI's apology sentence as an
untitled error with no indication the account was the problem and no way
to re-authenticate without leaving bb.

Arm the assistant error code on the translator's per-turn state and
consume it when the terminal provider.error is built, the same way a
deferred hard rate-limit rejection already works. Surface the resulting
"unauthorized" failure on the latest timeline page as providerAuthRequired,
and render a prompt-stack banner whose button opens a thread terminal
already running the provider's own loginCommand.
Every full ThreadTimelineResponse literal outside the server has to carry
the new field: the demo world, and the CLI, client-core, server and
integration fixtures.
The recorded session now replays with errorInfo on its provider.error:
category "unauthorized", providerCode "authentication_failed". Regenerated
with `pnpm --filter @bb/provider-parity run rerecord --provider claude-code
--cell auth-failure`; the session id churn that rerecord produces is
reverted so the lane diff is the one line that changed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant