Skip to content

fix(account-management): clean removal, dead-fallback re-login indicator, OAuth-add label, refresh-error classification#99

Merged
ualtinok merged 2 commits into
cortexkit:mainfrom
iceteaSA:fix/account-management
Jun 26, 2026
Merged

fix(account-management): clean removal, dead-fallback re-login indicator, OAuth-add label, refresh-error classification#99
ualtinok merged 2 commits into
cortexkit:mainfrom
iceteaSA:fix/account-management

Conversation

@iceteaSA

@iceteaSA iceteaSA commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Four account-management fixes.

Fixes

1. Prune orphaned per-account state on removal (core/accounts.ts)
saveAccountStateUnlocked only pruned removed-account state on a scoped save; the removal path does a full save, so the state.accounts.<id> block (tokens/quota/refresh-error) was left orphaned in anthropic-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 in storage.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 by getUsableFallbackAccounts, so under fallback-first routing it silently degrades to main with no signal. New required needsReauth field on SidebarAccountState, computed from lastRefreshError && refreshBackoffActive && isPermanentRefreshError, rendered as a re-login status 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 --label to add-oauth-finish + a label prompt in the modal (collected at the code step). The account id stays a randomUUID() (OAuth has no natural key); only the display label is set.

4. Classify refresh errors with an explicit permanent flag (core/accounts.ts)
AccountOperationError gains status + permanent (set at construction: permanent = status === 400 for invalid_grant). isPermanentRefreshError precedence: explicit permanentstatus === 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 needsReauth indicator 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 explicit permanent flag (keyed on 400 invalid_grant) to avoid misclassifying transient failures as dead tokens.

  • State pruning (accounts.ts): the full-save path now drops any state.accounts.<id> block whose id is absent from storage.accounts, fixing the orphan-on-removal bug.
  • needsReauth indicator (sidebar-state.ts, index.ts, tui.tsx): computed from lastRefreshError && refreshBackoffActive && isPermanentRefreshError; rendered as re-login in the sidebar and clears automatically after re-login via refresh-token hash keying.
  • Label + permanent classification (commands/account.ts, tui/command-dialogs.tsx, accounts.ts): --label added to add-oauth-finish; permanent flag set exclusively for 400 invalid_grant (body + message check), preserved across save/load by normalizeOperationError, with a three-tier isPermanentRefreshError precedence 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.ts for the isInvalidGrant message-fallback check and packages/core/src/commands/account.ts for the undocumented --label flag in USAGE_TEXT.

Important Files Changed

Filename Overview
packages/core/src/accounts.ts Four surgical changes: adds status/permanent to AccountOperationError, preserves them across save/load in normalizeOperationError, prunes orphaned per-account state on full-save, and exports the new isPermanentRefreshError helper with a three-tier precedence chain. Logic is sound and well-tested; minor concern about the message-based isInvalidGrant fallback being too broad.
packages/core/src/commands/account.ts Adds --label parsing to add-oauth-finish using a greedy regex that correctly handles multi-word labels and code-first ordering. USAGE_TEXT not updated to document the new flag, which will leave CLI users unaware of the option.
packages/opencode/src/index.ts Wires needsReauth into sidebar state using the correct three-condition guard (lastRefreshError != null && refreshBackoffActive && isPermanentRefreshError) and threads action.label into the new OAuth account object. Clean and correct.
packages/opencode/src/sidebar-state.ts Adds needsReauth: boolean to SidebarAccountState and normalizes it safely (defaults to false on missing/non-boolean). Straightforward and correct.
packages/opencode/src/tui.tsx Adds needsReauth prop to AccountBlock, shows re-login/err tone when set, and includes it in the degraded sidebar signal. All three call-sites correctly pass the new prop.
packages/opencode/src/tui/command-dialogs.tsx Inserts an openOAuthLabelPrompt step between code entry and finish, improves cancel navigation to step back rather than jump to L1, and threads oauthUrl through 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.
packages/opencode/src/tests/accounts.test.ts Comprehensive tests for buildRefreshOperationError and isPermanentRefreshError covering 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
Loading
%%{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+status
Loading

Comments Outside Diff (1)

  1. packages/opencode/src/tui/command-dialogs.tsx, line 575-580 (link)

    P2 Cancelling the label dialog loses the already-entered OAuth code

    onCancel in 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-trigger add-oauth-start and 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

…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.
Comment thread packages/core/src/accounts.ts Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 to buildL1, so a retry requires re-running add-oauth-start and 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, treating permanent: status === 400 as a dead-token signal can misclassify valid retryable token-endpoint failures (for example non-invalid_grant 400s), 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
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/opencode/src/tui/command-dialogs.tsx Outdated
Comment thread packages/core/src/accounts.ts Outdated
…ly dead; preserve OAuth session when cancelling the label prompt
@ualtinok ualtinok merged commit 6286f4b into cortexkit:main Jun 26, 2026
4 checks passed
@iceteaSA iceteaSA deleted the fix/account-management branch June 30, 2026 14:16
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.

2 participants