From c2a6889567bead1704cfabd36da0dd281a2a651c Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Mon, 27 Apr 2026 22:23:06 +0900 Subject: [PATCH] Codex accounts: 6h auto-refresh, manual refresh, manual switch (#48) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/dashboard/src/App.tsx | 115 ++++++++++++++++++++++++++---- apps/dashboard/src/api.ts | 59 +++++++++++++++ apps/dashboard/src/styles.css | 39 ++++++++++ src/codex-token-rotation.ts | 36 ++++++++++ src/index.ts | 6 ++ src/settings-store.ts | 130 ++++++++++++++++++++++++++++++++++ src/web-dashboard-server.ts | 65 +++++++++++++++++ 7 files changed, 437 insertions(+), 13 deletions(-) diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx index 9e7221b..5905aa3 100644 --- a/apps/dashboard/src/App.tsx +++ b/apps/dashboard/src/App.tsx @@ -36,10 +36,13 @@ import { fetchDashboardData, fetchFastMode, fetchModelConfig, + refreshAllCodexAccounts as refreshAllCodexAccountsApi, + refreshCodexAccount as refreshCodexAccountApi, runInboxAction, runServiceAction, runScheduledTaskAction, sendRoomMessage, + setCurrentCodexAccount as setCurrentCodexAccountApi, updateFastMode, updateModels, updateScheduledTask, @@ -1355,8 +1358,10 @@ function AccountSettings({ onRestartStack }: { onRestartStack: () => void }) { const [data, setData] = useState<{ claude: ClaudeAccountSummary[]; codex: CodexAccountSummary[]; + codexCurrentIndex?: number; } | null>(null); const [busy, setBusy] = useState(false); + const [perRowBusy, setPerRowBusy] = useState(null); const [error, setError] = useState(null); const [tokenInput, setTokenInput] = useState(''); @@ -1413,6 +1418,50 @@ function AccountSettings({ onRestartStack }: { onRestartStack: () => void }) { } } + async function handleCodexRefresh(index: number) { + setPerRowBusy(`refresh:${index}`); + setError(null); + try { + await refreshCodexAccountApi(index); + await refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setPerRowBusy(null); + } + } + + async function handleRefreshAllCodex() { + setBusy(true); + setError(null); + try { + const result = await refreshAllCodexAccountsApi(); + await refresh(); + if (result.failed.length > 0) { + setError( + `일부 갱신 실패: ${result.failed.map((f) => `#${f.index}`).join(', ')}`, + ); + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + } + + async function handleSwitchCodex(index: number) { + setPerRowBusy(`switch:${index}`); + setError(null); + try { + await setCurrentCodexAccountApi(index); + await refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setPerRowBusy(null); + } + } + return (

계정

@@ -1479,7 +1528,17 @@ function AccountSettings({ onRestartStack }: { onRestartStack: () => void }) {
-

Codex

+
+

Codex

+ +
{!data ? (

{busy ? '불러오는 중…' : '없음'}

) : data.codex.length === 0 ? ( @@ -1488,10 +1547,18 @@ function AccountSettings({ onRestartStack }: { onRestartStack: () => void }) {
    {data.codex.map((acc) => { const expiry = formatExpiry(acc.subscriptionUntil); + const isActive = data.codexCurrentIndex === acc.index; + const refreshing = perRowBusy === `refresh:${acc.index}`; + const switching = perRowBusy === `switch:${acc.index}`; return ( -
  • +
  • - #{acc.index} + + {isActive ? '●' : ''}#{acc.index} + {acc.email ? ( void }) { ) : null}
    - {acc.index > 0 ? ( +
    - ) : ( - 기본 - )} + {!isActive ? ( + + ) : ( + 사용중 + )} + {acc.index > 0 ? ( + + ) : null} +
  • ); })}
)}

- Codex 계정 추가는 codex login CLI로 진행한 뒤 - ~/.codex-accounts/N/ 디렉터리에 auth.json 파일이 생성되면 됩니다. + OAuth 토큰은 6시간마다 자동 갱신됩니다. plan 변경/해지가 즉시 반영되게 + 하려면 수동으로 “전체 갱신”을 누르세요.

diff --git a/apps/dashboard/src/api.ts b/apps/dashboard/src/api.ts index c804eca..4fc84ae 100644 --- a/apps/dashboard/src/api.ts +++ b/apps/dashboard/src/api.ts @@ -381,6 +381,7 @@ export interface FastModeSnapshot { export async function fetchAccounts(): Promise<{ claude: ClaudeAccountSummary[]; codex: CodexAccountSummary[]; + codexCurrentIndex?: number; }> { return fetchJson('/api/settings/accounts'); } @@ -417,6 +418,64 @@ export async function updateModels( return (await response.json()) as ModelConfigSnapshot; } +export async function refreshCodexAccount( + index: number, +): Promise { + const response = await fetch( + `/api/settings/accounts/codex/${index}/refresh`, + { method: 'POST' }, + ); + if (!response.ok) { + let msg = `refresh failed: ${response.status}`; + try { + const payload = (await response.json()) as { error?: string }; + if (payload.error) msg = payload.error; + } catch { + /* ignore */ + } + throw new Error(msg); + } + const json = (await response.json()) as { account: CodexAccountSummary }; + return json.account; +} + +export async function refreshAllCodexAccounts(): Promise<{ + refreshed: number[]; + failed: Array<{ index: number; error: string }>; +}> { + const response = await fetch('/api/settings/accounts/codex/refresh-all', { + method: 'POST', + }); + if (!response.ok) { + throw new Error(`refresh-all failed: ${response.status}`); + } + return (await response.json()) as { + refreshed: number[]; + failed: Array<{ index: number; error: string }>; + }; +} + +export async function setCurrentCodexAccount( + index: number, +): Promise<{ codexCurrentIndex: number }> { + const response = await fetch('/api/settings/accounts/codex/current', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ index }), + }); + if (!response.ok) { + let msg = `switch failed: ${response.status}`; + try { + const payload = (await response.json()) as { error?: string }; + if (payload.error) msg = payload.error; + } catch { + /* ignore */ + } + throw new Error(msg); + } + return (await response.json()) as { codexCurrentIndex: number }; +} + export async function fetchFastMode(): Promise { return fetchJson('/api/settings/fast-mode'); } diff --git a/apps/dashboard/src/styles.css b/apps/dashboard/src/styles.css index 1f4a650..b59fb55 100644 --- a/apps/dashboard/src/styles.css +++ b/apps/dashboard/src/styles.css @@ -897,6 +897,13 @@ button:disabled { gap: 4px; } +.settings-account-group-head { + display: flex; + gap: 8px; + align-items: center; + justify-content: space-between; +} + .settings-account-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; @@ -908,6 +915,38 @@ button:disabled { background: rgba(255, 255, 255, 0.02); } +.settings-account-row.is-active-account { + border-color: rgba(80, 180, 130, 0.45); + background: rgba(80, 180, 130, 0.06); +} + +.settings-account-actions { + display: flex; + gap: 6px; + align-items: center; +} + +.settings-secondary { + min-height: 28px; + padding: 0 10px; + border: 1px solid var(--panel-border-strong); + border-radius: 6px; + background: rgba(255, 255, 255, 0.04); + color: var(--bg-ink); + font-size: 11px; + font-weight: 700; + cursor: pointer; +} + +.settings-secondary:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.settings-secondary:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.08); +} + .settings-account-main { display: grid; grid-template-columns: 28px minmax(0, 1fr) 60px 180px; diff --git a/src/codex-token-rotation.ts b/src/codex-token-rotation.ts index bb7b03c..b93bbe9 100644 --- a/src/codex-token-rotation.ts +++ b/src/codex-token-rotation.ts @@ -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 { diff --git a/src/index.ts b/src/index.ts index 5326688..b749aba 100644 --- a/src/index.ts +++ b/src/index.ts @@ -59,6 +59,10 @@ import { startWebDashboardServer } from './web-dashboard-server.js'; import { Channel, NewMessage, RegisteredGroup } from './types.js'; import { logger } from './logger.js'; import { initCodexTokenRotation } from './codex-token-rotation.js'; +import { + startCodexAccountRefreshLoop, + stopCodexAccountRefreshLoop, +} from './settings-store.js'; import { hasAvailableClaudeToken, initTokenRotation, @@ -245,6 +249,7 @@ async function main(): Promise { initTokenRotation(); initCodexTokenRotation(); startTokenRefreshLoop(); + startCodexAccountRefreshLoop(); runtimeState.loadState(); @@ -254,6 +259,7 @@ async function main(): Promise { const shutdown = async (signal: string) => { logger.info({ signal }, 'Shutdown signal received'); stopTokenRefreshLoop(); + stopCodexAccountRefreshLoop(); if (leaseRecoveryTimer) { clearInterval(leaseRecoveryTimer); leaseRecoveryTimer = null; diff --git a/src/settings-store.ts b/src/settings-store.ts index 7c0cec5..3519439 100644 --- a/src/settings-store.ts +++ b/src/settings-store.ts @@ -277,6 +277,136 @@ export function updateModelConfig( return getModelConfig(); } +const CODEX_OAUTH_TOKEN_URL = 'https://auth.openai.com/oauth/token'; +const CODEX_OAUTH_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'; + +interface CodexAuthFile { + auth_mode?: string; + OPENAI_API_KEY?: string | null; + tokens?: { + id_token?: string; + access_token?: string; + refresh_token?: string; + account_id?: string; + }; + last_refresh?: string; +} + +function readCodexAuthFile(file: string): CodexAuthFile | null { + return readJson(file); +} + +function writeCodexAuthFile(file: string, data: CodexAuthFile): void { + const tempPath = `${file}.tmp`; + fs.writeFileSync(tempPath, `${JSON.stringify(data, null, 2)}\n`, { + mode: 0o600, + }); + fs.renameSync(tempPath, file); +} + +export async function refreshCodexAccount( + index: number, +): Promise { + const file = codexAuthPath(index); + if (!fs.existsSync(file)) { + throw new Error(`codex auth.json not found for index ${index}`); + } + const data = readCodexAuthFile(file); + const refreshToken = data?.tokens?.refresh_token; + if (!refreshToken) { + throw new Error(`codex account #${index} has no refresh_token`); + } + + const body = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: CODEX_OAUTH_CLIENT_ID, + }); + + const res = await fetch(CODEX_OAUTH_TOKEN_URL, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }); + + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error( + `codex refresh failed (#${index}): ${res.status} ${text.slice(0, 200)}`, + ); + } + + const payload = (await res.json()) as { + access_token?: string; + id_token?: string; + refresh_token?: string; + }; + + const updated: CodexAuthFile = { + ...(data ?? {}), + tokens: { + ...(data?.tokens ?? {}), + id_token: payload.id_token ?? data?.tokens?.id_token, + access_token: payload.access_token ?? data?.tokens?.access_token, + refresh_token: payload.refresh_token ?? refreshToken, + }, + last_refresh: new Date().toISOString(), + }; + writeCodexAuthFile(file, updated); + + const summary = readCodexAccount(index); + if (!summary) + throw new Error('failed to re-read codex account after refresh'); + return summary; +} + +export async function refreshAllCodexAccounts(): Promise<{ + refreshed: number[]; + failed: Array<{ index: number; error: string }>; +}> { + const refreshed: number[] = []; + const failed: Array<{ index: number; error: string }> = []; + const accounts = listCodexAccounts(); + for (const acc of accounts) { + try { + await refreshCodexAccount(acc.index); + refreshed.push(acc.index); + } catch (err) { + failed.push({ + index: acc.index, + error: err instanceof Error ? err.message : String(err), + }); + } + } + return { refreshed, failed }; +} + +const CODEX_REFRESH_INTERVAL_MS = 6 * 60 * 60 * 1000; +let codexRefreshTimer: ReturnType | null = null; + +export function startCodexAccountRefreshLoop(): void { + if (codexRefreshTimer) return; + // Stagger first refresh so server boot isn't slowed. + setTimeout(() => { + void refreshAllCodexAccounts().catch(() => { + /* swallow; per-account errors logged inside */ + }); + }, 60_000).unref?.(); + codexRefreshTimer = setInterval(() => { + void refreshAllCodexAccounts().catch(() => { + /* swallow */ + }); + }, CODEX_REFRESH_INTERVAL_MS); + codexRefreshTimer.unref?.(); +} + +export function stopCodexAccountRefreshLoop(): void { + if (codexRefreshTimer) { + clearInterval(codexRefreshTimer); + codexRefreshTimer = null; + } +} + function codexConfigPath(): string { return path.join(os.homedir(), '.codex', 'config.toml'); } diff --git a/src/web-dashboard-server.ts b/src/web-dashboard-server.ts index 6d2c990..506d680 100644 --- a/src/web-dashboard-server.ts +++ b/src/web-dashboard-server.ts @@ -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(), }); }