fix(account-management): clean removal, dead-fallback re-login indicator, OAuth-add label, refresh-error classification#99
Open
iceteaSA wants to merge 2 commits into
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 focused account-management fixes: orphaned per-account state is now pruned on full save (preventing stale tokens from merging onto a re-added same-id account); a new
needsReauthfield surfaces permanently-dead fallback OAuth tokens in the sidebar with are-loginindicator;add-oauth-finishnow accepts--labelso OAuth accounts no longer display as raw UUIDs; and refresh errors gain explicitstatus/permanentfields soisPermanentRefreshErrorcorrectly distinguishes dead tokens (400 invalid_grant) from transient backoffs without relying on a delay heuristic.accounts.ts): the full-save path now dropsstate.accounts.<id>entries for any id absent fromstorage.accounts, eliminating the stale-state merge hazard on re-add.needsReauthindicator (sidebar-state.ts,index.ts,tui.tsx): computed fromlastRefreshError && refreshBackoffActive && isPermanentRefreshError; rendered asre-login/errtone inAccountBlockand OR'd into the sidebar'sdegradedsignal.command-dialogs.tsx,account.ts): a newopenOAuthLabelPromptstep is inserted between code-entry and submission; back-navigation now goes URL screen ← code prompt ← label prompt rather than bailing to L1, preserving the live PKCE session.Confidence Score: 5/5
Safe to merge — all four fixes are tightly scoped, each has a RED→GREEN test, and the orthogonal
permanent/statusround-trip coverage guards the most fragile migration path.The changes are well-bounded: the orphan-pruning only touches the full-save branch, the
needsReauthfield defaults tofalseso existing serialized state is safe, and the error-classification logic is protected by explicitpermanentchecks that take precedence over the legacy heuristic. No auth or data-loss regressions are introduced.No files require special attention; test coverage across the four fixes is thorough.
Important Files Changed
status/permanentfields onAccountOperationError,buildRefreshOperationErrorfix to only setpermanent=truefor 400invalid_grant, and the newisPermanentRefreshErrorhelper with a three-level precedence chain. Logic is correct and round-trip-tested.--labelparsing toadd-oauth-finishvia a greedy regex that handles multi-word labels and the opaque OAuth code/state payload. USAGE_TEXT is not updated to document the new flag.needsReauthfromlastRefreshError && refreshBackoffActive && isPermanentRefreshErrorand threads it intoSidebarAccountState; also assignslabelfrom the newadd-oauth-finish --labelaction.needsReauth: booleantoSidebarAccountStatewith a safe default-false normalization innormalizeSidebarState.needsReauthintoAccountBlock(re-loginstatus word,errtone), adds aneedsReauth()derived signal onQuotaSidebar, and ORs it into thedegradedindicator.openOAuthLabelPromptstep between code entry and submission; fixes back-navigation so cancelling goes to the URL screen (code step) rather than L1, preventing PKCE session invalidation.Sequence Diagram
%%{init: {'theme': 'neutral'}}%% sequenceDiagram participant U as User participant D as Dialog participant P as parseAccountCommandAction participant A as AnthropicAuthPlugin participant S as Sidebar U->>D: add-oauth-start D->>D: openOAuthUrlScreen(oauthUrl) U->>D: confirm → openOAuthCodePrompt(oauthUrl) U->>D: paste code → openOAuthLabelPrompt(code, oauthUrl) U->>D: enter label (optional) D->>P: "add-oauth-finish {code} [--label {label}]" P-->>A: "{ type: add-oauth-finish, code, label? }" A->>A: create OAuthAccount (randomUUID id, label) A->>S: "SidebarAccountState { needsReauth: false }" Note over A,S: On 400 invalid_grant refresh error A->>A: "buildRefreshOperationError → permanent=true" A->>S: "SidebarAccountState { needsReauth: true }" S-->>U: AccountBlock shows re-login (err tone)%%{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 U as User participant D as Dialog participant P as parseAccountCommandAction participant A as AnthropicAuthPlugin participant S as Sidebar U->>D: add-oauth-start D->>D: openOAuthUrlScreen(oauthUrl) U->>D: confirm → openOAuthCodePrompt(oauthUrl) U->>D: paste code → openOAuthLabelPrompt(code, oauthUrl) U->>D: enter label (optional) D->>P: "add-oauth-finish {code} [--label {label}]" P-->>A: "{ type: add-oauth-finish, code, label? }" A->>A: create OAuthAccount (randomUUID id, label) A->>S: "SidebarAccountState { needsReauth: false }" Note over A,S: On 400 invalid_grant refresh error A->>A: "buildRefreshOperationError → permanent=true" A->>S: "SidebarAccountState { needsReauth: true }" S-->>U: AccountBlock shows re-login (err tone)Comments 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 (2): Last reviewed commit: "fix(account-management): only classify 4..." | Re-trigger Greptile