fix(accounts): heal corrupted CC rows and collapse duplicates on load
Anthropic accounts auto-imported from Claude Code credentials could end
up with `source="oauth"` and `identity={kind:"legacy",...}` after a
partial write from an older plugin version, even though the row's id
still proved its CC origin (`cc-cc-keychain-*` / `cc-cc-file-*`). Once
the kind diverged from `cc`, identity-based dedup stopped matching the
row against the live CC credential, and CC token rotation broke the
refresh-token fallback too — so every load created a fresh duplicate
row for the same upstream account.
Fix is a load-time repair pass plus targeted downgrade guards:
- src/accounts/repair.ts: new module. `repairCorruptedCCAccounts` heals
rows whose id encodes a CC source by re-attaching the live CC
credential's metadata, falling back to a partial source-restore when
no credential matches, then collapses any rows that resolve to the
same `{kind:"cc",...}` identity. Stats are NOT summed during merge —
both rows represent the same upstream account. The storage-level
`repairStoredCCAccounts` + `loadAccountsWithRepair` variant returns
the dropped row ids so the next save can opt the disk-only union out
of restoring them.
- src/accounts.ts: AccountManager.load runs the repair pass before the
CC auto-import loop so auto-import sees a deduped list. addAccount's
existing-row branch refuses to reshape source/identity/label/email of
any row whose source is `cc-*` or whose id is CC-born when the caller
passes no explicit CC options. saveToDisk threads `#pendingDroppedIds`
through prepareStorageForSave and saveAccounts so the union honors
the intentional removal.
- src/accounts/persistence.ts, src/storage.ts: prepareStorageForSave
and unionAccountsWithDisk now accept an optional `droppedIds` filter.
Disk rows whose id is in the set are not restored as "disk-only"
accounts. Without this filter, the union safety net was re-adding
every collapsed row on the next save, defeating the heal in a loop.
- src/accounts/matching.ts: updateManagedAccountFromStorage prefers an
existing CC source when the disk row's source is not CC, protecting
a healthy in-memory CC row from being downgraded by a malformed disk
row during syncActiveIndexFromDisk.
- src/index.ts: OAuth re-auth callback skips any candidate whose id is
CC-born so a CC-sourced row can never be reshaped to source="oauth".
- src/commands/oauth-flow.ts, src/cli/commands/auth.ts: direct-storage
writers (slash-command login/reauth, CLI cmdLogin/cmdReauth) skip
source/identity/email mutation when the matched row is CC-sourced and
always set explicit source+identity on newly-created rows. cmdList
and cmdStatus use loadAccountsWithRepair so the standalone CLI heals
on every load.
- src/accounts/repair.test.ts: 6 regression tests covering full heal
with a matching live credential, partial heal via single-credential
fallback, duplicate collapse after repair, and the no-downgrade
guards in addAccount with and without explicit cc options.
- src/accounts.test.ts, index.test.ts: existing saveAccounts call
assertions extended with a second `expect.anything()` matcher to
accommodate the new `{droppedIds}` options argument.
Verified: full suite 1097/1097 passing, lsp diagnostics clean on all
changed files. V
Vacbo committed
0e7fd6e1d24ab210443fd472ec24adbee1383c53
Parent: deed641