Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
74f24ba
Disable invalid account groups instead of removing them
ndycode Mar 13, 2026
5a5d9c7
Preserve manual disables when reviving accounts
ndycode Mar 14, 2026
a5df174
Fix remaining auth disable review gaps
ndycode Mar 14, 2026
09eabd3
Harden disabled account persistence and retries
ndycode Mar 14, 2026
d466201
Clarify enabled account selection logging
ndycode Mar 14, 2026
c8538cf
Fix enabled account selection toast positions
ndycode Mar 14, 2026
208e5b2
Preserve disabled sibling state during auth failure
ndycode Mar 14, 2026
51886eb
Harden save flushing and disable semantics
ndycode Mar 14, 2026
323871e
Drain pending saves without overlapping writes
ndycode Mar 14, 2026
113941f
Cap flush loops and log shutdown save failures
ndycode Mar 14, 2026
2b851cc
Address remaining PR follow-up comments
ndycode Mar 14, 2026
9a7f29b
Flush invalidated account managers on shutdown
ndycode Mar 14, 2026
dd37ad0
Finish PR #78 review follow-ups
ndycode Mar 14, 2026
f78127b
Harden account disable reason handling
ndycode Mar 14, 2026
2cd74ea
Track shutdown cleanup across plugin instances
ndycode Mar 14, 2026
e4421d9
Add flush rejection regression coverage
ndycode Mar 14, 2026
e9b75da
Preserve auth-failure disable locks
ndycode Mar 14, 2026
e421a5c
Fix remaining review regressions
ndycode Mar 14, 2026
e2b313d
Stabilize shutdown signal tests
ndycode Mar 14, 2026
fca1832
chore: retrigger review bots
ndycode Mar 14, 2026
17e819d
Fix disabled-account persistence edge cases
ndycode Mar 14, 2026
943d363
Tighten disabled-account shutdown persistence
ndycode Mar 14, 2026
321a973
Harden storage validation and cleanup tracking
ndycode Mar 14, 2026
fb1461f
Clear stale cooldowns on re-enable
ndycode Mar 14, 2026
f08847f
Harden shutdown signal cleanup
ndycode Mar 14, 2026
b6742d2
Clear cooldowns on menu re-enable
ndycode Mar 14, 2026
067f24c
Harden flush recovery for disabled state
ndycode Mar 15, 2026
8a2f988
Prune idle shutdown manager tracking
ndycode Mar 15, 2026
8da4029
Prune settled managers during cleanup tracking
ndycode Mar 15, 2026
7666837
Fix storage canonicalization and account toggle API
ndycode Mar 15, 2026
e8c0227
Preserve auth-failure disable invariants
ndycode Mar 15, 2026
b51d093
Prune tracked managers after saves settle
ndycode Mar 15, 2026
b27305f
Harden shutdown cleanup and reauth messaging
ndycode Mar 15, 2026
de691ef
Merge branch 'main' into fix/disable-auth-failure-accounts
ndycode Mar 15, 2026
c5add83
Address remaining review follow-ups
ndycode Mar 15, 2026
749eaaf
Tighten shutdown exit ordering
ndycode Mar 15, 2026
2840611
Harden auth-failure save flush follow-ups
ndycode Mar 15, 2026
66fb080
Tighten disabled-account review follow-ups
ndycode Mar 15, 2026
5072f0e
Refine disabled-pool guidance and test doubles
ndycode Mar 15, 2026
8f5f9cf
Harden disabled account cleanup handling
ndycode Mar 15, 2026
b257c2a
Harden shutdown and flush retry coverage
ndycode Mar 15, 2026
31a7427
Fix shutdown and auth-disable follow-ups
ndycode Mar 15, 2026
feb5f29
Harden account save shutdown tracking
ndycode Mar 15, 2026
b979b68
Fix debounced save retry tracking
ndycode Mar 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
281 changes: 237 additions & 44 deletions index.ts

Large diffs are not rendered by default.

299 changes: 277 additions & 22 deletions lib/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { createLogger } from "./logger.js";
import {
loadAccounts,
saveAccounts,
type AccountDisabledReason,
type AccountStorageV3,
type CooldownReason,
type RateLimitStateV3,
Expand Down Expand Up @@ -72,6 +73,10 @@ export type CodexCliTokenCacheEntry = {
accountId?: string;
};

type FlushPendingSaveOptions = {
deadlineMs?: number;
};

const CODEX_CLI_ACCOUNTS_PATH = join(homedir(), ".codex", "accounts.json");
const CODEX_CLI_CACHE_TTL_MS = 5_000;
let codexCliTokenCache: Map<string, CodexCliTokenCacheEntry> | null = null;
Expand Down Expand Up @@ -181,6 +186,7 @@ export interface ManagedAccount {
email?: string;
refreshToken: string;
enabled?: boolean;
disabledReason?: AccountDisabledReason;
access?: string;
expires?: number;
addedAt: number;
Expand All @@ -206,6 +212,12 @@ export interface AccountSelectionExplainability {
lastUsed: number;
}

export type SetAccountEnabledFailureReason = "auth-failure-blocked" | "invalid-index";

export type SetAccountEnabledResult =
| { ok: true; account: ManagedAccount }
| { ok: false; reason: SetAccountEnabledFailureReason };

export class AccountManager {
private accounts: ManagedAccount[] = [];
private cursorByFamily: Record<ModelFamily, number> = initFamilyState(0);
Expand All @@ -214,7 +226,11 @@ export class AccountManager {
private lastToastTime = 0;
private saveDebounceTimer: ReturnType<typeof setTimeout> | null = null;
private pendingSave: Promise<void> | null = null;
private saveRetryNeeded = false;
private authFailuresByRefreshToken: Map<string, number> = new Map();
private pendingSaveSettledWaiters = new Set<() => void>();
private saveFinalizationTick = 0;
private saveFinalizationWaiters = new Set<{ afterTick: number; resolve: () => void }>();

static async loadFromDisk(authFallback?: OAuthAuthDetails): Promise<AccountManager> {
const stored = await loadAccounts();
Expand Down Expand Up @@ -307,8 +323,11 @@ export class AccountManager {
email: matchesFallback
? fallbackAccountEmail ?? sanitizeEmail(account.email)
: sanitizeEmail(account.email),
// Storage only persists `enabled: false`; in memory we normalize to a concrete boolean.
refreshToken,
enabled: account.enabled !== false,
disabledReason:
account.enabled === false ? account.disabledReason ?? "user" : undefined,
access: matchesFallback && authFallback ? authFallback.access : account.accessToken,
expires: matchesFallback && authFallback ? authFallback.expires : account.expiresAt,
addedAt: clampNonNegativeInt(account.addedAt, baseNow),
Expand Down Expand Up @@ -392,6 +411,16 @@ export class AccountManager {
return this.accounts.length;
}

getEnabledAccountCount(): number {
let enabledCount = 0;
for (const account of this.accounts) {
if (account.enabled !== false) {
enabledCount += 1;
}
}
return enabledCount;
}

getActiveIndex(): number {
return this.getActiveIndexForFamily("codex");
}
Expand Down Expand Up @@ -872,6 +901,31 @@ export class AccountManager {
return this.removeAccount(account);
}

/**
* Disable all accounts that share the same refreshToken as the given account.
* This keeps org/workspace variants visible in the pool while preventing reuse.
* @returns Number of accounts newly disabled
*/
disableAccountsWithSameRefreshToken(account: ManagedAccount): number {
const refreshToken = account.refreshToken;
let disabledCount = 0;

for (const accountToDisable of this.accounts) {
if (accountToDisable.refreshToken !== refreshToken) continue;
if (accountToDisable.enabled === false) continue;
this.clearAccountCooldown(accountToDisable);
accountToDisable.enabled = false;
accountToDisable.disabledReason = "auth-failure";
disabledCount++;
}

// Reset any accumulated auth failures for this token, even if matching accounts
// were already manually disabled, so a later manual re-enable starts clean.
this.authFailuresByRefreshToken.delete(refreshToken);

return disabledCount;
}

/**
* Remove all accounts that share the same refreshToken as the given account.
* This is used when auth refresh fails to remove all org variants together.
Expand All @@ -895,16 +949,53 @@ export class AccountManager {
return removedCount;
}

setAccountEnabled(index: number, enabled: boolean): ManagedAccount | null {
if (!Number.isFinite(index)) return null;
if (index < 0 || index >= this.accounts.length) return null;
trySetAccountEnabled(
index: number,
enabled: boolean,
reason?: AccountDisabledReason,
): SetAccountEnabledResult {
if (!Number.isFinite(index)) return { ok: false, reason: "invalid-index" };
if (index < 0 || index >= this.accounts.length) return { ok: false, reason: "invalid-index" };
const account = this.accounts[index];
if (!account) return null;
account.enabled = enabled;
return account;
if (!account) return { ok: false, reason: "invalid-index" };
if (enabled && account.disabledReason === "auth-failure") {
return { ok: false, reason: "auth-failure-blocked" };
}
// Once an account is auth-failure disabled, callers must refresh credentials
// instead of downgrading the reason to a manually re-enableable state.
if (!enabled && account.disabledReason === "auth-failure") {
account.enabled = false;
return { ok: true, account };
}
if (enabled) {
const wasDisabled = account.enabled === false;
account.enabled = true;
if (wasDisabled) {
delete account.disabledReason;
this.clearAccountCooldown(account);
}
} else if (reason) {
account.enabled = false;
account.disabledReason = reason;
} else if (account.disabledReason !== "auth-failure") {
account.enabled = false;
account.disabledReason = "user";
} else {
account.enabled = false;
}
return { ok: true, account };
}

async saveToDisk(): Promise<void> {
setAccountEnabled(
index: number,
enabled: boolean,
reason?: AccountDisabledReason,
): ManagedAccount | null {
const result = this.trySetAccountEnabled(index, enabled, reason);
return result.ok ? result.account : null;
}

private async persistToDisk(): Promise<void> {
const activeIndexByFamily: Partial<Record<ModelFamily, number>> = {};
for (const family of MODEL_FAMILIES) {
const raw = this.currentAccountIndexByFamily[family];
Expand All @@ -926,7 +1017,10 @@ export class AccountManager {
refreshToken: account.refreshToken,
accessToken: account.access,
expiresAt: account.expires,
// Persist enabled accounts by omitting the flag to preserve the storage convention.
enabled: account.enabled === false ? false : undefined,
disabledReason:
account.enabled === false ? account.disabledReason ?? "user" : undefined,
addedAt: account.addedAt,
lastUsed: account.lastUsed,
lastSwitchReason: account.lastSwitchReason,
Expand All @@ -942,21 +1036,101 @@ export class AccountManager {
await saveAccounts(storage);
}

async saveToDisk(): Promise<void> {
return this.enqueueSave(() => this.persistToDisk());
}

private enqueueSave(saveOperation: () => Promise<void>): Promise<void> {
const previousSave = this.pendingSave;
const nextSave = (async () => {
if (previousSave) {
try {
await previousSave;
} catch (error) {
log.warn("Continuing queued save after previous save failure", {
error: error instanceof Error ? error.message : String(error),
});
}
}
try {
await saveOperation();
this.saveRetryNeeded = false;
} catch (error) {
this.saveRetryNeeded = true;
throw error;
}
})().finally(() => {
if (this.pendingSave === nextSave) {
this.pendingSave = null;
}
this.resolvePendingSaveSettledWaiters();
this.notifySaveFinalization();
});
this.pendingSave = nextSave;
return nextSave;
}

hasPendingSave(): boolean {
return this.saveDebounceTimer !== null || this.pendingSave !== null;
}
Comment on lines +1073 to +1075
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Track retry-needed state as pending work.

hasPendingSave() ignores saveRetryNeeded. After a failed save, Line 1074 can return false even though flushPendingSave() still has retry work, which can let manager-settled/prune logic drop an instance before retry/flush.

💡 Suggested fix
 hasPendingSave(): boolean {
-	return this.saveDebounceTimer !== null || this.pendingSave !== null;
+	return this.saveRetryNeeded || this.saveDebounceTimer !== null || this.pendingSave !== null;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
hasPendingSave(): boolean {
return this.saveDebounceTimer !== null || this.pendingSave !== null;
}
hasPendingSave(): boolean {
return this.saveRetryNeeded || this.saveDebounceTimer !== null || this.pendingSave !== null;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/accounts.ts` around lines 1073 - 1075, hasPendingSave() currently returns
true only if saveDebounceTimer or pendingSave are non-null and ignores the
saveRetryNeeded flag, so after a failed save it can wrongly report no pending
work; update the hasPendingSave() method to also check this.saveRetryNeeded
(e.g., return this.saveDebounceTimer !== null || this.pendingSave !== null ||
this.saveRetryNeeded === true) so flushPendingSave()/manager-settled/prune logic
will treat retry-needed state as pending work.


waitForPendingSaveToSettle(): Promise<void> {
if (!this.hasPendingSave()) {
return Promise.resolve();
}

// This intentionally treats debounce-only timers as pending work too. Callers
// such as scheduleTrackedManagerPrune wait across those gaps so a later re-arm
// cannot drop shutdown tracking before the deferred save actually runs. If a
// manager is abandoned before the debounce is replayed through flushPendingSave
// or the timer itself fires, cleanup-time pruning is the final backstop.
return new Promise((resolve) => {
this.pendingSaveSettledWaiters.add(resolve);
});
}

private waitForSaveFinalization(afterTick: number): Promise<void> {
if (this.saveFinalizationTick > afterTick) {
return Promise.resolve();
}

return new Promise((resolve) => {
this.saveFinalizationWaiters.add({ afterTick, resolve });
});
}

private notifySaveFinalization(): void {
this.saveFinalizationTick += 1;
for (const waiter of [...this.saveFinalizationWaiters]) {
if (this.saveFinalizationTick <= waiter.afterTick) {
continue;
}
this.saveFinalizationWaiters.delete(waiter);
waiter.resolve();
}
}

private resolvePendingSaveSettledWaiters(): void {
if (this.hasPendingSave() || this.pendingSaveSettledWaiters.size === 0) {
return;
}

for (const resolve of [...this.pendingSaveSettledWaiters]) {
this.pendingSaveSettledWaiters.delete(resolve);
resolve();
}
}

saveToDiskDebounced(delayMs = 500): void {
this.saveRetryNeeded = true;
if (this.saveDebounceTimer) {
clearTimeout(this.saveDebounceTimer);
}
this.saveDebounceTimer = setTimeout(() => {
this.saveDebounceTimer = null;
const doSave = async () => {
try {
if (this.pendingSave) {
await this.pendingSave;
}
this.pendingSave = this.saveToDisk().finally(() => {
this.pendingSave = null;
});
await this.pendingSave;
await this.saveToDisk();
} catch (error) {
log.warn("Debounced save failed", { error: error instanceof Error ? error.message : String(error) });
}
Expand All @@ -965,14 +1139,95 @@ export class AccountManager {
}, delayMs);
}

async flushPendingSave(): Promise<void> {
if (this.saveDebounceTimer) {
clearTimeout(this.saveDebounceTimer);
this.saveDebounceTimer = null;
await this.saveToDisk();
}
if (this.pendingSave) {
await this.pendingSave;
async flushPendingSave(options?: FlushPendingSaveOptions): Promise<void> {
const MAX_FLUSH_ITERATIONS = 20;
let flushIterations = 0;
const deadlineMs =
typeof options?.deadlineMs === "number" && Number.isFinite(options.deadlineMs)
? options.deadlineMs
: undefined;
const throwIfDeadlineExceeded = (): void => {
if (deadlineMs !== undefined && Date.now() >= deadlineMs) {
throw new Error("flushPendingSave: shutdown deadline exceeded before save state settled");
}
};

while (true) {
throwIfDeadlineExceeded();
flushIterations += 1;
if (flushIterations > MAX_FLUSH_ITERATIONS) {
// This is intentionally far above realistic debounce re-arm chains; if we
// still haven't converged, shutdown callers log the failure and continue exit.
log.warn("flushPendingSave exceeded max iterations; possible save loop", {
iterations: flushIterations - 1,
});
throw new Error("flushPendingSave: exceeded max flush iterations; save state may be incomplete");
}
const hadDebouncedSave = !!this.saveDebounceTimer;
if (this.saveDebounceTimer) {
clearTimeout(this.saveDebounceTimer);
this.saveDebounceTimer = null;
}
if (this.pendingSave) {
const pendingSaveTick = this.saveFinalizationTick;
let pendingSaveError: unknown;
try {
await this.pendingSave;
} catch (error) {
pendingSaveError = error;
}
await this.waitForSaveFinalization(pendingSaveTick);
if (this.saveDebounceTimer !== null || this.pendingSave !== null) {
throwIfDeadlineExceeded();
continue;
}
const shouldRetryAfterPendingSave = hadDebouncedSave || this.saveRetryNeeded;
if (pendingSaveError) {
if (!shouldRetryAfterPendingSave) {
throw pendingSaveError;
}
log.warn("flushPendingSave: retrying after save failure to flush latest state", {
error:
pendingSaveError instanceof Error
? pendingSaveError.message
: String(pendingSaveError),
});
}
}
if (!hadDebouncedSave && !this.saveRetryNeeded) {
return;
}
if (this.saveDebounceTimer !== null || this.pendingSave !== null) {
throwIfDeadlineExceeded();
continue;
}
const flushSaveTick = this.saveFinalizationTick;
const flushSave = this.saveToDisk();
let flushSaveError: unknown;
try {
await flushSave;
} catch (error) {
flushSaveError = error;
}
await this.waitForSaveFinalization(flushSaveTick);
if (this.saveDebounceTimer !== null || this.pendingSave !== null) {
if (flushSaveError) {
log.warn("flushPendingSave: retrying after flush save failure while newer save is queued", {
error:
flushSaveError instanceof Error
? flushSaveError.message
: String(flushSaveError),
});
}
throwIfDeadlineExceeded();
continue;
}
if (flushSaveError) {
throw flushSaveError;
}
if (this.saveDebounceTimer === null && this.pendingSave === null) {
return;
}
}
}
}
Expand Down
Loading