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

@@ -52,10 +52,16 @@ import {
getModelConfig,
listClaudeAccounts,
listCodexAccounts,
refreshAllCodexAccounts,
refreshCodexAccount,
removeAccountDirectory,
updateFastMode,
updateModelConfig,
} from './settings-store.js';
import {
getCurrentCodexAccountIndex,
setCurrentCodexAccountIndex,
} from './codex-token-rotation.js';
const DEFAULT_STATUS_MAX_AGE_MS = 10 * 60 * 1000;
const ROOM_MESSAGE_ID_CACHE_LIMIT = 500;
@@ -1340,6 +1346,64 @@ export function createWebDashboardHandler(
}
}
{
const refreshMatch = url.pathname.match(
/^\/api\/settings\/accounts\/codex\/(\d+)\/refresh$/,
);
if (refreshMatch && request.method === 'POST') {
const index = Number.parseInt(refreshMatch[1], 10);
try {
const updated = await refreshCodexAccount(index);
return jsonResponse({ ok: true, account: updated });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return jsonResponse({ error: message }, { status: 400 });
}
}
}
if (
url.pathname === '/api/settings/accounts/codex/refresh-all' &&
request.method === 'POST'
) {
try {
const result = await refreshAllCodexAccounts();
return jsonResponse({ ok: true, ...result });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return jsonResponse({ error: message }, { status: 500 });
}
}
if (
url.pathname === '/api/settings/accounts/codex/current' &&
request.method === 'PUT'
) {
let body: { index?: unknown } | null = null;
try {
body = (await request.json()) as { index?: unknown };
} catch {
return jsonResponse({ error: 'Invalid JSON body' }, { status: 400 });
}
const idx = typeof body?.index === 'number' ? body.index : Number.NaN;
if (!Number.isInteger(idx)) {
return jsonResponse(
{ error: 'index must be an integer' },
{ status: 400 },
);
}
try {
setCurrentCodexAccountIndex(idx);
return jsonResponse({
ok: true,
codexCurrentIndex: getCurrentCodexAccountIndex(),
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return jsonResponse({ error: message }, { status: 400 });
}
}
if (url.pathname === '/api/tasks' && request.method === 'POST') {
if (!loadRoomBindings) {
return jsonResponse(
@@ -1538,6 +1602,7 @@ export function createWebDashboardHandler(
return jsonResponse({
claude: listClaudeAccounts(),
codex: listCodexAccounts(),
codexCurrentIndex: getCurrentCodexAccountIndex(),
});
}