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:
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user