fix: surface permanent OAuth refresh failures instead of silent provider disconnect#763
Open
Kenmege wants to merge 3 commits into
Open
fix: surface permanent OAuth refresh failures instead of silent provider disconnect#763Kenmege wants to merge 3 commits into
Kenmege wants to merge 3 commits into
Conversation
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Problem: When the daemon's OAuth token refresh hits a permanent failure (
401/403, or400withinvalid_client/invalid_grant/unauthorized_client),TokenRefreshManagerdisconnects the provider and deletes its token record and keychain entry silently — the permanent branch has noprocessLog(the transient branch logs), and the deletion erases every trace. The user's next interaction reportsNo provider connectedwith 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
TokenRefreshManageris per-process) — leaves a stale refresh token whose next use returnsinvalid_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:
TokenRefreshManagerpermanent-failure branch now logs symmetrically with the transient branch (provider id, HTTP status, OAuth error code) and passes disconnect details through.withProviderDisconnected(id, details?)— withdetails, the provider entry is retained as a tombstone carryinglastDisconnect { at, reason, errorCode?, statusCode? }; withoutdetails(manual disconnect), behavior is unchanged (entry fully removed).isProviderConnectedtreats a tombstoned entry as not connected; reconnecting replaces the entry, clearing the tombstone.clearStaleProviderConfigskips tombstoned entries so the recorded reason survives daemon restarts (their credentials are intentionally gone; re-disconnecting would erase the tombstone).ProviderDTO+ theprovider:listhandler surfacelastDisconnect, so provider views can render when/why the disconnect happened and hint the exact reconnect command (brv providers connect <id> --oauthfor OAuth entries —authMethodis already on the DTO).model:setActiveandprovider:setActivenow reject a tombstoned provider with the existingProvider "<id>" is not connectederror instead of re-activating a provider whose credentials were deliberately dropped. (byteroverkeeps its independentisByteRoverAuthSatisfied()gate and is exempt from the config check, mirroringresolveProviderConfig'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
ProviderDTO)Linked issues
None filed — happy to open one if you'd like this tracked separately.
Root cause
token-refresh-manager.tscatch-branch forisPermanentOAuthError: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 inexplicableNo 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,isProviderConnectedsemantics, manual disconnect unchanged.test/unit/infra/storage/file-provider-config-store.test.ts(new) —lastDisconnectround-trips through save/load; legacy configs without the field load unchanged.test/unit/infra/provider/provider-config-resolver.test.ts—clearStaleProviderConfigpreserves tombstones across restarts.test/unit/infra/transport/handlers/provider-handler.test.ts/model-handler.test.ts—provider:listsurfaceslastDisconnect;setActiveon 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 test— 8924 passing, 16 pending, 0 failing.User-visible changes
[TokenRefreshManager] Permanent refresh failure for <id> (status 400, invalid_grant): disconnecting provider.provider:listDTO includeslastDisconnectfor disconnected-with-reason providers (renderable by the provider views).Provider "<id>" is not connectederror 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 connectedwith no history.After: same deletions of dead credentials, plus a session-log line, a durable
lastDisconnecttombstone visible toprovider:list, andsetActiveprotection — reconnecting via the normal connect flow clears the tombstone (covered by tests listed above).Risks and mitigations
lastDisconnectis additive and optional; legacyproviders.jsonfiles load unchanged (round-trip tested), andfromJsontolerates its absence/presence.isProviderConnectedgates the handlers that matter, and refresh on a tombstone is a no-op (token record is already gone → early return; no loop).Checklist
npm test: 8924 passing)npm run lint(0 errors) /npm run typecheck/npm run buildmainat time of submissionDeveloped with AI assistance (cross-reviewed by two independent model families over three review rounds); all changes verified against the full local gate.