Codex accounts: 6h auto-refresh, manual refresh, manual switch (#48)

Auto refresh
- New refreshCodexAccount(index) calls https://auth.openai.com/oauth/token
  with grant_type=refresh_token and persists rotated tokens back to
  ~/.codex-accounts/{N}/auth.json. JWT's subscription_active_until
  reflects the latest plan state OpenAI hands back.
- startCodexAccountRefreshLoop() runs every 6h (first run delayed 60s
  after boot to keep startup snappy). Hooked into main()/shutdown()
  alongside the existing claude token refresh loop.

Manual controls (Settings → 계정 → Codex)
- "갱신" button per row: forces an immediate refresh for that account.
- "전체 갱신" button: refreshes all codex accounts in sequence.
- "전환" button: writes data/codex-rotation-state.json so the next codex
  spawn picks the chosen account. Active account marked with a green-
  bordered card and "사용중" badge.

Endpoints
- POST /api/settings/accounts/codex/{i}/refresh
- POST /api/settings/accounts/codex/refresh-all
- PUT  /api/settings/accounts/codex/current  { index }
- GET  /api/settings/accounts now also returns codexCurrentIndex.

Why
JWTs cache subscription state at issue-time. When a Pro plan lapses or
a user upgrades/downgrades, the dashboard kept showing stale data until
the user logged in again. Periodic refresh + explicit "갱신" button
keeps the displayed state honest, and the manual switch unblocks the
user when rotation lands on a known-bad account.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Eyejoker
2026-04-27 22:23:06 +09:00
committed by GitHub
parent 2f7e09e194
commit c2a6889567
7 changed files with 437 additions and 13 deletions

View File

@@ -256,6 +256,42 @@ export function getActiveCodexAuthPath(): string | null {
return getCodexAuthPath();
}
/** Currently active codex account index (after rotation). */
export function getCurrentCodexAccountIndex(): number {
return accounts.length === 0 ? 0 : currentIndex;
}
/** Manually switch the active codex account. Clears its rate-limit cooldown. */
export function setCurrentCodexAccountIndex(targetIndex: number): void {
if (accounts.length === 0) {
throw new Error('codex token rotation: no accounts loaded');
}
if (
!Number.isInteger(targetIndex) ||
targetIndex < 0 ||
targetIndex >= accounts.length
) {
throw new Error(
`codex switch: index ${targetIndex} out of range [0..${accounts.length - 1}]`,
);
}
if (targetIndex === currentIndex) return;
const previous = currentIndex;
currentIndex = targetIndex;
// Clear rate-limit on the chosen account so rotation logic doesn't bounce it.
accounts[targetIndex].rateLimitedUntil = null;
saveCodexState();
logger.info(
{
transition: 'rotation:manual-switch',
fromIndex: previous,
toIndex: currentIndex,
totalAccounts: accounts.length,
},
`Codex switched manually to account #${currentIndex + 1}/${accounts.length}`,
);
}
export function getCodexAuthPath(
accountIndex: number = currentIndex,
): string | null {