/** * Settings store for the web dashboard: * - lists Claude / Codex accounts (safe metadata only, no tokens) * - reads/writes model role configuration via .env * - removes account directories on the filesystem * * Account directory layout (existing convention): * Claude default: ~/.claude/.credentials.json * Claude index N≥1: ~/.claude-accounts/{N}/.credentials.json * Codex default: ~/.codex/auth.json * Codex index N≥1: ~/.codex-accounts/{N}/auth.json */ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { findCodexAccountIndexByAuthPath, getActiveCodexAuthPath, setCurrentCodexAccountIndex as setRotationIndex, } from './codex-token-rotation.js'; import { normalizeCodexLiveStatus, toCodexLiveStatusSummary, type CodexLiveStatus, type CodexLiveStatusSummary, } from './codex-live-status.js'; import { readCodexFeatureFromFile, writeCodexFeatureToFile, } from './codex-config-features.js'; import { readHostClaudeFastMode } from './claude-session-settings.js'; import { agentTypeForRole, isEffortSupported } from './settings-effort.js'; export type { CodexAdditionalRateLimitSummary, CodexCreditsSummary, CodexLiveStatusSummary, CodexRateLimitSummary, CodexRateLimitWindowSummary, CodexSpendControlSummary, } from './codex-live-status.js'; export interface ClaudeAccountSummary { index: number; expiresAt: number | null; scopes: string[]; subscriptionType?: string; rateLimitTier?: string; exists: boolean; } export interface CodexAccountSummary { index: number; accountId: string | null; email: string | null; planType: string | null; subscriptionUntil: string | null; subscriptionLastChecked: string | null; subscriptionSource: 'jwt-cache' | null; liveStatus: CodexLiveStatusSummary | null; exists: boolean; } export interface ModelRoleConfig { model: string; effort: string; } export interface ModelAgentTypes { owner: 'claude-code' | 'codex'; reviewer: 'claude-code' | 'codex'; arbiter: 'claude-code' | 'codex' | null; } export interface ModelConfigSnapshot { owner: ModelRoleConfig; reviewer: ModelRoleConfig; arbiter: ModelRoleConfig; agentTypes: ModelAgentTypes; } export interface FastModeSnapshot { codex: boolean; claude: boolean; } export interface CodexFeatureSnapshot { goals: boolean; } const ROLE_KEYS = ['OWNER', 'REVIEWER', 'ARBITER'] as const; function envFilePath(): string { return path.join(process.cwd(), '.env'); } function readJson(file: string): T | null { try { if (!fs.existsSync(file)) return null; return JSON.parse(fs.readFileSync(file, 'utf-8')) as T; } catch { return null; } } function settingsHomeDir(): string { return process.env.EJCLAW_SETTINGS_HOME || os.homedir(); } function claudeCredsPath(index: number): string { if (index === 0) { return path.join(settingsHomeDir(), '.claude', '.credentials.json'); } return path.join( settingsHomeDir(), '.claude-accounts', String(index), '.credentials.json', ); } function codexAuthPath(index: number): string { if (index === 0) { return path.join(settingsHomeDir(), '.codex', 'auth.json'); } return path.join( settingsHomeDir(), '.codex-accounts', String(index), 'auth.json', ); } function readClaudeAccount(index: number): ClaudeAccountSummary | null { const file = claudeCredsPath(index); if (!fs.existsSync(file)) return null; const data = readJson<{ claudeAiOauth?: Record }>(file); const oauth = data?.claudeAiOauth ?? {}; return { index, expiresAt: typeof oauth.expiresAt === 'number' ? oauth.expiresAt : null, scopes: Array.isArray(oauth.scopes) ? (oauth.scopes as string[]) : [], subscriptionType: typeof oauth.subscriptionType === 'string' ? oauth.subscriptionType : undefined, rateLimitTier: typeof oauth.rateLimitTier === 'string' ? oauth.rateLimitTier : undefined, exists: true, }; } function readCodexAccount(index: number): CodexAccountSummary | null { const file = codexAuthPath(index); if (!fs.existsSync(file)) return null; const data = readJson<{ OPENAI_API_KEY?: string; tokens?: { id_token?: string; access_token?: string }; }>(file); const live = readCodexLiveStatus(index); let accountId: string | null = null; let email: string | null = null; let planType: string | null = null; let subscriptionUntil: string | null = null; let subscriptionLastChecked: string | null = null; let subscriptionSource: 'jwt-cache' | null = null; let liveStatus: CodexLiveStatusSummary | null = null; const idToken = data?.tokens?.id_token; if (idToken && typeof idToken === 'string') { const parts = idToken.split('.'); if (parts.length >= 2) { try { const payload = JSON.parse( Buffer.from(parts[1], 'base64').toString('utf-8'), ) as Record; if (typeof payload.sub === 'string') accountId = payload.sub; if (typeof payload.email === 'string') email = payload.email; const auth = payload['https://api.openai.com/auth'] as | Record | undefined; if (auth) { if (typeof auth.chatgpt_plan_type === 'string') { planType = auth.chatgpt_plan_type; } if (typeof auth.chatgpt_subscription_active_until === 'string') { subscriptionUntil = auth.chatgpt_subscription_active_until; subscriptionSource = 'jwt-cache'; } if (typeof auth.chatgpt_subscription_last_checked === 'string') { subscriptionLastChecked = auth.chatgpt_subscription_last_checked; } } } catch { /* ignore parse errors */ } } } // Live wham/usage data, written by refreshCodexAccount, takes precedence. // When live status exists, do not surface the stale JWT active_until claim as // a real billing expiry. OpenAI/Auth0 can cache that claim for days. if (live) { liveStatus = toCodexLiveStatusSummary(live); if (typeof live.plan_type === 'string') planType = live.plan_type; if (typeof live.email === 'string') email = live.email; subscriptionLastChecked = live.checked_at; subscriptionUntil = null; subscriptionSource = null; } return { index, accountId, email, planType, subscriptionUntil, subscriptionLastChecked, subscriptionSource, liveStatus, exists: true, }; } export function listClaudeAccounts(): ClaudeAccountSummary[] { const out: ClaudeAccountSummary[] = []; const def = readClaudeAccount(0); if (def) out.push(def); const dir = path.join(settingsHomeDir(), '.claude-accounts'); if (fs.existsSync(dir)) { const entries = fs.readdirSync(dir); const indices = entries .filter((e) => /^\d+$/.test(e)) .map((e) => Number.parseInt(e, 10)) .filter((n) => Number.isFinite(n) && n >= 1) .sort((a, b) => a - b); for (const i of indices) { const acc = readClaudeAccount(i); if (acc) out.push(acc); } } return out; } export function listCodexAccounts(): CodexAccountSummary[] { const out: CodexAccountSummary[] = []; const def = readCodexAccount(0); if (def) out.push(def); const dir = path.join(settingsHomeDir(), '.codex-accounts'); if (fs.existsSync(dir)) { const entries = fs.readdirSync(dir); const indices = entries .filter((e) => /^\d+$/.test(e)) .map((e) => Number.parseInt(e, 10)) .filter((n) => Number.isFinite(n) && n >= 1) .sort((a, b) => a - b); for (const i of indices) { const acc = readCodexAccount(i); if (acc) out.push(acc); } } return out; } function pickEnvValue(content: string, key: string): string | undefined { const re = new RegExp(`^${key}=(.*)$`, 'm'); const match = content.match(re); if (!match) return undefined; return match[1].trim().replace(/^['"]|['"]$/g, ''); } function readEnvFile(): string { const file = envFilePath(); if (!fs.existsSync(file)) return ''; return fs.readFileSync(file, 'utf-8'); } function readEnvOrProcess(key: string): string | undefined { const fromFile = pickEnvValue(readEnvFile(), key); if (fromFile !== undefined) return fromFile; const fromProc = process.env[key]; return fromProc; } export function getModelConfig(): ModelConfigSnapshot { const env = readEnvFile(); const envLookup = (key: string): string | undefined => { const fromFile = pickEnvValue(env, key); if (fromFile !== undefined) return fromFile; return process.env[key]; }; const out: Partial = { agentTypes: { owner: agentTypeForRole('owner', envLookup) ?? 'codex', reviewer: agentTypeForRole('reviewer', envLookup) ?? 'claude-code', arbiter: agentTypeForRole('arbiter', envLookup), }, }; for (const role of ROLE_KEYS) { const model = readEnvOrProcess(`${role}_MODEL`) ?? ''; const effort = readEnvOrProcess(`${role}_EFFORT`) ?? ''; (out as Record)[role.toLowerCase()] = { model, effort, }; } return out as ModelConfigSnapshot; } export interface ModelUpdateInput { owner?: Partial; reviewer?: Partial; arbiter?: Partial; } function setOrInsertEnvLine( content: string, key: string, value: string, ): string { const re = new RegExp(`^${key}=.*$`, 'm'); if (re.test(content)) { return content.replace(re, `${key}=${value}`); } const trimmed = content.replace(/\s*$/, ''); return `${trimmed}\n${key}=${value}\n`; } export function updateModelConfig( input: ModelUpdateInput, ): ModelConfigSnapshot { const file = envFilePath(); let content = ''; if (fs.existsSync(file)) { content = fs.readFileSync(file, 'utf-8'); } const envLookup = (key: string): string | undefined => pickEnvValue(content, key) ?? process.env[key]; for (const role of ROLE_KEYS) { const update = ( input as Record | undefined> )[role.toLowerCase()]; if (!update) continue; if (update.model !== undefined) { content = setOrInsertEnvLine(content, `${role}_MODEL`, update.model); } if (update.effort !== undefined) { const agentType = agentTypeForRole( role.toLowerCase() as 'owner' | 'reviewer' | 'arbiter', envLookup, ); if ( update.effort && agentType && !isEffortSupported(agentType, update.effort) ) { throw new Error( `${role}_EFFORT=${update.effort} is not supported for ${agentType} agents`, ); } content = setOrInsertEnvLine(content, `${role}_EFFORT`, update.effort); } } const tempPath = `${file}.tmp`; fs.writeFileSync(tempPath, content, { mode: 0o600 }); fs.renameSync(tempPath, file); return getModelConfig(); } const CODEX_OAUTH_TOKEN_URL = 'https://auth.openai.com/oauth/token'; const CODEX_OAUTH_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'; const CODEX_USAGE_URL = 'https://chatgpt.com/backend-api/wham/usage'; 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 planStatusPath(index: number): string { if (index === 0) { return path.join(settingsHomeDir(), '.codex', 'plan-status.json'); } return path.join( settingsHomeDir(), '.codex-accounts', String(index), 'plan-status.json', ); } function readCodexLiveStatus(index: number): CodexLiveStatus | null { return readJson(planStatusPath(index)); } function writeCodexLiveStatus(index: number, status: CodexLiveStatus): void { const file = planStatusPath(index); fs.mkdirSync(path.dirname(file), { recursive: true }); const tempPath = `${file}.tmp`; fs.writeFileSync(tempPath, `${JSON.stringify(status, null, 2)}\n`, { mode: 0o600, }); fs.renameSync(tempPath, file); } async function fetchCodexLiveUsage( accessToken: string, ): Promise { try { const res = await fetch(CODEX_USAGE_URL, { method: 'GET', headers: { authorization: `Bearer ${accessToken}` }, }); if (!res.ok) return null; const json = (await res.json()) as unknown; return normalizeCodexLiveStatus(json, new Date().toISOString()); } catch { return null; } } 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); // Hit chatgpt.com/backend-api/wham/usage to read the live plan, rate limit, // and spend-control state. The JWT id_token's chatgpt_plan_type / // *_active_until / *_last_checked claims are cached on OpenAI's Auth0 side and // don't refresh on every OAuth refresh_token grant, so the dashboard must not // present that cached expiry as live billing truth. const accessToken = payload.access_token ?? data?.tokens?.access_token ?? null; if (accessToken) { const live = await fetchCodexLiveUsage(accessToken); if (live) { writeCodexLiveStatus(index, live); } } const summary = readCodexAccount(index); if (!summary) throw new Error('failed to re-read codex account after refresh'); return summary; } /** * settings-store lists codex accounts in an order that includes the default * `~/.codex/auth.json` as index 0 plus `~/.codex-accounts/{N}` as index N. * The rotation array (codex-token-rotation) only loads `~/.codex-accounts/{N}` * when those dirs exist (it ignores `~/.codex/auth.json` in that mode), so its * array indices are off-by-one vs. the settings indices. Translate via path. */ export function getActiveCodexSettingsIndex(): number | null { const activePath = getActiveCodexAuthPath(); if (!activePath) return null; // Walk the same listing order as listCodexAccounts() to find the matching // settings-store index. if (codexAuthPath(0) === activePath && fs.existsSync(activePath)) { return 0; } const dir = path.join(settingsHomeDir(), '.codex-accounts'); if (fs.existsSync(dir)) { const indices = fs .readdirSync(dir) .filter((e) => /^\d+$/.test(e)) .map((e) => Number.parseInt(e, 10)) .filter((n) => Number.isFinite(n) && n >= 1) .sort((a, b) => a - b); for (const i of indices) { if (codexAuthPath(i) === activePath) return i; } } return null; } export function setActiveCodexSettingsIndex(settingsIndex: number): void { const file = codexAuthPath(settingsIndex); if (!fs.existsSync(file)) { throw new Error( `codex auth.json not found for settings index ${settingsIndex}`, ); } const rotationIndex = findCodexAccountIndexByAuthPath(file); if (rotationIndex === null) { throw new Error( `codex switch: settings #${settingsIndex} (${file}) is not part of the rotation pool. ` + `Rotation only manages accounts under ~/.codex-accounts/. ` + `If you want to use ~/.codex/auth.json directly, remove the ~/.codex-accounts dir first.`, ); } setRotationIndex(rotationIndex); } 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 codexConfigFile(): string { return path.join(settingsHomeDir(), '.codex', 'config.toml'); } function claudeSettingsPath(): string { return path.join(settingsHomeDir(), '.claude', 'settings.json'); } function readCodexFastMode(): boolean { return readCodexFeatureFromFile(codexConfigFile(), 'fast_mode'); } function writeCodexFastMode(value: boolean): void { writeCodexFeatureToFile(codexConfigFile(), 'fast_mode', value); } function readClaudeFastMode(): boolean { return readHostClaudeFastMode(); } function writeClaudeFastMode(value: boolean): void { const file = claudeSettingsPath(); fs.mkdirSync(path.dirname(file), { recursive: true }); let data: Record = {}; if (fs.existsSync(file)) { try { data = JSON.parse(fs.readFileSync(file, 'utf-8')) as Record< string, unknown >; } catch { data = {}; } } data.fastMode = value; const tempPath = `${file}.tmp`; fs.writeFileSync(tempPath, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600, }); fs.renameSync(tempPath, file); } export function getFastMode(): FastModeSnapshot { return { codex: readCodexFastMode(), claude: readClaudeFastMode(), }; } export function updateFastMode( input: Partial, ): FastModeSnapshot { if (typeof input.codex === 'boolean') writeCodexFastMode(input.codex); if (typeof input.claude === 'boolean') writeClaudeFastMode(input.claude); return getFastMode(); } function readCodexGoals(): boolean { const fromConfig = readCodexFeatureFromFile(codexConfigFile(), 'goals'); if (fromConfig) return true; // Legacy .env opt-in kept for backward compatibility until migrated. return readEnvOrProcess('CODEX_GOALS') === 'true'; } function writeCodexGoals(value: boolean): void { writeCodexFeatureToFile(codexConfigFile(), 'goals', value); const file = envFilePath(); if (!fs.existsSync(file)) return; const content = fs.readFileSync(file, 'utf-8'); if (!/^CODEX_GOALS=.*$/m.test(content)) return; const updated = content .split('\n') .filter((line) => !/^CODEX_GOALS=.*$/.test(line)) .join('\n') .replace(/\n+$/, ''); const tempPath = `${file}.tmp`; fs.writeFileSync(tempPath, updated ? `${updated}\n` : '', { mode: 0o600 }); fs.renameSync(tempPath, file); } export function getCodexFeatures(): CodexFeatureSnapshot { return { goals: readCodexGoals(), }; } export function updateCodexFeatures( input: Partial, ): CodexFeatureSnapshot { if (typeof input.goals === 'boolean') writeCodexGoals(input.goals); return getCodexFeatures(); } export function removeAccountDirectory( provider: 'claude' | 'codex', index: number, ): void { if (!Number.isFinite(index) || index < 1) { throw new Error('cannot remove default account (index 0)'); } const baseDir = provider === 'claude' ? path.join(settingsHomeDir(), '.claude-accounts', String(index)) : path.join(settingsHomeDir(), '.codex-accounts', String(index)); if (!fs.existsSync(baseDir)) { throw new Error(`account directory not found: ${baseDir}`); } fs.rmSync(baseDir, { recursive: true, force: true }); } export function addClaudeAccountFromToken(token: string): { index: number; accountId: string | null; } { const trimmed = token.trim(); if (!trimmed) throw new Error('empty token'); const parts = trimmed.split('.'); if (parts.length < 2) { throw new Error('invalid OAuth token: expected JWT format'); } let payload: Record; try { payload = JSON.parse( Buffer.from(parts[1], 'base64').toString('utf-8'), ) as Record; } catch { throw new Error('invalid OAuth token: payload not parseable'); } const exp = typeof payload.exp === 'number' ? payload.exp * 1000 : 0; const accountId = typeof payload.sub === 'string' ? payload.sub : null; const baseDir = path.join(settingsHomeDir(), '.claude-accounts'); if (!fs.existsSync(baseDir)) { fs.mkdirSync(baseDir, { recursive: true, mode: 0o700 }); } let nextIndex = 1; while ( fs.existsSync(path.join(baseDir, String(nextIndex), '.credentials.json')) ) { nextIndex += 1; } const dir = path.join(baseDir, String(nextIndex)); fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); const creds = { claudeAiOauth: { accessToken: trimmed, refreshToken: '', expiresAt: exp, scopes: ['user:profile', 'user:inference'], }, }; const credsPath = path.join(dir, '.credentials.json'); const tempPath = `${credsPath}.tmp`; fs.writeFileSync(tempPath, JSON.stringify(creds, null, 2), { mode: 0o600 }); fs.renameSync(tempPath, credsPath); return { index: nextIndex, accountId }; }