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

@@ -36,10 +36,13 @@ import {
fetchDashboardData, fetchDashboardData,
fetchFastMode, fetchFastMode,
fetchModelConfig, fetchModelConfig,
refreshAllCodexAccounts as refreshAllCodexAccountsApi,
refreshCodexAccount as refreshCodexAccountApi,
runInboxAction, runInboxAction,
runServiceAction, runServiceAction,
runScheduledTaskAction, runScheduledTaskAction,
sendRoomMessage, sendRoomMessage,
setCurrentCodexAccount as setCurrentCodexAccountApi,
updateFastMode, updateFastMode,
updateModels, updateModels,
updateScheduledTask, updateScheduledTask,
@@ -1355,8 +1358,10 @@ function AccountSettings({ onRestartStack }: { onRestartStack: () => void }) {
const [data, setData] = useState<{ const [data, setData] = useState<{
claude: ClaudeAccountSummary[]; claude: ClaudeAccountSummary[];
codex: CodexAccountSummary[]; codex: CodexAccountSummary[];
codexCurrentIndex?: number;
} | null>(null); } | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [perRowBusy, setPerRowBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [tokenInput, setTokenInput] = useState(''); 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 ( return (
<section className="settings-section"> <section className="settings-section">
<h3></h3> <h3></h3>
@@ -1479,7 +1528,17 @@ function AccountSettings({ onRestartStack }: { onRestartStack: () => void }) {
</div> </div>
<div className="settings-account-group"> <div className="settings-account-group">
<div className="settings-account-group-head">
<h4>Codex</h4> <h4>Codex</h4>
<button
className="settings-secondary"
disabled={busy}
onClick={() => void handleRefreshAllCodex()}
type="button"
>
</button>
</div>
{!data ? ( {!data ? (
<p className="settings-hint">{busy ? '불러오는 중…' : '없음'}</p> <p className="settings-hint">{busy ? '불러오는 중…' : '없음'}</p>
) : data.codex.length === 0 ? ( ) : data.codex.length === 0 ? (
@@ -1488,10 +1547,18 @@ function AccountSettings({ onRestartStack }: { onRestartStack: () => void }) {
<ul className="settings-account-list"> <ul className="settings-account-list">
{data.codex.map((acc) => { {data.codex.map((acc) => {
const expiry = formatExpiry(acc.subscriptionUntil); const expiry = formatExpiry(acc.subscriptionUntil);
const isActive = data.codexCurrentIndex === acc.index;
const refreshing = perRowBusy === `refresh:${acc.index}`;
const switching = perRowBusy === `switch:${acc.index}`;
return ( return (
<li key={acc.index} className="settings-account-row"> <li
key={acc.index}
className={`settings-account-row${isActive ? ' is-active-account' : ''}`}
>
<div className="settings-account-main"> <div className="settings-account-main">
<span className="settings-account-tag">#{acc.index}</span> <span className="settings-account-tag">
{isActive ? '●' : ''}#{acc.index}
</span>
{acc.email ? ( {acc.email ? (
<span <span
className="settings-account-email" className="settings-account-email"
@@ -1516,26 +1583,48 @@ function AccountSettings({ onRestartStack }: { onRestartStack: () => void }) {
</span> </span>
) : null} ) : null}
</div> </div>
<div className="settings-account-actions">
<button
className="settings-secondary"
disabled={busy || perRowBusy !== null}
onClick={() => void handleCodexRefresh(acc.index)}
title="OAuth 토큰을 다시 받아 구독 상태를 갱신합니다"
type="button"
>
{refreshing ? '갱신중…' : '갱신'}
</button>
{!isActive ? (
<button
className="settings-secondary"
disabled={busy || perRowBusy !== null}
onClick={() => void handleSwitchCodex(acc.index)}
title="이 계정으로 즉시 전환합니다 (다음 codex 호출부터 적용)"
type="button"
>
{switching ? '전환중…' : '전환'}
</button>
) : (
<span className="settings-account-default"></span>
)}
{acc.index > 0 ? ( {acc.index > 0 ? (
<button <button
className="settings-delete" className="settings-delete"
disabled={busy} disabled={busy || perRowBusy !== null}
onClick={() => void handleDelete('codex', acc.index)} onClick={() => void handleDelete('codex', acc.index)}
type="button" type="button"
> >
</button> </button>
) : ( ) : null}
<span className="settings-account-default"></span> </div>
)}
</li> </li>
); );
})} })}
</ul> </ul>
)} )}
<p className="settings-hint"> <p className="settings-hint">
Codex <code>codex login</code> CLI로 OAuth 6 . plan /
~/.codex-accounts/N/ auth.json . .
</p> </p>
</div> </div>

View File

@@ -381,6 +381,7 @@ export interface FastModeSnapshot {
export async function fetchAccounts(): Promise<{ export async function fetchAccounts(): Promise<{
claude: ClaudeAccountSummary[]; claude: ClaudeAccountSummary[];
codex: CodexAccountSummary[]; codex: CodexAccountSummary[];
codexCurrentIndex?: number;
}> { }> {
return fetchJson('/api/settings/accounts'); return fetchJson('/api/settings/accounts');
} }
@@ -417,6 +418,64 @@ export async function updateModels(
return (await response.json()) as ModelConfigSnapshot; return (await response.json()) as ModelConfigSnapshot;
} }
export async function refreshCodexAccount(
index: number,
): Promise<CodexAccountSummary> {
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<FastModeSnapshot> { export async function fetchFastMode(): Promise<FastModeSnapshot> {
return fetchJson('/api/settings/fast-mode'); return fetchJson('/api/settings/fast-mode');
} }

View File

@@ -897,6 +897,13 @@ button:disabled {
gap: 4px; gap: 4px;
} }
.settings-account-group-head {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
}
.settings-account-row { .settings-account-row {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
@@ -908,6 +915,38 @@ button:disabled {
background: rgba(255, 255, 255, 0.02); 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 { .settings-account-main {
display: grid; display: grid;
grid-template-columns: 28px minmax(0, 1fr) 60px 180px; grid-template-columns: 28px minmax(0, 1fr) 60px 180px;

View File

@@ -256,6 +256,42 @@ export function getActiveCodexAuthPath(): string | null {
return getCodexAuthPath(); 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( export function getCodexAuthPath(
accountIndex: number = currentIndex, accountIndex: number = currentIndex,
): string | null { ): string | null {

View File

@@ -59,6 +59,10 @@ import { startWebDashboardServer } from './web-dashboard-server.js';
import { Channel, NewMessage, RegisteredGroup } from './types.js'; import { Channel, NewMessage, RegisteredGroup } from './types.js';
import { logger } from './logger.js'; import { logger } from './logger.js';
import { initCodexTokenRotation } from './codex-token-rotation.js'; import { initCodexTokenRotation } from './codex-token-rotation.js';
import {
startCodexAccountRefreshLoop,
stopCodexAccountRefreshLoop,
} from './settings-store.js';
import { import {
hasAvailableClaudeToken, hasAvailableClaudeToken,
initTokenRotation, initTokenRotation,
@@ -245,6 +249,7 @@ async function main(): Promise<void> {
initTokenRotation(); initTokenRotation();
initCodexTokenRotation(); initCodexTokenRotation();
startTokenRefreshLoop(); startTokenRefreshLoop();
startCodexAccountRefreshLoop();
runtimeState.loadState(); runtimeState.loadState();
@@ -254,6 +259,7 @@ async function main(): Promise<void> {
const shutdown = async (signal: string) => { const shutdown = async (signal: string) => {
logger.info({ signal }, 'Shutdown signal received'); logger.info({ signal }, 'Shutdown signal received');
stopTokenRefreshLoop(); stopTokenRefreshLoop();
stopCodexAccountRefreshLoop();
if (leaseRecoveryTimer) { if (leaseRecoveryTimer) {
clearInterval(leaseRecoveryTimer); clearInterval(leaseRecoveryTimer);
leaseRecoveryTimer = null; leaseRecoveryTimer = null;

View File

@@ -277,6 +277,136 @@ export function updateModelConfig(
return getModelConfig(); 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<CodexAuthFile>(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<CodexAccountSummary> {
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<typeof setInterval> | 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 { function codexConfigPath(): string {
return path.join(os.homedir(), '.codex', 'config.toml'); return path.join(os.homedir(), '.codex', 'config.toml');
} }

View File

@@ -52,10 +52,16 @@ import {
getModelConfig, getModelConfig,
listClaudeAccounts, listClaudeAccounts,
listCodexAccounts, listCodexAccounts,
refreshAllCodexAccounts,
refreshCodexAccount,
removeAccountDirectory, removeAccountDirectory,
updateFastMode, updateFastMode,
updateModelConfig, updateModelConfig,
} from './settings-store.js'; } from './settings-store.js';
import {
getCurrentCodexAccountIndex,
setCurrentCodexAccountIndex,
} from './codex-token-rotation.js';
const DEFAULT_STATUS_MAX_AGE_MS = 10 * 60 * 1000; const DEFAULT_STATUS_MAX_AGE_MS = 10 * 60 * 1000;
const ROOM_MESSAGE_ID_CACHE_LIMIT = 500; 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 (url.pathname === '/api/tasks' && request.method === 'POST') {
if (!loadRoomBindings) { if (!loadRoomBindings) {
return jsonResponse( return jsonResponse(
@@ -1538,6 +1602,7 @@ export function createWebDashboardHandler(
return jsonResponse({ return jsonResponse({
claude: listClaudeAccounts(), claude: listClaudeAccounts(),
codex: listCodexAccounts(), codex: listCodexAccounts(),
codexCurrentIndex: getCurrentCodexAccountIndex(),
}); });
} }