Skip to content

fix: surface permanent OAuth refresh failures instead of silent provider disconnect#763

Open
Kenmege wants to merge 3 commits into
campfirein:mainfrom
Kenmege:fix/surface-permanent-oauth-refresh-failure
Open

fix: surface permanent OAuth refresh failures instead of silent provider disconnect#763
Kenmege wants to merge 3 commits into
campfirein:mainfrom
Kenmege:fix/surface-permanent-oauth-refresh-failure

Conversation

@Kenmege

@Kenmege Kenmege commented Jul 14, 2026

Copy link
Copy Markdown

Summary

Problem: When the daemon's OAuth token refresh hits a permanent failure (401/403, or 400 with invalid_client/invalid_grant/unauthorized_client), TokenRefreshManager disconnects the provider and deletes its token record and keychain entry silently — the permanent branch has no processLog (the transient branch logs), and the deletion erases every trace. The user's next interaction reports No provider connected with zero evidence of what happened, when, or why.

This is reachable through benign conditions, not just revocation: providers with rotating refresh tokens (e.g. OpenAI) rotate on every refresh, so a crash between the server-side rotation and the client persisting the new token — or two processes racing a refresh (the in-flight dedup in TokenRefreshManager is per-process) — leaves a stale refresh token whose next use returns invalid_grant → silent wipe. We hit exactly this in the field: a previously connected OpenAI OAuth provider vanished with nothing in the logs and no on-disk trace.

Why: Deleting dead credentials is correct — they're unusable server-side. Deleting the evidence is the bug: users can't distinguish "never connected" from "connection was dropped an hour ago because the token was rejected," and there's nothing to tell them the fix is a one-command reconnect.

What changed:

  • TokenRefreshManager permanent-failure branch now logs symmetrically with the transient branch (provider id, HTTP status, OAuth error code) and passes disconnect details through.
  • withProviderDisconnected(id, details?) — with details, the provider entry is retained as a tombstone carrying lastDisconnect { at, reason, errorCode?, statusCode? }; without details (manual disconnect), behavior is unchanged (entry fully removed).
  • isProviderConnected treats a tombstoned entry as not connected; reconnecting replaces the entry, clearing the tombstone.
  • clearStaleProviderConfig skips tombstoned entries so the recorded reason survives daemon restarts (their credentials are intentionally gone; re-disconnecting would erase the tombstone).
  • ProviderDTO + the provider:list handler surface lastDisconnect, so provider views can render when/why the disconnect happened and hint the exact reconnect command (brv providers connect <id> --oauth for OAuth entries — authMethod is already on the DTO).
  • model:setActive and provider:setActive now reject a tombstoned provider with the existing Provider "<id>" is not connected error instead of re-activating a provider whose credentials were deliberately dropped. (byterover keeps its independent isByteRoverAuthSatisfied() gate and is exempt from the config check, mirroring resolveProviderConfig's existing special-case; it has no OAuth-refresh path and can never be tombstoned.)

What did NOT change: dead token records and keychain keys are still deleted on permanent failure; manual disconnects still remove the entry entirely; no new transport events; no changes to the transient path. The provider-list rendering lives out-of-tree (@campfirein/byterover-packages), so this PR surfaces the data on the DTO — happy to follow up there with the actual rendered line if that's welcome.

Type of change

Bug fix (behavioral: silent state loss → visible, durable, actionable).

Scope

  • LLM Providers
  • Server / Daemon
  • Shared (additive optional field on ProviderDTO)

Linked issues

None filed — happy to open one if you'd like this tracked separately.

Root cause

token-refresh-manager.ts catch-branch for isPermanentOAuthError: disconnectProvider → tokenStore.delete → keychain.deleteApiKey, all with swallowed errors, no logging, no persisted reason. Combined with refresh-token rotation, a single lost persist or cross-process race manifests later as an inexplicable No provider connected.

Test plan

  • test/unit/infra/provider-oauth/token-refresh-manager.test.ts — permanent-failure test now asserts the disconnect details are plumbed; transient behavior unchanged.
  • test/unit/core/domain/entities/provider-config.test.ts — tombstone creation, reconnect-clears-tombstone, isProviderConnected semantics, manual disconnect unchanged.
  • test/unit/infra/storage/file-provider-config-store.test.ts (new) — lastDisconnect round-trips through save/load; legacy configs without the field load unchanged.
  • test/unit/infra/provider/provider-config-resolver.test.tsclearStaleProviderConfig preserves tombstones across restarts.
  • test/unit/infra/transport/handlers/provider-handler.test.ts / model-handler.test.tsprovider:list surfaces lastDisconnect; setActive on a tombstoned provider is rejected and does not mutate active state; healthy-provider paths still work.

Local gate: npm run lint (0 errors), npm run typecheck, npm run build, npm test8924 passing, 16 pending, 0 failing.

User-visible changes

  • Session log gains a line on permanent refresh failure: [TokenRefreshManager] Permanent refresh failure for <id> (status 400, invalid_grant): disconnecting provider.
  • provider:list DTO includes lastDisconnect for disconnected-with-reason providers (renderable by the provider views).
  • Explicitly setting a tombstoned provider active now returns the existing Provider "<id>" is not connected error instead of silently producing an active-but-disconnected configuration.

Evidence

Before: permanent refresh failure → provider entry, token record, and keychain key all deleted; no log line; next CLI use shows No provider connected with no history.
After: same deletions of dead credentials, plus a session-log line, a durable lastDisconnect tombstone visible to provider:list, and setActive protection — reconnecting via the normal connect flow clears the tombstone (covered by tests listed above).

Risks and mitigations

  • Config compatibility: lastDisconnect is additive and optional; legacy providers.json files load unchanged (round-trip tested), and fromJson tolerates its absence/presence.
  • Tombstone leakage: every direct consumer of the providers map was swept; isProviderConnected gates the handlers that matter, and refresh on a tombstone is a no-op (token record is already gone → early return; no loop).
  • byterover exemption: its authorization is checked independently before the exemption; it cannot be tombstoned (no OAuth-refresh path).

Checklist

  • Tests added/updated and passing (npm test: 8924 passing)
  • npm run lint (0 errors) / npm run typecheck / npm run build
  • Conventional Commits
  • Up to date with main at time of submission

Developed with AI assistance (cross-reviewed by two independent model families over three review rounds); all changes verified against the full local gate.

Dr Kennedy Umege and others added 3 commits July 14, 2026 17:13
…der disconnect

A permanent OAuth refresh failure (401/403, or 400 with invalid_client /
invalid_grant / unauthorized_client) disconnected the provider and deleted its
token/keychain records with no processLog and no user-visible trace. With
rotating refresh tokens a single lost persist or race yields invalid_grant, and
the user later finds "no provider connected" with zero evidence of what happened.

- TokenRefreshManager: log the permanent-failure branch symmetrically with the
  transient one (providerId, statusCode, OAuth errorCode) and plumb the reason
  into disconnectProvider so the drop is recorded durably.
- ProviderConfig: add an optional `lastDisconnect` ({at, reason, errorCode?,
  statusCode?}) to the provider entry. withProviderDisconnected(id, details?)
  now keeps a tombstone carrying that record when a reason is given (and clears
  it on reconnect); isProviderConnected treats a tombstoned entry as not
  connected. Omitting details preserves the existing full-removal behavior. Old
  providers.json files load unchanged.
- disconnectProvider gains an optional `details` param across the interface and
  the file store, so existing call sites compile unchanged.
- clearStaleProviderConfig skips already-tombstoned providers so the recorded
  reason survives a daemon restart.
- ProviderDTO/provider:list carry `lastDisconnect` so the providers view can
  show when/why a provider dropped and the reconnect hint, following the same
  path authMethod already takes to the display.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UJL4EwQfe3PYHJsj8i3sL7
Dual review (GPT-5.6-Sol + Opus) of 4ca7b52 flagged two issues:

1. model:setActive and provider:setActive gated on entry existence only, so a
   tombstoned provider (disconnected with a recorded lastDisconnect reason,
   e.g. a permanent OAuth refresh failure) would pass the guard and get
   written back as the active provider — an active-but-disconnected config.
   Both handlers now reject unless the provider is actually connected
   (config.isProviderConnected), reusing the existing
   `Provider "<id>" is not connected` error already used by model-handler.
   byterover is exempted from the new provider-handler check: it is gated
   independently by isByteRoverAuthSatisfied() (see resolveProviderConfig's
   early return for it) and is not necessarily present in config.providers.

2. test/unit/core/domain/entities/provider-config.test.ts used a `!`
   non-null assertion, which the repo's TypeScript conventions forbid outside
   lazy-init singletons. Replaced with an explicit narrowing throw.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UJL4EwQfe3PYHJsj8i3sL7
Round-2 review flagged six `!` non-null assertions introduced by the tests
added in ce8926e (model-handler.test.ts:156,172 and
provider-handler.test.ts:467,482,1004,1023) — the same repo rule already
fixed in provider-config.test.ts. Each `const handler = transport._handlers.get(...)`
now gets an explicit `if (!handler) throw new Error(...)` guard before use,
matching the throw-guard narrowing pattern used elsewhere in this branch,
instead of asserting non-null with `!`.

Swept the full branch diff (1052ac1..HEAD working tree) for any other newly
introduced non-null assertions: none found — these six were the only ones.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UJL4EwQfe3PYHJsj8i3sL7
@Kenmege
Kenmege requested a review from bao-byterover as a code owner July 14, 2026 18:51
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.

1 participant