Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ For remote or headless shells, prefer `codex-multi-auth login --device-auth`.
| Budget guards | `~/.codex/multi-auth/budget-guards.json` |
| Local client tokens | `~/.codex/multi-auth/local-client-tokens.json` |
| Runtime app helper status | `~/.codex/multi-auth/runtime-rotation-app-helper.json` |
| Runtime app helper owner metadata | `~/.codex/multi-auth/runtime-rotation-app-helper-owner.<pid>.json` |
| Persistent app bind state/logs | `~/.codex/multi-auth/app-bind/` |
| Logs | `~/.codex/multi-auth/logs/codex-plugin/` |
| Per-project accounts | `~/.codex/multi-auth/projects/<project-key>/openai-codex-accounts.json` |
Expand Down
4 changes: 4 additions & 0 deletions docs/development/CONFIG_FIELDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,10 @@ Upgrade note:
| `preemptiveQuotaRemainingPercent7d` | `5` |
| `preemptiveQuotaMaxDeferralMs` | `7200000` |

`preemptiveQuotaMaxDeferralMs` is the fallback delay when a near-exhausted window has
missing, invalid, or stale reset data. A trusted future reset may schedule through the
reset time, subject to the scheduler's seven-day safety ceiling.

### Notifications

| Key | Default |
Expand Down
1 change: 1 addition & 0 deletions docs/reference/storage-paths.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ Override root:
| Local bridge client tokens | `~/.codex/multi-auth/local-client-tokens.json` |
| Cross-process refresh leases | `~/.codex/multi-auth/refresh-leases/` |
| Runtime app helper status | `~/.codex/multi-auth/runtime-rotation-app-helper.json` |
| Runtime app helper owner metadata | `~/.codex/multi-auth/runtime-rotation-app-helper-owner.<pid>.json` |
| Persistent app bind directory | `~/.codex/multi-auth/app-bind/` |
| Named pool backups | `~/.codex/multi-auth/backups/` |
| Per-project account pools | `~/.codex/multi-auth/projects/<project-key>/openai-codex-accounts.json` |
Expand Down
192 changes: 171 additions & 21 deletions lib/accounts.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Auth } from "@codex-ai/sdk";
import { createHash } from "node:crypto";
import { saveAccountsWithRetry } from "./storage/save-retry.js";
import { createLogger } from "./logger.js";
import {
Expand Down Expand Up @@ -109,6 +110,36 @@ function getAccountCircuitKey(account: ManagedAccount): string {
return account.circuitKeyId;
}

function deriveAccountRecordId(
account: {
accountId?: string;
email?: string;
refreshToken: string;
addedAt: number;
},
): string {
const seed = [
account.addedAt,
account.accountId?.trim() ?? "",
account.email?.trim().toLowerCase() ?? "",
account.refreshToken.trim(),
].join("\u0000");
return `record:${createHash("sha256").update(seed).digest("hex")}`;
}

function resolveAccountRecordId(
account: {
recordId?: string;
accountId?: string;
email?: string;
refreshToken: string;
addedAt: number;
},
): string {
const stored = account.recordId?.trim();
return stored || deriveAccountRecordId(account);
}

export function getRuntimeTrackerKey(account: ManagedAccount): string | number {
if (account._runtimeTrackerKey !== undefined) {
return account._runtimeTrackerKey;
Expand Down Expand Up @@ -239,8 +270,12 @@ function isRetryableAuthPersistenceError(error: unknown): boolean {
// re-exported here to preserve the historical import surface.
export type { Workspace } from "./storage/public-types.js";

/** Stable operator-facing marker for an explicitly invalidated OAuth token. */
export const AUTH_INVALIDATION_MARKER = "token-invalid — re-login needed";

export interface ManagedAccount {
index: number;
recordId?: string;
_runtimeTrackerKey?: string | number;
circuitKeyId?: string;
accountId?: string;
Expand All @@ -264,6 +299,8 @@ export interface ManagedAccount {
rateLimitResetTimes: RateLimitStateV3;
coolingDownUntil?: number;
cooldownReason?: CooldownReason;
authInvalidatedAt?: number;
authInvalidationErrorCode?: string;
consecutiveAuthFailures?: number;
workspaces?: Workspace[];
currentWorkspaceIndex?: number;
Expand Down Expand Up @@ -488,6 +525,9 @@ export class AccountManager {

return {
index,
recordId: resolveAccountRecordId(
{ ...account, refreshToken },
),
accountId: matchesFallback
? (fallbackAccountId ?? account.accountId)
: account.accountId,
Expand All @@ -512,6 +552,8 @@ export class AccountManager {
rateLimitResetTimes: account.rateLimitResetTimes ?? {},
coolingDownUntil: account.coolingDownUntil,
cooldownReason: account.cooldownReason,
authInvalidatedAt: account.authInvalidatedAt,
authInvalidationErrorCode: account.authInvalidationErrorCode,
workspaces: account.workspaces,
currentWorkspaceIndex: account.currentWorkspaceIndex,
};
Expand All @@ -525,6 +567,14 @@ export class AccountManager {
const now = nowMs();
this.accounts.push({
index: this.accounts.length,
recordId: deriveAccountRecordId(
{
accountId: fallbackAccountId,
email: fallbackAccountEmail,
refreshToken: authFallback.refresh,
addedAt: now,
},
),
accountId: fallbackAccountId,
accountIdSource: fallbackAccountId ? "token" : undefined,
email: fallbackAccountEmail,
Expand Down Expand Up @@ -559,6 +609,14 @@ export class AccountManager {
this.accounts = [
{
index: 0,
recordId: deriveAccountRecordId(
{
accountId: fallbackAccountId,
email: fallbackAccountEmail,
refreshToken: authFallback.refresh,
addedAt: now,
},
),
accountId: fallbackAccountId,
accountIdSource: fallbackAccountId ? "token" : undefined,
email: fallbackAccountEmail,
Expand Down Expand Up @@ -640,6 +698,7 @@ export class AccountManager {
clearExpiredRateLimits(account);
return (
!isRateLimitedForFamily(account, family, model) &&
!this.isAccountAuthInvalidated(account) &&
!this.isAccountCoolingDown(account) &&
this.isCircuitAvailable(account)
);
Expand All @@ -652,8 +711,23 @@ export class AccountManager {
): string | null {
const account = this.getAccountByIndex(index);
if (!account) return "missing";
return this.getManagedAccountRuntimeSkipReason(account, family, model);
}

/**
* Evaluate a managed-account snapshot with the same runtime gates used by
* production selection. This is public for read-only diagnostics that build
* their own trace rows from storage and therefore cannot safely resolve an
* account by its compacted array position.
*/
getManagedAccountRuntimeSkipReason(
account: ManagedAccount,
family: ModelFamily,
model?: string | null,
): string | null {
if (account.enabled === false) return "disabled";
if (!this.hasEnabledWorkspaces(account)) return "workspace-disabled";
if (this.isAccountAuthInvalidated(account)) return AUTH_INVALIDATION_MARKER;
clearExpiredRateLimits(account);
if (isRateLimitedForFamily(account, family, model)) return "rate-limited";
if (this.isAccountCoolingDown(account)) {
Expand Down Expand Up @@ -770,6 +844,7 @@ export class AccountManager {
clearExpiredRateLimits(account);
if (
isRateLimitedForFamily(account, family, model) ||
this.isAccountAuthInvalidated(account) ||
this.isAccountCoolingDown(account) ||
!this.isCircuitAvailable(account)
) {
Expand Down Expand Up @@ -804,6 +879,7 @@ export class AccountManager {
clearExpiredRateLimits(account);
if (
isRateLimitedForFamily(account, family, model) ||
this.isAccountAuthInvalidated(account) ||
this.isAccountCoolingDown(account) ||
!this.isCircuitAvailable(account)
) {
Expand Down Expand Up @@ -861,6 +937,7 @@ export class AccountManager {
clearExpiredRateLimits(account);
return (
!isRateLimitedForFamily(account, family, model) &&
!this.isAccountAuthInvalidated(account) &&
!this.isAccountCoolingDown(account) &&
this.isCircuitAvailable(account)
);
Expand Down Expand Up @@ -917,6 +994,7 @@ export class AccountManager {
clearExpiredRateLimits(account);
const isAvailable =
!isRateLimitedForFamily(account, family, model) &&
!this.isAccountAuthInvalidated(account) &&
!this.isAccountCoolingDown(account) &&
this.isCircuitAvailable(account);
return {
Expand Down Expand Up @@ -1226,6 +1304,28 @@ export class AccountManager {
account.consecutiveAuthFailures = 0;
}

markAuthInvalidated(
account: ManagedAccount,
errorCode = "token_invalidated",
invalidatedAt = nowMs(),
): void {
account.authInvalidatedAt = invalidatedAt;
account.authInvalidationErrorCode = errorCode;
}

clearAuthInvalidation(account: ManagedAccount): void {
delete account.authInvalidatedAt;
delete account.authInvalidationErrorCode;
}

isAccountAuthInvalidated(account: ManagedAccount): boolean {
return (
typeof account.authInvalidatedAt === "number" &&
Number.isFinite(account.authInvalidatedAt) &&
account.authInvalidatedAt > 0
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

getAccountByIdentity(
candidate: AccountIdentityCandidate,
auth?: OAuthAuthDetails,
Expand Down Expand Up @@ -1257,6 +1357,7 @@ export class AccountManager {
account.refreshToken = auth.refresh;
account.access = auth.access;
account.expires = auth.expires;
this.clearAuthInvalidation(account);
const tokenAccountId = extractAccountId(auth.access)?.trim() || undefined;
if (
tokenAccountId &&
Expand Down Expand Up @@ -1315,6 +1416,24 @@ export class AccountManager {
account.accessToken = disk.accessToken;
account.expiresAt = disk.expiresAt;
}
const diskInvalidatedAt = disk.authInvalidatedAt;
const diskHasValidInvalidation =
typeof diskInvalidatedAt === "number" &&
Number.isFinite(diskInvalidatedAt) &&
diskInvalidatedAt > 0;
const accountHasValidInvalidation =
typeof account.authInvalidatedAt === "number" &&
Number.isFinite(account.authInvalidatedAt) &&
account.authInvalidatedAt > 0;
if (diskHasValidInvalidation && !accountHasValidInvalidation) {
// Ordinary saves must not erase an invalidation written by another
// AccountManager. Successful refresh persistence explicitly deletes
// this marker in commitRefreshedAuth; this reconciliation path does not.
account.authInvalidatedAt = diskInvalidatedAt;
if (disk.authInvalidationErrorCode) {
account.authInvalidationErrorCode = disk.authInvalidationErrorCode;
}
}
}
return snapshot;
}
Expand Down Expand Up @@ -1364,27 +1483,38 @@ export class AccountManager {

const snapshot: AccountStorageV3 = {
version: 3,
accounts: this.accounts.map((account) => ({
accountId: account.accountId,
accountIdSource: account.accountIdSource,
accountLabel: account.accountLabel,
email: account.email,
refreshToken: account.refreshToken,
accessToken: account.access,
expiresAt: account.expires,
enabled: account.enabled === false ? false : undefined,
addedAt: account.addedAt,
lastUsed: account.lastUsed,
lastSwitchReason: account.lastSwitchReason,
rateLimitResetTimes:
Object.keys(account.rateLimitResetTimes).length > 0
? account.rateLimitResetTimes
accounts: this.accounts.map((account) => {
const hasValidAuthInvalidation =
this.isAccountAuthInvalidated(account);
return {
recordId: account.recordId,
accountId: account.accountId,
accountIdSource: account.accountIdSource,
accountLabel: account.accountLabel,
email: account.email,
refreshToken: account.refreshToken,
accessToken: account.access,
expiresAt: account.expires,
enabled: account.enabled === false ? false : undefined,
addedAt: account.addedAt,
lastUsed: account.lastUsed,
lastSwitchReason: account.lastSwitchReason,
rateLimitResetTimes:
Object.keys(account.rateLimitResetTimes).length > 0
? account.rateLimitResetTimes
: undefined,
coolingDownUntil: account.coolingDownUntil,
cooldownReason: account.cooldownReason,
authInvalidatedAt: hasValidAuthInvalidation
? account.authInvalidatedAt
: undefined,
authInvalidationErrorCode: hasValidAuthInvalidation
? account.authInvalidationErrorCode
: undefined,
coolingDownUntil: account.coolingDownUntil,
cooldownReason: account.cooldownReason,
workspaces: account.workspaces,
currentWorkspaceIndex: account.currentWorkspaceIndex,
})),
workspaces: account.workspaces,
currentWorkspaceIndex: account.currentWorkspaceIndex,
};
}),
activeIndex,
activeIndexByFamily,
};
Expand Down Expand Up @@ -1447,6 +1577,8 @@ export class AccountManager {
storedAccount.email = nextEmail;
}
storedAccount.enabled = undefined;
delete storedAccount.authInvalidatedAt;
delete storedAccount.authInvalidationErrorCode;
delete storedAccount.coolingDownUntil;
delete storedAccount.cooldownReason;

Expand All @@ -1463,6 +1595,9 @@ export class AccountManager {
coolingDownUntil: liveAccount.coolingDownUntil,
cooldownReason: liveAccount.cooldownReason,
consecutiveAuthFailures: liveAccount.consecutiveAuthFailures,
authInvalidatedAt: liveAccount.authInvalidatedAt,
authInvalidationErrorCode:
liveAccount.authInvalidationErrorCode,
};

this.updateFromAuth(liveAccount, auth);
Expand All @@ -1483,6 +1618,20 @@ export class AccountManager {
liveAccount.enabled = previousLiveAccountState.enabled;
liveAccount.consecutiveAuthFailures =
previousLiveAccountState.consecutiveAuthFailures;
if (previousLiveAccountState.authInvalidatedAt === undefined) {
delete liveAccount.authInvalidatedAt;
} else {
liveAccount.authInvalidatedAt =
previousLiveAccountState.authInvalidatedAt;
}
if (
previousLiveAccountState.authInvalidationErrorCode === undefined
) {
delete liveAccount.authInvalidationErrorCode;
} else {
liveAccount.authInvalidationErrorCode =
previousLiveAccountState.authInvalidationErrorCode;
}
if (previousLiveAccountState.coolingDownUntil === undefined) {
delete liveAccount.coolingDownUntil;
} else {
Expand Down Expand Up @@ -1531,7 +1680,8 @@ export class AccountManager {
getMinWaitTimeForFamily(family: ModelFamily, model?: string | null): number {
const now = nowMs();
const enabledAccounts = this.accounts.filter(
(account) => account.enabled !== false,
(account) =>
account.enabled !== false && !this.isAccountAuthInvalidated(account),
);
const available = enabledAccounts.filter((account) => {
clearExpiredRateLimits(account);
Expand Down
Loading