fix(account-management): clean removal, dead-fallback re-login indicator, OAuth-add label, refresh-error classification#99
Merged
Conversation
…tor, OAuth-add label, refresh-error classification - Prune orphaned per-account state on removal (full-save path skipped the state delete) — orphaned accounts.<id> state would resurface on same-id re-add. - Surface a dead-fallback 'needs re-login' indicator in the sidebar (was silently degrading fallback-first to main with no signal). New needsReauth field gated on refreshBackoffActive && isPermanentRefreshError. - Prompt for a label when adding an OAuth account via /claude-account (was naming it a raw UUID); --label on add-oauth-finish, id stays UUID. - Classify refresh errors with an explicit permanent flag (status===400 invalid_grant = dead → re-login); retry-exhausted/transient errors non-permanent; 24h-delay heuristic kept only as legacy back-compat. status/permanent preserved across save/load (normalizeOperationError) so the round-trip can't undo the classification.
There was a problem hiding this comment.
2 issues found across 11 files
Confidence score: 3/5
- In
packages/opencode/src/tui/command-dialogs.tsx, cancelling at the OAuth label prompt returns users tobuildL1, so a retry requires re-runningadd-oauth-startand can overwrite the in-progress PKCE/session state; merging as-is risks broken or confusing OAuth setup flows — keep cancellation within the current OAuth dialog path (or preserve pending session state) before merging. - In
packages/core/src/accounts.ts, treatingpermanent: status === 400as a dead-token signal can misclassify valid retryable token-endpoint failures (for example non-invalid_grant400s), which may cause unnecessary token invalidation and forced re-authentication — gate permanent failure on error type (e.g.,invalid_grant) rather than HTTP status alone before merging.
Architecture diagram
sequenceDiagram
participant U as User (TUI)
participant D as command‑dialogs.tsx
participant P as parseAccountCommandAction
participant I as index.ts (Plugin)
participant A as accounts.ts
participant S as sidebar‑state.ts
participant V as tui.tsx (AccountBlock)
Note over U,V: OAuth account addition with label (fix 3)
U->>D: add-oauth-start → paste OAuth code
D->>D: openOAuthCodePrompt()
U->>D: submit code
D->>D: NEW: openOAuthLabelPrompt(code)
U->>D: submit label (optional)
D->>P: NEW: "add-oauth-finish {code} --label {label}"
P-->>I: NEW: { type: 'add-oauth-finish', code, label? }
I->>A: create OAuthAccount { id: randomUUID(), label }
A->>A: saveAccounts() full save
A->>A: NEW: prune orphaned state ids not in storage.accounts (fix 1)
A-->>I: success
I-->>D: toast + refresh sidebar
Note over I,V: Sidebar state refresh (fix 2 & 4)
I->>A: refreshBackoffActive() + isPermanentRefreshError()
A-->>I: NEW: needsReauth = (lastRefreshError != null && backoff active && permanent)
I->>S: SidebarAccountState { needsReauth }
S-->>V: render "re‑login" / err tone (fix 2)
Note over A,A: Error classification (fix 4)
A->>A: buildRefreshOperationError()
Note over A: NEW: sets permanent true only if status === 400
A->>A: isPermanentRefreshError()
Note over A: precedence: permanent flag → status === 400 → legacy 24h heuristic
A->>A: normalizeOperationError() preserves status & permanent across save/load
Note over V,V: Fallback degradation
V->>V: CHANGED: degraded() includes any fallback with needsReauth
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
…ly dead; preserve OAuth session when cancelling the label prompt
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.
Four account-management fixes.
Fixes
1. Prune orphaned per-account state on removal (
core/accounts.ts)saveAccountStateUnlockedonly pruned removed-account state on a scoped save; the removal path does a full save, so thestate.accounts.<id>block (tokens/quota/refresh-error) was left orphaned inanthropic-auth-state.json. Invisible until the same id is re-added (e.g. re-login reusing a label-derived id), where the stale state would merge onto the new account. The full-save branch now drops any state-account id not present instorage.accounts.2. Surface a dead-fallback "needs re-login" indicator (
sidebar-state.ts,index.ts,tui.tsx)A fallback whose OAuth refresh token is permanently dead (
invalid_grant) is dropped bygetUsableFallbackAccounts, so under fallback-first routing it silently degrades to main with no signal. New requiredneedsReauthfield onSidebarAccountState, computed fromlastRefreshError && refreshBackoffActive && isPermanentRefreshError, rendered as are-loginstatus in the sidebar. Clears automatically after re-login (keyed on the refresh-token hash).3. Prompt for a label when adding an OAuth account via
/claude-account(core/commands/account.ts,index.ts,tui/command-dialogs.tsx)The in-modal OAuth add built the account with no label, so it displayed as a raw UUID. Adds
--labeltoadd-oauth-finish+ a label prompt in the modal (collected at the code step). The account id stays arandomUUID()(OAuth has no natural key); only the display label is set.4. Classify refresh errors with an explicit
permanentflag (core/accounts.ts)AccountOperationErrorgainsstatus+permanent(set at construction:permanent = status === 400forinvalid_grant).isPermanentRefreshErrorprecedence: explicitpermanent→status === 400→ a legacy 24h-delay heuristic (back-compat only for errors persisted before the field existed). This keeps a transient (429/5xx/retry-exhausted) error from being mis-flagged as "needs re-login". The discriminators are preserved across the save/load round-trip (normalizeOperationError) so a reload can't undo the classification.Verification
Each fix has a RED→GREEN test. Gates green: opencode 731 / pi 44 / e2e 2, typecheck clean, biome lint 0 warnings. Single commit off
main, scoped to account-management.Greptile Summary
Four targeted account-management fixes: orphaned per-account state is now pruned on full-save (preventing stale token/quota data from merging onto a re-added account), a
needsReauthindicator is surfaced in the sidebar when a fallback's refresh token is permanently dead, OAuth accounts added via the modal now prompt for a label, and refresh errors carry an explicitpermanentflag (keyed on400 invalid_grant) to avoid misclassifying transient failures as dead tokens.accounts.ts): the full-save path now drops anystate.accounts.<id>block whose id is absent fromstorage.accounts, fixing the orphan-on-removal bug.needsReauthindicator (sidebar-state.ts,index.ts,tui.tsx): computed fromlastRefreshError && refreshBackoffActive && isPermanentRefreshError; rendered asre-loginin the sidebar and clears automatically after re-login via refresh-token hash keying.permanentclassification (commands/account.ts,tui/command-dialogs.tsx,accounts.ts):--labeladded toadd-oauth-finish;permanentflag set exclusively for400 invalid_grant(body + message check), preserved across save/load bynormalizeOperationError, with a three-tierisPermanentRefreshErrorprecedence chain for back-compat with pre-existing persisted errors.Confidence Score: 5/5
Safe to merge — all four fixes are well-scoped, each backed by a RED→GREEN test, and no production code paths are left unguarded.
The changes are narrowly scoped to account-management: a state-file pruning fix, a new boolean field on the sidebar state, a label argument threaded through the OAuth flow, and a discriminator flag on refresh errors. Each change has direct test coverage including round-trip persistence tests. No regressions were found in the logic; the two observations are stylistic (usage text) and theoretical (message-based substring check).
packages/core/src/accounts.tsfor theisInvalidGrantmessage-fallback check andpackages/core/src/commands/account.tsfor the undocumented--labelflag inUSAGE_TEXT.Important Files Changed
status/permanenttoAccountOperationError, preserves them across save/load innormalizeOperationError, prunes orphaned per-account state on full-save, and exports the newisPermanentRefreshErrorhelper with a three-tier precedence chain. Logic is sound and well-tested; minor concern about themessage-basedisInvalidGrantfallback being too broad.--labelparsing toadd-oauth-finishusing a greedy regex that correctly handles multi-word labels and code-first ordering.USAGE_TEXTnot updated to document the new flag, which will leave CLI users unaware of the option.needsReauthinto sidebar state using the correct three-condition guard (lastRefreshError != null && refreshBackoffActive && isPermanentRefreshError) and threadsaction.labelinto the new OAuth account object. Clean and correct.needsReauth: booleantoSidebarAccountStateand normalizes it safely (defaults tofalseon missing/non-boolean). Straightforward and correct.needsReauthprop toAccountBlock, showsre-login/errtone when set, and includes it in thedegradedsidebar signal. All three call-sites correctly pass the new prop.openOAuthLabelPromptstep between code entry and finish, improves cancel navigation to step back rather than jump to L1, and threadsoauthUrlthrough both prompts. Cancel on the label step returns to the code prompt with an empty value (user must re-enter their code), which is a known limitation noted in previous review comments.buildRefreshOperationErrorandisPermanentRefreshErrorcovering the 400-invalid_grant / 400-invalid_client / 429 / 500 / retry-exhausted cases plus save/load round-trips. Test coverage is thorough.Sequence Diagram
%%{init: {'theme': 'neutral'}}%% sequenceDiagram participant TUI as TUI / command-dialogs participant Plugin as index.ts (Plugin) participant Core as accounts.ts (Core) participant Disk as State File Note over TUI,Disk: Fix 3 — OAuth add with label TUI->>Plugin: add-oauth-start Plugin-->>TUI: oauthUrl TUI->>TUI: openOAuthUrlScreen(oauthUrl) TUI->>TUI: openOAuthCodePrompt(oauthUrl) TUI->>TUI: openOAuthLabelPrompt(code, oauthUrl) TUI->>Plugin: add-oauth-finish code --label work Plugin->>Core: "addAccountPersistent({label:work, id:UUID})" Core->>Disk: saveAccountStateUnlocked (full save) Note over Core,Disk: Fix 1 — Orphan pruning on full save Core->>Disk: drop state.accounts[id] if id not in storage.accounts Note over Plugin,TUI: Fix 2 — needsReauth indicator Plugin->>Core: isPermanentRefreshError(lastRefreshError) Core-->>Plugin: true (400 invalid_grant) Plugin-->>TUI: "SidebarState { needsReauth: true }" TUI->>TUI: AccountBlock shows re-login / err tone Note over Core: Fix 4 — permanent flag classification Core->>Core: buildRefreshOperationError Core->>Core: "permanent = status===400 and isInvalidGrant" Core->>Disk: normalizeOperationError preserves permanent+status%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% sequenceDiagram participant TUI as TUI / command-dialogs participant Plugin as index.ts (Plugin) participant Core as accounts.ts (Core) participant Disk as State File Note over TUI,Disk: Fix 3 — OAuth add with label TUI->>Plugin: add-oauth-start Plugin-->>TUI: oauthUrl TUI->>TUI: openOAuthUrlScreen(oauthUrl) TUI->>TUI: openOAuthCodePrompt(oauthUrl) TUI->>TUI: openOAuthLabelPrompt(code, oauthUrl) TUI->>Plugin: add-oauth-finish code --label work Plugin->>Core: "addAccountPersistent({label:work, id:UUID})" Core->>Disk: saveAccountStateUnlocked (full save) Note over Core,Disk: Fix 1 — Orphan pruning on full save Core->>Disk: drop state.accounts[id] if id not in storage.accounts Note over Plugin,TUI: Fix 2 — needsReauth indicator Plugin->>Core: isPermanentRefreshError(lastRefreshError) Core-->>Plugin: true (400 invalid_grant) Plugin-->>TUI: "SidebarState { needsReauth: true }" TUI->>TUI: AccountBlock shows re-login / err tone Note over Core: Fix 4 — permanent flag classification Core->>Core: buildRefreshOperationError Core->>Core: "permanent = status===400 and isInvalidGrant" Core->>Disk: normalizeOperationError preserves permanent+statusComments Outside Diff (1)
packages/opencode/src/tui/command-dialogs.tsx, line 575-580 (link)onCancelin the label dialog jumps straight back to L1 (buildL1()), discarding the OAuth code the user just pasted. Since OAuth authorization codes are single-use and typically short-lived, the user would need to restart the entire OAuth flow (re-triggeradd-oauth-startand open a new browser session) just because they changed their mind on the label. Going back to the code-entry step instead would be a safer fallback.Reviews (3): Last reviewed commit: "fix(account-management): only classify 4..." | Re-trigger Greptile