diff --git a/apps/dashboard/src/SettingsPanel.tsx b/apps/dashboard/src/SettingsPanel.tsx index 0f41c53..96b8bb3 100644 --- a/apps/dashboard/src/SettingsPanel.tsx +++ b/apps/dashboard/src/SettingsPanel.tsx @@ -20,6 +20,12 @@ import { updateFastMode, updateModels, } from './api'; +import { + formatDateTime, + formatJwtCacheExpiry, + formatLiveStatusBadge, + formatUsageBadge, +} from './codexAccountBadges'; import { type Locale, type Messages } from './i18n'; import { MoaSettingsPanel } from './MoaSettingsPanel'; import { RuntimeInventorySettings } from './RuntimeInventorySettings'; @@ -410,37 +416,6 @@ function CodexFeatureSettings() { ); } -function formatExpiry( - iso: string | null, -): { label: string; cls: string } | null { - if (!iso) return null; - const dt = new Date(iso); - if (Number.isNaN(dt.getTime())) return null; - const days = (dt.getTime() - Date.now()) / 86400000; - const dateStr = dt.toLocaleDateString('ko-KR', { - year: '2-digit', - month: '2-digit', - day: '2-digit', - }); - if (days < 0) { - const ago = Math.ceil(-days); - return { - label: `결제 만료 ${dateStr} (${ago}일 전)`, - cls: 'is-expired', - }; - } - if (days < 7) { - return { - label: `결제 ${dateStr}까지 (${Math.floor(days)}일)`, - cls: 'is-soon', - }; - } - return { - label: `결제 ${dateStr}까지 (${Math.floor(days)}일)`, - cls: 'is-active', - }; -} - function AccountSettings() { const [data, setData] = useState(null); const [busy, setBusy] = useState(false); @@ -702,8 +677,9 @@ function CodexAccounts({ )}

- OAuth 토큰은 6시간마다 자동 갱신됩니다. plan 변경/해지가 즉시 반영되게 - 하려면 수동으로 “전체 갱신”을 누르세요. + “갱신”은 chatgpt.com wham/usage live plan·rate limit·지출 제한을 + 저장합니다. JWT 만료일 캐시는 live 값이 없을 때만 fallback으로 + 표시합니다.

); @@ -726,7 +702,14 @@ function CodexAccountRow({ onSwitch: (index: number) => void; perRowBusy: string | null; }) { - const expiry = formatExpiry(acc.subscriptionUntil); + const liveBadge = formatLiveStatusBadge(acc.liveStatus); + const usageBadge = formatUsageBadge(acc.liveStatus); + const cachedExpiry = acc.liveStatus + ? null + : formatJwtCacheExpiry(acc.subscriptionUntil); + const checkedAt = acc.subscriptionLastChecked + ? formatDateTime(acc.subscriptionLastChecked) + : null; const refreshing = perRowBusy === `refresh:${acc.index}`; const switching = perRowBusy === `switch:${acc.index}`; @@ -746,16 +729,39 @@ function CodexAccountRow({ {acc.planType ?? 'unknown'} - {expiry ? ( + {liveBadge ? ( + {liveBadge.label} + + ) : cachedExpiry ? ( + + {cachedExpiry.label} + + ) : null} + {usageBadge ? ( + + {usageBadge.label} + + ) : null} + {checkedAt ? ( + - {expiry.label} + 갱신 {checkedAt} ) : null} @@ -764,7 +770,7 @@ function CodexAccountRow({ className="settings-secondary" disabled={busy || perRowBusy !== null} onClick={() => onRefresh(acc.index)} - title="OAuth 토큰을 다시 받아 구독 상태를 갱신합니다" + title="OAuth 토큰을 다시 받고 wham/usage live plan·limit 상태를 갱신합니다" type="button" > {refreshing ? '갱신중…' : '갱신'} diff --git a/apps/dashboard/src/api.ts b/apps/dashboard/src/api.ts index 02ffd57..842c5ed 100644 --- a/apps/dashboard/src/api.ts +++ b/apps/dashboard/src/api.ts @@ -362,6 +362,48 @@ export interface ClaudeAccountSummary { exists: boolean; } +export interface CodexRateLimitWindowSummary { + limitWindowSeconds: number | null; + resetAfterSeconds: number | null; + resetAt: string | null; + usedPercent: number | null; +} + +export interface CodexRateLimitSummary { + allowed: boolean | null; + limitReached: boolean | null; + primaryWindow: CodexRateLimitWindowSummary | null; + secondaryWindow: CodexRateLimitWindowSummary | null; +} + +export interface CodexAdditionalRateLimitSummary { + limitName: string | null; + meteredFeature: string | null; + rateLimit: CodexRateLimitSummary | null; +} + +export interface CodexCreditsSummary { + hasCredits: boolean | null; + overageLimitReached: boolean | null; + unlimited: boolean | null; +} + +export interface CodexSpendControlSummary { + reached: boolean | null; +} + +export interface CodexLiveStatusSummary { + checkedAt: string; + source: 'wham/usage'; + planType: string | null; + email: string | null; + rateLimit: CodexRateLimitSummary | null; + rateLimitReachedType: string | null; + additionalRateLimits: CodexAdditionalRateLimitSummary[]; + credits: CodexCreditsSummary | null; + spendControl: CodexSpendControlSummary | null; +} + export interface CodexAccountSummary { index: number; accountId: string | null; @@ -369,6 +411,8 @@ export interface CodexAccountSummary { planType: string | null; subscriptionUntil: string | null; subscriptionLastChecked: string | null; + subscriptionSource: 'jwt-cache' | null; + liveStatus: CodexLiveStatusSummary | null; exists: boolean; } diff --git a/apps/dashboard/src/codexAccountBadges.ts b/apps/dashboard/src/codexAccountBadges.ts new file mode 100644 index 0000000..6398380 --- /dev/null +++ b/apps/dashboard/src/codexAccountBadges.ts @@ -0,0 +1,120 @@ +import { + type CodexLiveStatusSummary, + type CodexRateLimitSummary, + type CodexRateLimitWindowSummary, +} from './api'; + +export function formatDateTime(iso: string): string | null { + const dt = new Date(iso); + if (Number.isNaN(dt.getTime())) return null; + return dt.toLocaleString('ko-KR', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); +} + +export function formatJwtCacheExpiry( + iso: string | null, +): { label: string; cls: string; title: string } | null { + if (!iso) return null; + const dt = new Date(iso); + if (Number.isNaN(dt.getTime())) return null; + const days = (dt.getTime() - Date.now()) / 86400000; + const dateStr = dt.toLocaleDateString('ko-KR', { + year: '2-digit', + month: '2-digit', + day: '2-digit', + }); + const suffix = + days < 0 ? `${Math.ceil(-days)}일 전` : `${Math.floor(days)}일 남음`; + return { + label: `JWT 캐시 ${dateStr}`, + cls: 'is-stale', + title: `OpenAI/Auth0 JWT 캐시 만료일입니다. live wham/usage 갱신값이 있으면 이 값은 표시하지 않습니다. (${suffix})`, + }; +} + +function formatWindowName(seconds: number | null | undefined): string { + if (!seconds || !Number.isFinite(seconds)) return 'limit'; + if (seconds % 86400 === 0) return `${Math.round(seconds / 86400)}d`; + if (seconds % 3600 === 0) return `${Math.round(seconds / 3600)}h`; + if (seconds % 60 === 0) return `${Math.round(seconds / 60)}m`; + return `${seconds}s`; +} + +function formatPercent(value: number | null | undefined): string { + if (typeof value !== 'number' || !Number.isFinite(value)) return '?%'; + return `${Math.round(value)}%`; +} + +function formatRateLimitWindow( + window: CodexRateLimitWindowSummary | null, +): string | null { + if (!window) return null; + return `${formatWindowName(window.limitWindowSeconds)} ${formatPercent(window.usedPercent)}`; +} + +export function formatLiveStatusBadge( + live: CodexLiveStatusSummary | null, +): { label: string; cls: string; title: string } | null { + if (!live) return null; + const limits = [ + live.rateLimit, + ...live.additionalRateLimits.map((limit) => limit.rateLimit), + ].filter((limit): limit is CodexRateLimitSummary => limit !== null); + const anyReached = limits.some((limit) => limit.limitReached === true); + const anyBlocked = limits.some((limit) => limit.allowed === false); + const checkedAt = formatDateTime(live.checkedAt) ?? live.checkedAt; + if (anyReached || anyBlocked || live.rateLimitReachedType) { + return { + label: 'live 제한 도달', + cls: 'is-expired', + title: `wham/usage live 확인: ${checkedAt}${ + live.rateLimitReachedType ? ` · ${live.rateLimitReachedType}` : '' + }`, + }; + } + if (live.spendControl?.reached || live.credits?.overageLimitReached) { + return { + label: 'live 지출 제한', + cls: 'is-expired', + title: `wham/usage live 확인: ${checkedAt}`, + }; + } + if (limits.some((limit) => limit.allowed === true)) { + return { + label: 'live 사용 가능', + cls: 'is-active', + title: `wham/usage live 확인: ${checkedAt}`, + }; + } + return { + label: 'live 확인됨', + cls: 'is-soon', + title: `wham/usage live 확인: ${checkedAt}`, + }; +} + +export function formatUsageBadge( + live: CodexLiveStatusSummary | null, +): { label: string; title: string } | null { + if (!live?.rateLimit) return null; + const primary = formatRateLimitWindow(live.rateLimit.primaryWindow); + const secondary = formatRateLimitWindow(live.rateLimit.secondaryWindow); + const label = [primary, secondary].filter(Boolean).join(' · '); + if (!label) return null; + const resetParts = [ + live.rateLimit.primaryWindow?.resetAt + ? `primary reset ${formatDateTime(live.rateLimit.primaryWindow.resetAt) ?? live.rateLimit.primaryWindow.resetAt}` + : null, + live.rateLimit.secondaryWindow?.resetAt + ? `secondary reset ${formatDateTime(live.rateLimit.secondaryWindow.resetAt) ?? live.rateLimit.secondaryWindow.resetAt}` + : null, + ].filter(Boolean); + return { + label, + title: resetParts.join(' · ') || 'wham/usage live rate_limit', + }; +} diff --git a/apps/dashboard/src/styles.css b/apps/dashboard/src/styles.css index 70523bf..83f3c24 100644 --- a/apps/dashboard/src/styles.css +++ b/apps/dashboard/src/styles.css @@ -1127,6 +1127,16 @@ button:disabled { color: var(--status-critical, #e0644b); } +.settings-account-badge.is-muted { + background: rgba(255, 255, 255, 0.05); + color: var(--muted-soft); +} + +.settings-account-badge.is-stale { + background: rgba(148, 169, 210, 0.14); + color: var(--muted-soft); +} + .settings-account-meta { font-size: 12px; color: var(--bg-ink); diff --git a/scripts/dashboard-ux.ts b/scripts/dashboard-ux.ts index 737501c..ee93733 100644 --- a/scripts/dashboard-ux.ts +++ b/scripts/dashboard-ux.ts @@ -425,8 +425,30 @@ async function handleMockApi(route: Route, state: MockApiState) { accountId: 'acct_test', email: 'codex@example.com', planType: 'plus', - subscriptionUntil: '2099-01-01T00:00:00.000Z', + subscriptionUntil: null, subscriptionLastChecked: '2026-01-01T00:00:00.000Z', + subscriptionSource: null, + liveStatus: { + checkedAt: '2026-01-01T00:00:00.000Z', + source: 'wham/usage', + planType: 'plus', + email: 'codex@example.com', + rateLimit: { + allowed: true, + limitReached: false, + primaryWindow: { + limitWindowSeconds: 18000, + resetAfterSeconds: 3600, + resetAt: '2026-01-01T01:00:00.000Z', + usedPercent: 8, + }, + secondaryWindow: null, + }, + rateLimitReachedType: null, + additionalRateLimits: [], + credits: null, + spendControl: null, + }, exists: true, }, ], diff --git a/src/codex-live-status.ts b/src/codex-live-status.ts new file mode 100644 index 0000000..4c4b636 --- /dev/null +++ b/src/codex-live-status.ts @@ -0,0 +1,245 @@ +export interface CodexRateLimitWindowSummary { + limitWindowSeconds: number | null; + resetAfterSeconds: number | null; + resetAt: string | null; + usedPercent: number | null; +} + +export interface CodexRateLimitSummary { + allowed: boolean | null; + limitReached: boolean | null; + primaryWindow: CodexRateLimitWindowSummary | null; + secondaryWindow: CodexRateLimitWindowSummary | null; +} + +export interface CodexAdditionalRateLimitSummary { + limitName: string | null; + meteredFeature: string | null; + rateLimit: CodexRateLimitSummary | null; +} + +export interface CodexCreditsSummary { + hasCredits: boolean | null; + overageLimitReached: boolean | null; + unlimited: boolean | null; +} + +export interface CodexSpendControlSummary { + reached: boolean | null; +} + +export interface CodexLiveStatusSummary { + checkedAt: string; + source: 'wham/usage'; + planType: string | null; + email: string | null; + rateLimit: CodexRateLimitSummary | null; + rateLimitReachedType: string | null; + additionalRateLimits: CodexAdditionalRateLimitSummary[]; + credits: CodexCreditsSummary | null; + spendControl: CodexSpendControlSummary | null; +} + +interface CodexLiveRateLimitWindow { + limit_window_seconds?: number | null; + reset_after_seconds?: number | null; + reset_at?: number | null; + used_percent?: number | null; +} + +interface CodexLiveRateLimit { + allowed?: boolean | null; + limit_reached?: boolean | null; + primary_window?: CodexLiveRateLimitWindow | null; + secondary_window?: CodexLiveRateLimitWindow | null; +} + +interface CodexAdditionalLiveRateLimit { + limit_name?: string | null; + metered_feature?: string | null; + rate_limit?: CodexLiveRateLimit | null; +} + +interface CodexLiveCredits { + has_credits?: boolean | null; + overage_limit_reached?: boolean | null; + unlimited?: boolean | null; +} + +interface CodexLiveSpendControl { + reached?: boolean | null; +} + +export interface CodexLiveStatus { + checked_at: string; + plan_type?: string | null; + email?: string | null; + rate_limit?: CodexLiveRateLimit | null; + rate_limit_reached_type?: string | null; + additional_rate_limits?: CodexAdditionalLiveRateLimit[]; + credits?: CodexLiveCredits | null; + spend_control?: CodexLiveSpendControl | null; +} + +function recordOrNull(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} + +function stringOrNull(value: unknown): string | null { + return typeof value === 'string' ? value : null; +} + +function numberOrNull(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + +function booleanOrNull(value: unknown): boolean | null { + return typeof value === 'boolean' ? value : null; +} + +function epochSecondsToIso(value: number | null | undefined): string | null { + if (typeof value !== 'number' || !Number.isFinite(value)) return null; + return new Date(value * 1000).toISOString(); +} + +function normalizeRateLimitWindow( + value: unknown, +): CodexLiveRateLimitWindow | null { + const record = recordOrNull(value); + if (!record) return null; + return { + limit_window_seconds: numberOrNull(record.limit_window_seconds), + reset_after_seconds: numberOrNull(record.reset_after_seconds), + reset_at: numberOrNull(record.reset_at), + used_percent: numberOrNull(record.used_percent), + }; +} + +function normalizeRateLimit(value: unknown): CodexLiveRateLimit | null { + const record = recordOrNull(value); + if (!record) return null; + return { + allowed: booleanOrNull(record.allowed), + limit_reached: booleanOrNull(record.limit_reached), + primary_window: normalizeRateLimitWindow(record.primary_window), + secondary_window: normalizeRateLimitWindow(record.secondary_window), + }; +} + +function normalizeAdditionalRateLimits( + value: unknown, +): CodexAdditionalLiveRateLimit[] { + if (!Array.isArray(value)) return []; + return value + .map((item): CodexAdditionalLiveRateLimit | null => { + const record = recordOrNull(item); + if (!record) return null; + return { + limit_name: stringOrNull(record.limit_name), + metered_feature: stringOrNull(record.metered_feature), + rate_limit: normalizeRateLimit(record.rate_limit), + }; + }) + .filter((item): item is CodexAdditionalLiveRateLimit => item !== null); +} + +function normalizeCredits(value: unknown): CodexLiveCredits | null { + const record = recordOrNull(value); + if (!record) return null; + return { + has_credits: booleanOrNull(record.has_credits), + overage_limit_reached: booleanOrNull(record.overage_limit_reached), + unlimited: booleanOrNull(record.unlimited), + }; +} + +function normalizeSpendControl(value: unknown): CodexLiveSpendControl | null { + const record = recordOrNull(value); + if (!record) return null; + return { + reached: booleanOrNull(record.reached), + }; +} + +export function normalizeCodexLiveStatus( + value: unknown, + checkedAt: string, +): CodexLiveStatus | null { + const record = recordOrNull(value); + if (!record) return null; + return { + checked_at: checkedAt, + plan_type: stringOrNull(record.plan_type), + email: stringOrNull(record.email), + rate_limit: normalizeRateLimit(record.rate_limit), + rate_limit_reached_type: stringOrNull(record.rate_limit_reached_type), + additional_rate_limits: normalizeAdditionalRateLimits( + record.additional_rate_limits, + ), + credits: normalizeCredits(record.credits), + spend_control: normalizeSpendControl(record.spend_control), + }; +} + +function toRateLimitWindowSummary( + window: CodexLiveRateLimitWindow | null | undefined, +): CodexRateLimitWindowSummary | null { + if (!window) return null; + return { + limitWindowSeconds: window.limit_window_seconds ?? null, + resetAfterSeconds: window.reset_after_seconds ?? null, + resetAt: epochSecondsToIso(window.reset_at), + usedPercent: window.used_percent ?? null, + }; +} + +function toRateLimitSummary( + rateLimit: CodexLiveRateLimit | null | undefined, +): CodexRateLimitSummary | null { + if (!rateLimit) return null; + return { + allowed: rateLimit.allowed ?? null, + limitReached: rateLimit.limit_reached ?? null, + primaryWindow: toRateLimitWindowSummary(rateLimit.primary_window), + secondaryWindow: toRateLimitWindowSummary(rateLimit.secondary_window), + }; +} + +export function toCodexLiveStatusSummary( + status: CodexLiveStatus, +): CodexLiveStatusSummary { + return { + checkedAt: status.checked_at, + source: 'wham/usage', + planType: status.plan_type ?? null, + email: status.email ?? null, + rateLimit: toRateLimitSummary(status.rate_limit), + rateLimitReachedType: status.rate_limit_reached_type ?? null, + additionalRateLimits: (status.additional_rate_limits ?? []).map( + (limit) => ({ + limitName: limit.limit_name ?? null, + meteredFeature: limit.metered_feature ?? null, + rateLimit: toRateLimitSummary(limit.rate_limit), + }), + ), + credits: status.credits + ? { + hasCredits: status.credits.has_credits ?? null, + overageLimitReached: status.credits.overage_limit_reached ?? null, + unlimited: status.credits.unlimited ?? null, + } + : null, + spendControl: status.spend_control + ? { + reached: status.spend_control.reached ?? null, + } + : null, + }; +} diff --git a/src/settings-store.test.ts b/src/settings-store.test.ts index 2f965e9..c9353bd 100644 --- a/src/settings-store.test.ts +++ b/src/settings-store.test.ts @@ -2,33 +2,63 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { getCodexFeatures, updateCodexFeatures } from './settings-store.js'; +import { + getCodexFeatures, + listCodexAccounts, + refreshCodexAccount, + updateCodexFeatures, +} from './settings-store.js'; describe('settings-store Codex features', () => { let tempDir: string; let previousCwd: string; let previousCodexGoals: string | undefined; + let previousHome: string | undefined; + let previousSettingsHome: string | undefined; + let previousFetch: typeof fetch; beforeEach(() => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-settings-')); previousCwd = process.cwd(); previousCodexGoals = process.env.CODEX_GOALS; + previousHome = process.env.HOME; + previousSettingsHome = process.env.EJCLAW_SETTINGS_HOME; + previousFetch = globalThis.fetch; delete process.env.CODEX_GOALS; + process.env.HOME = tempDir; + process.env.EJCLAW_SETTINGS_HOME = tempDir; process.chdir(tempDir); }); afterEach(() => { process.chdir(previousCwd); + globalThis.fetch = previousFetch; if (previousCodexGoals === undefined) { delete process.env.CODEX_GOALS; } else { process.env.CODEX_GOALS = previousCodexGoals; } + if (previousHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = previousHome; + } + if (previousSettingsHome === undefined) { + delete process.env.EJCLAW_SETTINGS_HOME; + } else { + process.env.EJCLAW_SETTINGS_HOME = previousSettingsHome; + } fs.rmSync(tempDir, { recursive: true, force: true }); }); + function fakeJwt(payload: Record): string { + const encode = (value: Record) => + Buffer.from(JSON.stringify(value)).toString('base64'); + return `${encode({ alg: 'none', typ: 'JWT' })}.${encode(payload)}.`; + } + it('stores the Codex goals opt-in in the EJClaw .env file', () => { expect(getCodexFeatures()).toEqual({ goals: false }); @@ -42,4 +72,123 @@ describe('settings-store Codex features', () => { 'CODEX_GOALS=false', ); }); + + it('stores wham usage status and does not expose stale JWT expiry as live billing', async () => { + const accountDir = path.join(tempDir, '.codex-accounts', '1'); + fs.mkdirSync(accountDir, { recursive: true }); + const staleJwt = fakeJwt({ + email: 'cached@example.com', + sub: 'acct_cached', + 'https://api.openai.com/auth': { + chatgpt_plan_type: 'free', + chatgpt_subscription_active_until: '2026-01-01T00:00:00.000Z', + chatgpt_subscription_last_checked: '2026-01-01T00:00:00.000Z', + }, + }); + fs.writeFileSync( + path.join(accountDir, 'auth.json'), + `${JSON.stringify( + { + tokens: { + access_token: 'old-access', + id_token: staleJwt, + refresh_token: 'refresh-token', + }, + }, + null, + 2, + )}\n`, + ); + + const resetAt = 1_779_300_000; + const fetchMock = vi.fn(async (input: string | URL | Request) => { + const url = String(input); + if (url === 'https://auth.openai.com/oauth/token') { + return new Response( + JSON.stringify({ + access_token: 'live-access', + id_token: staleJwt, + refresh_token: 'next-refresh-token', + }), + { status: 200 }, + ); + } + if (url === 'https://chatgpt.com/backend-api/wham/usage') { + return new Response( + JSON.stringify({ + email: 'live@example.com', + plan_type: 'pro', + rate_limit: { + allowed: true, + limit_reached: false, + primary_window: { + limit_window_seconds: 18000, + reset_after_seconds: 600, + reset_at: resetAt, + used_percent: 21, + }, + secondary_window: { + limit_window_seconds: 604800, + reset_after_seconds: 3600, + reset_at: resetAt + 600, + used_percent: 7, + }, + }, + rate_limit_reached_type: null, + credits: { + has_credits: false, + overage_limit_reached: false, + unlimited: false, + }, + spend_control: { reached: false }, + }), + { status: 200 }, + ); + } + return new Response('not found', { status: 404 }); + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const refreshed = await refreshCodexAccount(1); + + expect(refreshed.planType).toBe('pro'); + expect(refreshed.email).toBe('live@example.com'); + expect(refreshed.subscriptionUntil).toBeNull(); + expect(refreshed.subscriptionSource).toBeNull(); + expect(refreshed.liveStatus).toMatchObject({ + source: 'wham/usage', + planType: 'pro', + rateLimit: { + allowed: true, + limitReached: false, + primaryWindow: { + limitWindowSeconds: 18000, + resetAfterSeconds: 600, + resetAt: new Date(resetAt * 1000).toISOString(), + usedPercent: 21, + }, + }, + credits: { + hasCredits: false, + overageLimitReached: false, + unlimited: false, + }, + spendControl: { reached: false }, + }); + + const sidecar = JSON.parse( + fs.readFileSync(path.join(accountDir, 'plan-status.json'), 'utf-8'), + ) as Record; + expect(sidecar).toMatchObject({ + plan_type: 'pro', + rate_limit: { + primary_window: { used_percent: 21 }, + }, + }); + expect(listCodexAccounts()[0]).toMatchObject({ + planType: 'pro', + subscriptionUntil: null, + liveStatus: { source: 'wham/usage' }, + }); + }); }); diff --git a/src/settings-store.ts b/src/settings-store.ts index 3ecd232..c62e771 100644 --- a/src/settings-store.ts +++ b/src/settings-store.ts @@ -20,6 +20,21 @@ import { getActiveCodexAuthPath, setCurrentCodexAccountIndex as setRotationIndex, } from './codex-token-rotation.js'; +import { + normalizeCodexLiveStatus, + toCodexLiveStatusSummary, + type CodexLiveStatus, + type CodexLiveStatusSummary, +} from './codex-live-status.js'; + +export type { + CodexAdditionalRateLimitSummary, + CodexCreditsSummary, + CodexLiveStatusSummary, + CodexRateLimitSummary, + CodexRateLimitWindowSummary, + CodexSpendControlSummary, +} from './codex-live-status.js'; export interface ClaudeAccountSummary { index: number; @@ -37,6 +52,8 @@ export interface CodexAccountSummary { planType: string | null; subscriptionUntil: string | null; subscriptionLastChecked: string | null; + subscriptionSource: 'jwt-cache' | null; + liveStatus: CodexLiveStatusSummary | null; exists: boolean; } @@ -75,12 +92,16 @@ function readJson(file: string): T | null { } } +function settingsHomeDir(): string { + return process.env.EJCLAW_SETTINGS_HOME || os.homedir(); +} + function claudeCredsPath(index: number): string { if (index === 0) { - return path.join(os.homedir(), '.claude', '.credentials.json'); + return path.join(settingsHomeDir(), '.claude', '.credentials.json'); } return path.join( - os.homedir(), + settingsHomeDir(), '.claude-accounts', String(index), '.credentials.json', @@ -89,9 +110,14 @@ function claudeCredsPath(index: number): string { function codexAuthPath(index: number): string { if (index === 0) { - return path.join(os.homedir(), '.codex', 'auth.json'); + return path.join(settingsHomeDir(), '.codex', 'auth.json'); } - return path.join(os.homedir(), '.codex-accounts', String(index), 'auth.json'); + return path.join( + settingsHomeDir(), + '.codex-accounts', + String(index), + 'auth.json', + ); } function readClaudeAccount(index: number): ClaudeAccountSummary | null { @@ -126,6 +152,8 @@ function readCodexAccount(index: number): CodexAccountSummary | 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('.'); @@ -145,6 +173,7 @@ function readCodexAccount(index: number): CodexAccountSummary | null { } 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; @@ -156,10 +185,15 @@ function readCodexAccount(index: number): CodexAccountSummary | null { } } // 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, @@ -168,6 +202,8 @@ function readCodexAccount(index: number): CodexAccountSummary | null { planType, subscriptionUntil, subscriptionLastChecked, + subscriptionSource, + liveStatus, exists: true, }; } @@ -176,7 +212,7 @@ export function listClaudeAccounts(): ClaudeAccountSummary[] { const out: ClaudeAccountSummary[] = []; const def = readClaudeAccount(0); if (def) out.push(def); - const dir = path.join(os.homedir(), '.claude-accounts'); + const dir = path.join(settingsHomeDir(), '.claude-accounts'); if (fs.existsSync(dir)) { const entries = fs.readdirSync(dir); const indices = entries @@ -196,7 +232,7 @@ export function listCodexAccounts(): CodexAccountSummary[] { const out: CodexAccountSummary[] = []; const def = readCodexAccount(0); if (def) out.push(def); - const dir = path.join(os.homedir(), '.codex-accounts'); + const dir = path.join(settingsHomeDir(), '.codex-accounts'); if (fs.existsSync(dir)) { const entries = fs.readdirSync(dir); const indices = entries @@ -309,18 +345,12 @@ interface CodexAuthFile { last_refresh?: string; } -interface CodexLiveStatus { - checked_at: string; - plan_type?: string | null; - email?: string | null; -} - function planStatusPath(index: number): string { if (index === 0) { - return path.join(os.homedir(), '.codex', 'plan-status.json'); + return path.join(settingsHomeDir(), '.codex', 'plan-status.json'); } return path.join( - os.homedir(), + settingsHomeDir(), '.codex-accounts', String(index), 'plan-status.json', @@ -341,24 +371,17 @@ function writeCodexLiveStatus(index: number, status: CodexLiveStatus): void { fs.renameSync(tempPath, file); } -async function fetchCodexLivePlanType( +async function fetchCodexLiveUsage( accessToken: string, -): Promise<{ plan_type?: string; email?: string } | null> { +): 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 { - plan_type?: unknown; - email?: unknown; - }; - return { - plan_type: - typeof json.plan_type === 'string' ? json.plan_type : undefined, - email: typeof json.email === 'string' ? json.email : undefined, - }; + const json = (await res.json()) as unknown; + return normalizeCodexLiveStatus(json, new Date().toISOString()); } catch { return null; } @@ -426,21 +449,17 @@ export async function refreshCodexAccount( }; writeCodexAuthFile(file, updated); - // Hit chatgpt.com/backend-api/wham/usage to read the live plan_type - // (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). Persist the live values to a sidecar - // plan-status.json so readCodexAccount can prefer them. + // 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 fetchCodexLivePlanType(accessToken); + const live = await fetchCodexLiveUsage(accessToken); if (live) { - writeCodexLiveStatus(index, { - checked_at: new Date().toISOString(), - plan_type: live.plan_type ?? null, - email: live.email ?? null, - }); + writeCodexLiveStatus(index, live); } } @@ -465,7 +484,7 @@ export function getActiveCodexSettingsIndex(): number | null { if (codexAuthPath(0) === activePath && fs.existsSync(activePath)) { return 0; } - const dir = path.join(os.homedir(), '.codex-accounts'); + const dir = path.join(settingsHomeDir(), '.codex-accounts'); if (fs.existsSync(dir)) { const indices = fs .readdirSync(dir) @@ -546,11 +565,11 @@ export function stopCodexAccountRefreshLoop(): void { } function codexConfigPath(): string { - return path.join(os.homedir(), '.codex', 'config.toml'); + return path.join(settingsHomeDir(), '.codex', 'config.toml'); } function claudeSettingsPath(): string { - return path.join(os.homedir(), '.claude', 'settings.json'); + return path.join(settingsHomeDir(), '.claude', 'settings.json'); } function readCodexFastMode(): boolean { @@ -677,8 +696,8 @@ export function removeAccountDirectory( } const baseDir = provider === 'claude' - ? path.join(os.homedir(), '.claude-accounts', String(index)) - : path.join(os.homedir(), '.codex-accounts', String(index)); + ? 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}`); } @@ -707,7 +726,7 @@ export function addClaudeAccountFromToken(token: string): { const exp = typeof payload.exp === 'number' ? payload.exp * 1000 : 0; const accountId = typeof payload.sub === 'string' ? payload.sub : null; - const baseDir = path.join(os.homedir(), '.claude-accounts'); + const baseDir = path.join(settingsHomeDir(), '.claude-accounts'); if (!fs.existsSync(baseDir)) { fs.mkdirSync(baseDir, { recursive: true, mode: 0o700 }); } diff --git a/src/web-dashboard-settings-routes.test.ts b/src/web-dashboard-settings-routes.test.ts index f2b99dc..18e376d 100644 --- a/src/web-dashboard-settings-routes.test.ts +++ b/src/web-dashboard-settings-routes.test.ts @@ -208,6 +208,28 @@ function makeCodexAccount(): CodexAccountSummary { planType: 'pro', subscriptionUntil: null, subscriptionLastChecked: '2026-04-28T08:00:00.000Z', + subscriptionSource: null, + liveStatus: { + checkedAt: '2026-04-28T08:00:00.000Z', + source: 'wham/usage', + planType: 'pro', + email: 'codex@example.com', + rateLimit: { + allowed: true, + limitReached: false, + primaryWindow: { + limitWindowSeconds: 18000, + resetAfterSeconds: 1200, + resetAt: '2026-04-28T08:20:00.000Z', + usedPercent: 12, + }, + secondaryWindow: null, + }, + rateLimitReachedType: null, + additionalRateLimits: [], + credits: null, + spendControl: null, + }, exists: true, }; }