From ed86a089ba9102538f2aee07cd47515c7cb498ed Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 23 Jun 2026 22:56:38 -0400 Subject: [PATCH] feat(bar): enumerate ccs/ccsx subscription profiles with per-profile quota Replace single-account native quota collection with per-profile enumeration: read ccs auth (Claude) and ccsx auth (Codex) profile registries plus the bare ~/.codex login, fetch each profile's quota under the existing TTL cache, per-profile circuit breaker and 2.5s summary deadline. Emit surface, profile and is_subscription wire fields; account_id becomes ":". Active profiles (valid token) are live-polled and shown undimmed regardless of default status; profiles without resolvable on-disk credentials are parked (cache-only, dimmed). Claude per-profile credentials are read from disk only -- no macOS Keychain access -- so a profile without a credentials file renders as needs-reauth instead of triggering a keychain prompt. --- src/web-server/routes/bar-routes.ts | 16 + .../usage/native-quota-collector.ts | 859 ++++++++++++++++-- tests/unit/web-server/bar-routes.test.ts | 148 +++ .../web-server/native-quota-collector.test.ts | 430 +++++++++ 4 files changed, 1391 insertions(+), 62 deletions(-) diff --git a/src/web-server/routes/bar-routes.ts b/src/web-server/routes/bar-routes.ts index c6d8af96..3acc5fb9 100644 --- a/src/web-server/routes/bar-routes.ts +++ b/src/web-server/routes/bar-routes.ts @@ -96,6 +96,22 @@ export interface BarSummaryRow { fetchedAt: string; /** True if account token is expired and needs re-authentication */ needsReauth: boolean; + /** + * Native subscription surface: "ccs" (Claude Code) or "ccsx" (Codex). + * Present ONLY on native subscription rows; omitted on CLIProxy pool rows. + */ + surface?: string; + /** + * Native profile name (e.g. "work", "ck", "personal"). + * Present ONLY on native subscription rows; omitted on CLIProxy pool rows. + */ + profile?: string; + /** + * Explicit native-subscription flag. true on all native rows; omitted on + * CLIProxy pool rows (decodes to false/nil). Replaces the brittle + * accountId == "claude-code" heuristic in Swift. + */ + is_subscription?: boolean; /** * Native-only per-window quota breakdown (Claude: 5h/week/opus/sonnet, * Codex: 5h/week). CLIProxy rows OMIT this field so existing decode/encode diff --git a/src/web-server/usage/native-quota-collector.ts b/src/web-server/usage/native-quota-collector.ts index bbddccf1..0d89b3c4 100644 --- a/src/web-server/usage/native-quota-collector.ts +++ b/src/web-server/usage/native-quota-collector.ts @@ -14,10 +14,24 @@ * - circuit breaker stops calling after repeated 429s for a cooldown * - serve-stale-on-failure; only omit a row when there is genuinely no data * - * Claude path: reads native credentials + polls api.anthropic.com/api/oauth/usage. + * Claude path: reads per-profile .credentials.json (file-only, NO keychain) + * and polls api.anthropic.com/api/oauth/usage. If the file is absent the + * profile is emitted as a parked row (paused:true) — never a keychain call. + * * Codex path: PRIMARY = live network (chatgpt.com/backend-api/wham/usage, via * fetchCodexQuota), FALLBACK = local session logs (getCodexLocalQuota), mirroring * the same safety pattern as the Claude path. + * + * Multi-profile: each Claude or Codex profile gets its own ProviderState so a + * 429 on one profile never trips the breaker of another. The active/default + * profile for each surface is live-polled (paused:false); all other profiles are + * cache-only (paused:true, force=false hardcoded) so the 2.5s /summary deadline + * is maintained — at most 2 live upstream calls per /summary regardless of + * profile count. + * + * NO macOS Keychain access anywhere in this module. The old global-default + * Claude reader (readClaudeCredentials) is kept for back-compat but is no longer + * used by the multi-profile path. */ import { @@ -34,6 +48,11 @@ import { getCodexLocalQuota, type CodexLocalQuota } from './codex-local-quota-co import type { ClaudeQuotaResult, CodexQuotaResult } from '../../cliproxy/quota/quota-types'; import type { BarSummaryRow, QuotaWindowDetail } from '../routes/bar-routes'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { getCcsDir } from '../../utils/config-manager'; + // ============================================================================ // Safety constants (concrete, named, module-level) // ============================================================================ @@ -57,16 +76,31 @@ const CB_TRIP_THRESHOLD = 3; /** How long the breaker stays open (zero network) once tripped. */ const CB_COOLDOWN_MS = 900_000; // 15 minutes -const CLAUDE_PROVIDER = 'claude-code'; -const CODEX_PROVIDER = 'codex'; +// Surface identifiers +const SURFACE_CLAUDE = 'ccs'; +const SURFACE_CODEX = 'ccsx'; + +// Provider values on the wire (unchanged from before) +const CLAUDE_NATIVE_PROVIDER = 'claude-code'; +const CODEX_NATIVE_PROVIDER = 'codex'; + +// Keep old names as aliases to avoid breaking the existing global collector path +const CLAUDE_PROVIDER = CLAUDE_NATIVE_PROVIDER; +const CODEX_PROVIDER = CODEX_NATIVE_PROVIDER; // ============================================================================ // Injectable dependencies (tests inject mocks; never live endpoints in CI) // ============================================================================ export interface NativeQuotaDeps { - /** Read the native Claude Code credentials. */ + /** Read the native Claude Code credentials (global default path). */ readCredentials?: () => ClaudeNativeCredentials | null; + /** + * Read credentials for a specific Claude profile (file-only, no keychain). + * Injected so tests never touch real fs or Keychain. + * profile: the profile name (e.g. "work"); returns null when absent/unparseable. + */ + readClaudeCredentialsForProfile?: (profile: string) => ClaudeNativeCredentials | null; /** Fetch Claude quota with a directly-supplied native token. */ fetchClaudeQuota?: (accessToken: string, accountId?: string) => Promise; /** @@ -81,6 +115,20 @@ export interface NativeQuotaDeps { fetchCodexNetworkQuota?: (accountId: string) => Promise; /** Read Codex quota from local session logs (zero network, fallback). */ getCodexQuota?: () => Promise; + /** + * Read the native Codex auth for a profile (file-only, no keychain). + * 'personal' reads ~/.codex/auth.json; other names read codex-instances//auth.json. + * Returns null when absent/unparseable. + */ + readCodexNativeAuth?: (profile: string) => { accessToken: string; accountId: string } | null; + /** Enumerate Claude profile names. Injected so tests never touch real fs. */ + listClaudeProfiles?: () => string[]; + /** Enumerate Codex profile names (including 'personal' for bare ~/.codex). */ + listCodexProfiles?: () => string[]; + /** Resolve the default Claude profile name. */ + defaultClaudeProfile?: () => string | null; + /** Resolve the default Codex profile name. */ + defaultCodexProfile?: () => string | null; /** Clock seam for deterministic backoff/TTL/breaker tests. */ now?: () => number; /** Sleep seam (no real delay in tests). */ @@ -120,13 +168,23 @@ function freshProviderState(): ProviderState { }; } -const claudeState = freshProviderState(); -const codexState = freshProviderState(); +// Per-profile state maps (key = profile name) +const claudeProfileStates = new Map(); +const codexProfileStates = new Map(); + +function getState(map: Map, key: string): ProviderState { + let s = map.get(key); + if (!s) { + s = freshProviderState(); + map.set(key, s); + } + return s; +} /** Reset all module state. Tests call this to avoid cross-test pollution. */ export function resetNativeQuotaState(): void { - Object.assign(claudeState, freshProviderState()); - Object.assign(codexState, freshProviderState()); + claudeProfileStates.clear(); + codexProfileStates.clear(); } // ============================================================================ @@ -252,12 +310,21 @@ function buildClaudeQuotaWindows(quota: ClaudeQuotaResult): QuotaWindowDetail[] return windows; } -function buildClaudeRow(quota: ClaudeQuotaResult, tier: string | null, now: number): BarSummaryRow { +function buildClaudeRow( + quota: ClaudeQuotaResult, + tier: string | null, + now: number, + surface: string, + profile: string +): BarSummaryRow { const quotaWindows = buildClaudeQuotaWindows(quota); return { - account_id: CLAUDE_PROVIDER, - provider: CLAUDE_PROVIDER, - displayName: 'Claude Code', + account_id: `${surface}:${profile}`, + provider: CLAUDE_NATIVE_PROVIDER, + surface, + profile, + is_subscription: true, + displayName: profile, tier, paused: false, quota_percentage: deriveClaudeQuotaPercentage(quota), @@ -288,12 +355,20 @@ function buildCodexQuotaWindows(quota: CodexLocalQuota): QuotaWindowDetail[] { })); } -function buildCodexRow(quota: CodexLocalQuota, now: number): BarSummaryRow { +function buildCodexRow( + quota: CodexLocalQuota, + now: number, + surface: string, + profile: string +): BarSummaryRow { const quotaWindows = buildCodexQuotaWindows(quota); return { - account_id: CODEX_PROVIDER, - provider: CODEX_PROVIDER, - displayName: 'Codex', + account_id: `${surface}:${profile}`, + provider: CODEX_NATIVE_PROVIDER, + surface, + profile, + is_subscription: true, + displayName: profile, tier: quota.tier, paused: false, quota_percentage: quota.quotaPercentage, @@ -321,7 +396,12 @@ function buildCodexRow(quota: CodexLocalQuota, now: number): BarSummaryRow { * stable keys as the Claude path. quota_percentage = min remaining across present * core windows. next_reset = soonest core resetAt. No staleAsOf on a live result. */ -function buildCodexNetworkRow(quota: CodexQuotaResult, now: number): BarSummaryRow { +function buildCodexNetworkRow( + quota: CodexQuotaResult, + now: number, + surface: string, + profile: string +): BarSummaryRow { const windows: QuotaWindowDetail[] = []; const fiveHour = quota.coreUsage?.fiveHour; @@ -363,9 +443,12 @@ function buildCodexNetworkRow(quota: CodexQuotaResult, now: number): BarSummaryR const nextReset = resets.length > 0 ? resets[0].iso : null; return { - account_id: CODEX_PROVIDER, - provider: CODEX_PROVIDER, - displayName: 'Codex', + account_id: `${surface}:${profile}`, + provider: CODEX_NATIVE_PROVIDER, + surface, + profile, + is_subscription: true, + displayName: profile, tier: quota.planType ?? null, paused: false, quota_percentage: quotaPercentage, @@ -390,15 +473,177 @@ function serveCached(state: ProviderState): BarSummaryRow | null { } // ============================================================================ -// Claude path with full safety controls +// File-only Claude credentials reader for per-profile paths (NO keychain) // ============================================================================ -async function collectClaudeRow( +/** + * Read credentials for a specific Claude Code profile (file-only, no keychain). + * + * Looks for .credentials.json in the profile's instance directory. If the file + * is absent or unparseable, returns null — the caller emits a parked row. + * Never calls security/Keychain — zero new keychain access from this feature. + */ +function readClaudeCredentialsForProfileFromDisk(profile: string): ClaudeNativeCredentials | null { + try { + const instanceDir = path.join(getCcsDir(), 'instances', profile); + const credFile = path.join(instanceDir, '.credentials.json'); + if (!fs.existsSync(credFile)) return null; + const raw = fs.readFileSync(credFile, 'utf8'); + const parsed = JSON.parse(raw) as unknown; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as ClaudeNativeCredentials; + } + return null; + } catch { + return null; + } +} + +// ============================================================================ +// Profile enumeration helpers (production implementations, DI-overridable) +// ============================================================================ + +/** + * Read Codex native auth from the profile's on-disk auth.json. + * 'personal' reads ~/.codex/auth.json; other names read codex-instances//auth.json. + * Returns null when absent or unparseable. + */ +function readCodexNativeAuthFromDisk( + profile: string +): { accessToken: string; accountId: string } | null { + try { + let authPath: string; + if (profile === 'personal') { + authPath = path.join(os.homedir(), '.codex', 'auth.json'); + } else { + // resolveCodexProfileDir would validate, but we do it inline to avoid the + // import coupling and to handle invalid names gracefully (return null). + const instancesDir = path.join(getCcsDir(), 'codex-instances'); + authPath = path.join(instancesDir, profile, 'auth.json'); + } + + if (!fs.existsSync(authPath)) return null; + const raw = fs.readFileSync(authPath, 'utf8'); + const parsed = JSON.parse(raw) as Record; + const tokens = parsed.tokens as Record | undefined; + if (!tokens) return null; + const accessToken = tokens.access_token; + const accountId = tokens.account_id; + if (typeof accessToken !== 'string' || !accessToken) return null; + return { + accessToken, + accountId: typeof accountId === 'string' ? accountId : '', + }; + } catch { + return null; + } +} + +/** + * List all Claude profiles from the profile registry (merged legacy + unified). + * Returns [] on any read error so the collector degrades gracefully. + */ +function listClaudeProfilesFromDisk(): string[] { + try { + // Import lazily inside function to avoid circular dep and DI override in tests + const { ProfileRegistry } = require('../../auth/profile-registry') as { + ProfileRegistry: new () => { + getAllProfilesMerged: () => Record; + }; + }; + const registry = new ProfileRegistry(); + return Object.keys(registry.getAllProfilesMerged()); + } catch { + return []; + } +} + +/** + * Resolve the default Claude profile. Returns null when none is set. + */ +function getDefaultClaudeProfileFromDisk(): string | null { + try { + const { ProfileRegistry } = require('../../auth/profile-registry') as { + ProfileRegistry: new () => { + getDefaultResolved: () => string | null; + }; + }; + const registry = new ProfileRegistry(); + return registry.getDefaultResolved(); + } catch { + return null; + } +} + +/** + * List all Codex profiles from the registry, plus 'personal' when the bare + * ~/.codex/auth.json exists. Returns [] on any read error. + */ +function listCodexProfilesFromDisk(): string[] { + try { + const { CodexProfileRegistry } = require('../../codex-auth/codex-profile-registry') as { + CodexProfileRegistry: new () => { + listProfiles: () => string[]; + }; + }; + const registry = new CodexProfileRegistry(); + const profiles = registry.listProfiles(); + // Add 'personal' for the bare ~/.codex/auth.json if it exists + if (fs.existsSync(path.join(os.homedir(), '.codex', 'auth.json'))) { + if (!profiles.includes('personal')) profiles.push('personal'); + } + return profiles; + } catch { + return []; + } +} + +/** + * Resolve the default Codex profile. Falls back to 'personal' when the bare + * ~/.codex/auth.json exists and no registry default is set. + */ +function getDefaultCodexProfileFromDisk(): string | null { + try { + const { CodexProfileRegistry } = require('../../codex-auth/codex-profile-registry') as { + CodexProfileRegistry: new () => { + getDefault: () => string | null; + }; + }; + const registry = new CodexProfileRegistry(); + const def = registry.getDefault(); + if (def) return def; + // Fall back to 'personal' if the bare auth.json exists + if (fs.existsSync(path.join(os.homedir(), '.codex', 'auth.json'))) return 'personal'; + return null; + } catch { + return null; + } +} + +// ============================================================================ +// Per-profile collectors with full safety controls +// ============================================================================ + +/** + * Tag a row with whether it is the surface's default profile (drives the + * "active" badge only). The row's own `paused` flag is authoritative for + * dimming: it is already set by the collector to reflect LIVENESS — parked + * (no on-disk creds / unsupported) rows arrive with paused:true; profiles with + * a usable token arrive with paused:false regardless of default status. We must + * NOT override `paused` from `isDefault`, or a valid non-default subscription + * (e.g. an isolated `ccsx` profile) would render dimmed despite live quota. + */ +function markDefault(row: BarSummaryRow, isDefault: boolean): BarSummaryRow { + return { ...row, is_default: isDefault }; +} + +async function collectClaudeRowForProfile( + profile: string, deps: NativeQuotaDeps, force = false ): Promise { const now = (deps.now ?? Date.now)(); - const state = claudeState; + const state = getState(claudeProfileStates, profile); // Serve from cache while within TTL — force bypasses TTL short-circuit. if (!force && state.cachedRow && now - state.cachedAt < NATIVE_QUOTA_TTL_MS) { @@ -416,15 +661,50 @@ async function collectClaudeRow( return state.pending; } - const readCredentials = deps.readCredentials ?? readClaudeCredentials; + // For per-profile reads: use the injected seam (file-only, no keychain). + const readCreds = + deps.readClaudeCredentialsForProfile ?? + ((p: string) => readClaudeCredentialsForProfileFromDisk(p)); const fetchQuota = deps.fetchClaudeQuota ?? fetchClaudeQuotaWithToken; const sleep = deps.sleep ?? defaultSleep; state.pending = (async (): Promise => { try { - const creds = readCredentials(); + const creds = readCreds(profile); + + // No credentials file found -> emit parked row (needs auth, file absent). + // This is the expected case when the profile exists in the registry but the + // user has not logged in via 'ccs auth' for this machine or the credentials + // are stored only in keychain (which we deliberately do not access here). + if (!creds) { + const parkedRow: BarSummaryRow = { + account_id: `${SURFACE_CLAUDE}:${profile}`, + provider: CLAUDE_NATIVE_PROVIDER, + surface: SURFACE_CLAUDE, + profile, + is_subscription: true, + displayName: profile, + tier: null, + paused: true, + quota_percentage: null, + quotaStatus: 'unsupported', + next_reset: null, + is_default: false, + last_activity_at: null, + today_cost: null, + health: 'ok', + cached: false, + fetchedAt: new Date(now).toISOString(), + needsReauth: true, + }; + // Cache the parked row so repeated calls don't re-stat the fs. + state.cachedRow = parkedRow; + state.cachedAt = now; + return parkedRow; + } + // No token / unsupported subscription -> never spend a call, omit the row. - if (!creds || !hasSupportedSubscription(creds)) { + if (!hasSupportedSubscription(creds)) { return serveCached(state); } const token = getAccessToken(creds); @@ -433,7 +713,7 @@ async function collectClaudeRow( } const tier = getSubscriptionTier(creds); - const quota = await fetchQuota(token, CLAUDE_PROVIDER); + const quota = await fetchQuota(token, `${SURFACE_CLAUDE}:${profile}`); if (quota.success) { // Success closes the breaker and clears backoff. @@ -441,7 +721,7 @@ async function collectClaudeRow( state.breakerOpenUntil = 0; state.cooldownUntil = 0; state.backoffAttempt = 0; - const row = buildClaudeRow(quota, tier, now); + const row = buildClaudeRow(quota, tier, now, SURFACE_CLAUDE, profile); state.cachedRow = row; state.cachedAt = now; return { ...row, cached: false }; @@ -451,9 +731,12 @@ async function collectClaudeRow( // a real, actionable state distinct from a transient failure. if (quota.needsReauth) { const row: BarSummaryRow = { - account_id: CLAUDE_PROVIDER, - provider: CLAUDE_PROVIDER, - displayName: 'Claude Code', + account_id: `${SURFACE_CLAUDE}:${profile}`, + provider: CLAUDE_NATIVE_PROVIDER, + surface: SURFACE_CLAUDE, + profile, + is_subscription: true, + displayName: profile, tier, paused: false, quota_percentage: null, @@ -508,16 +791,13 @@ async function collectClaudeRow( return state.pending; } -// ============================================================================ -// Codex path with live network as PRIMARY, local logs as FALLBACK -// ============================================================================ - -async function collectCodexRow( +async function collectCodexRowForProfile( + profile: string, deps: NativeQuotaDeps, force = false ): Promise { const now = (deps.now ?? Date.now)(); - const state = codexState; + const state = getState(codexProfileStates, profile); // Serve from cache while within TTL — force bypasses TTL short-circuit. if (!force && state.cachedRow && now - state.cachedAt < NATIVE_QUOTA_TTL_MS) { @@ -533,8 +813,12 @@ async function collectCodexRow( return state.pending; } - const getDefaultAccountId = - deps.getDefaultCodexAccountId ?? (() => getDefaultAccount('codex')?.id ?? null); + // Resolve the native auth for this profile to get a network accountId. + const readNativeAuth = + deps.readCodexNativeAuth ?? ((p: string) => readCodexNativeAuthFromDisk(p)); + + // For the network fallback: the legacy getDefaultCodexAccountId is the + // CLIProxy-registry path; for native profiles we use the on-disk auth directly. const fetchNetwork = deps.fetchCodexNetworkQuota ?? ((accountId: string) => fetchCodexQuota(accountId)); const getCodex = deps.getCodexQuota ?? getCodexLocalQuota; @@ -546,9 +830,11 @@ async function collectCodexRow( // PRIMARY: live network fetch (skipped when breaker/cooldown active) // ---------------------------------------------------------------- if (!breakerOrCooldownActive) { - const accountId = getDefaultAccountId(); - if (accountId) { - const quota = await fetchNetwork(accountId); + const nativeAuth = readNativeAuth(profile); + // Use the on-disk accountId for the network call; fall through to local + // when the auth file is absent (parked profile). + if (nativeAuth) { + const quota = await fetchNetwork(nativeAuth.accountId || profile); if (quota.success) { // A healthy response closes the breaker and clears backoff, @@ -557,13 +843,9 @@ async function collectCodexRow( state.breakerOpenUntil = 0; state.cooldownUntil = 0; state.backoffAttempt = 0; - // Only usable when at least one core window (5h/weekly) resolved. A - // success with empty coreUsage (only code-review/additional windows, - // or a changed payload) carries no glanceable signal — do NOT cache - // a contentless "ok" row or clobber a good cache; fall through to - // the local fallback so the bar shows real data instead. + // Only usable when at least one core window (5h/weekly) resolved. if (quota.coreUsage?.fiveHour || quota.coreUsage?.weekly) { - const row = buildCodexNetworkRow(quota, now); + const row = buildCodexNetworkRow(quota, now, SURFACE_CODEX, profile); state.cachedRow = row; state.cachedAt = now; return { ...row, cached: false }; @@ -572,9 +854,12 @@ async function collectCodexRow( } else if (quota.needsReauth) { // Token expired -> reauth row; do NOT cache as a good value. return { - account_id: CODEX_PROVIDER, - provider: CODEX_PROVIDER, - displayName: 'Codex', + account_id: `${SURFACE_CODEX}:${profile}`, + provider: CODEX_NATIVE_PROVIDER, + surface: SURFACE_CODEX, + profile, + is_subscription: true, + displayName: profile, tier: null, paused: false, quota_percentage: null, @@ -614,16 +899,16 @@ async function collectCodexRow( } // Fall through to LOCAL fallback below. } - // No configured accountId -> fall through to local fallback. + // No on-disk auth for this profile -> fall through to local fallback. } // ---------------------------------------------------------------- // LOCAL FALLBACK: session log read (zero network, always attempted - // when network is unavailable / no accountId / breaker active) + // when network is unavailable / no auth file / breaker active) // ---------------------------------------------------------------- const localQuota = await getCodex(); if (localQuota) { - const row = buildCodexRow(localQuota, now); + const row = buildCodexRow(localQuota, now, SURFACE_CODEX, profile); state.cachedRow = row; state.cachedAt = now; return { ...row, cached: false }; @@ -645,25 +930,398 @@ async function collectCodexRow( return state.pending; } +// ============================================================================ +// Legacy single-profile collectors (unchanged; used by old tests + back-compat) +// ============================================================================ + +/** + * @deprecated Use collectClaudeRowForProfile with the 'default' or appropriate + * profile name. Kept for backward compatibility with existing tests that stub + * readCredentials/getDefaultCodexAccountId directly. + */ +async function collectClaudeRow( + deps: NativeQuotaDeps, + force = false +): Promise { + const now = (deps.now ?? Date.now)(); + + // Use the legacy single-state approach via profile key '__legacy__' to avoid + // breaking state isolation with the per-profile maps. + const state = getState(claudeProfileStates, '__legacy__'); + + // Serve from cache while within TTL — force bypasses TTL short-circuit. + if (!force && state.cachedRow && now - state.cachedAt < NATIVE_QUOTA_TTL_MS) { + return serveCached(state); + } + + // Breaker open or cooldown active -> zero network, serve stale (may be null). + if (now < state.breakerOpenUntil || now < state.cooldownUntil) { + return serveCached(state); + } + + // Coalesce: concurrent callers past TTL share one in-flight fetch. + if (state.pending) { + return state.pending; + } + + const readCredentialsFn = deps.readCredentials ?? readClaudeCredentials; + const fetchQuota = deps.fetchClaudeQuota ?? fetchClaudeQuotaWithToken; + const sleep = deps.sleep ?? defaultSleep; + + state.pending = (async (): Promise => { + try { + const creds = readCredentialsFn(); + // No token / unsupported subscription -> never spend a call, omit the row. + if (!creds || !hasSupportedSubscription(creds)) { + return serveCached(state); + } + const token = getAccessToken(creds); + if (!token) { + return serveCached(state); + } + const tier = getSubscriptionTier(creds); + + const quota = await fetchQuota(token, CLAUDE_PROVIDER); + + if (quota.success) { + // Success closes the breaker and clears backoff. + state.consecutive429 = 0; + state.breakerOpenUntil = 0; + state.cooldownUntil = 0; + state.backoffAttempt = 0; + const row = buildClaudeRowLegacy(quota, tier, now); + state.cachedRow = row; + state.cachedAt = now; + return { ...row, cached: false }; + } + + // 401 -> token expired. + if (quota.needsReauth) { + const row: BarSummaryRow = { + account_id: CLAUDE_PROVIDER, + provider: CLAUDE_PROVIDER, + displayName: 'Claude Code', + tier, + paused: false, + quota_percentage: null, + quotaStatus: 'error', + next_reset: null, + is_default: false, + last_activity_at: null, + today_cost: null, + health: 'error', + cached: false, + fetchedAt: new Date(now).toISOString(), + needsReauth: true, + }; + return row; + } + + // 429 / 5xx / transient. + const is429 = quota.httpStatus === 429; + if (is429) { + state.consecutive429 += 1; + if (state.consecutive429 >= CB_TRIP_THRESHOLD) { + state.breakerOpenUntil = now + CB_COOLDOWN_MS; + } + const retryAfter = parseRetryAfterMs(quota.errorDetail, now); + const backoff = retryAfter ?? computeBackoffMs(state.backoffAttempt); + state.cooldownUntil = now + backoff; + state.backoffAttempt += 1; + void sleep; + } else if (quota.retryable) { + const backoff = computeBackoffMs(state.backoffAttempt); + state.cooldownUntil = now + backoff; + state.backoffAttempt += 1; + } + + return serveCached(state); + } catch { + const backoff = computeBackoffMs(state.backoffAttempt); + state.cooldownUntil = now + backoff; + state.backoffAttempt += 1; + return serveCached(state); + } finally { + state.pending = null; + } + })(); + + return state.pending; +} + +/** Legacy row builder — no surface/profile/is_subscription fields. */ +function buildClaudeRowLegacy( + quota: ClaudeQuotaResult, + tier: string | null, + now: number +): BarSummaryRow { + const quotaWindows = buildClaudeQuotaWindows(quota); + return { + account_id: CLAUDE_PROVIDER, + provider: CLAUDE_PROVIDER, + displayName: 'Claude Code', + tier, + paused: false, + quota_percentage: deriveClaudeQuotaPercentage(quota), + quotaStatus: 'ok', + next_reset: deriveClaudeNextReset(quota), + is_default: false, + last_activity_at: null, + today_cost: null, + health: 'ok', + cached: false, + fetchedAt: new Date(now).toISOString(), + needsReauth: false, + ...(quotaWindows.length > 0 ? { quotaWindows } : {}), + }; +} + +/** + * Legacy Codex collector — uses the old getDefaultCodexAccountId dep. + * Kept for backward compatibility with existing tests. + */ +async function collectCodexRow( + deps: NativeQuotaDeps, + force = false +): Promise { + const now = (deps.now ?? Date.now)(); + const state = getState(codexProfileStates, '__legacy__'); + + // Serve from cache while within TTL — force bypasses TTL short-circuit. + if (!force && state.cachedRow && now - state.cachedAt < NATIVE_QUOTA_TTL_MS) { + return serveCached(state); + } + + // Breaker open or cooldown active -> skip network, go to LOCAL fallback. + const breakerOrCooldownActive = now < state.breakerOpenUntil || now < state.cooldownUntil; + + // Coalesce: concurrent callers past TTL share one in-flight resolution. + if (state.pending) { + return state.pending; + } + + const getDefaultAccountId = + deps.getDefaultCodexAccountId ?? (() => getDefaultAccount('codex')?.id ?? null); + const fetchNetwork = + deps.fetchCodexNetworkQuota ?? ((accountId: string) => fetchCodexQuota(accountId)); + const getCodex = deps.getCodexQuota ?? getCodexLocalQuota; + const sleep = deps.sleep ?? defaultSleep; + + state.pending = (async (): Promise => { + try { + // ---------------------------------------------------------------- + // PRIMARY: live network fetch (skipped when breaker/cooldown active) + // ---------------------------------------------------------------- + if (!breakerOrCooldownActive) { + const accountId = getDefaultAccountId(); + if (accountId) { + const quota = await fetchNetwork(accountId); + + if (quota.success) { + state.consecutive429 = 0; + state.breakerOpenUntil = 0; + state.cooldownUntil = 0; + state.backoffAttempt = 0; + if (quota.coreUsage?.fiveHour || quota.coreUsage?.weekly) { + const row = buildCodexNetworkRowLegacy(quota, now); + state.cachedRow = row; + state.cachedAt = now; + return { ...row, cached: false }; + } + // else: fall through to LOCAL fallback below. + } else if (quota.needsReauth) { + return { + account_id: CODEX_PROVIDER, + provider: CODEX_PROVIDER, + displayName: 'Codex', + tier: null, + paused: false, + quota_percentage: null, + quotaStatus: 'error', + next_reset: null, + is_default: false, + last_activity_at: null, + today_cost: null, + health: 'error', + cached: false, + fetchedAt: new Date(now).toISOString(), + needsReauth: true, + }; + } else if (quota.httpStatus === 429) { + state.consecutive429 += 1; + if (state.consecutive429 >= CB_TRIP_THRESHOLD) { + state.breakerOpenUntil = now + CB_COOLDOWN_MS; + } + const retryAfter = parseRetryAfterMs(quota.errorDetail, now); + const backoff = retryAfter ?? computeBackoffMs(state.backoffAttempt); + state.cooldownUntil = now + backoff; + state.backoffAttempt += 1; + void sleep; + } else if (quota.retryable) { + const backoff = computeBackoffMs(state.backoffAttempt); + state.cooldownUntil = now + backoff; + state.backoffAttempt += 1; + } else { + const backoff = computeBackoffMs(state.backoffAttempt); + state.cooldownUntil = now + backoff; + state.backoffAttempt += 1; + } + // Fall through to LOCAL fallback below. + } + } + + // ---------------------------------------------------------------- + // LOCAL FALLBACK + // ---------------------------------------------------------------- + const localQuota = await getCodex(); + if (localQuota) { + const row = buildCodexRowLegacy(localQuota, now); + state.cachedRow = row; + state.cachedAt = now; + return { ...row, cached: false }; + } + + return serveCached(state); + } catch { + const backoff = computeBackoffMs(state.backoffAttempt); + state.cooldownUntil = now + backoff; + state.backoffAttempt += 1; + return serveCached(state); + } finally { + state.pending = null; + } + })(); + + return state.pending; +} + +/** Legacy Codex row builder (local quota). */ +function buildCodexRowLegacy(quota: CodexLocalQuota, now: number): BarSummaryRow { + const quotaWindows = buildCodexQuotaWindows(quota); + return { + account_id: CODEX_PROVIDER, + provider: CODEX_PROVIDER, + displayName: 'Codex', + tier: quota.tier, + paused: false, + quota_percentage: quota.quotaPercentage, + quotaStatus: 'ok', + next_reset: quota.nextReset, + is_default: false, + last_activity_at: null, + today_cost: null, + health: quota.stale ? 'warning' : 'ok', + cached: false, + fetchedAt: new Date(now).toISOString(), + needsReauth: false, + ...(quotaWindows.length > 0 ? { quotaWindows } : {}), + ...(quota.staleAsOf ? { staleAsOf: quota.staleAsOf } : {}), + }; +} + +/** Legacy Codex network row builder. */ +function buildCodexNetworkRowLegacy(quota: CodexQuotaResult, now: number): BarSummaryRow { + const windows: QuotaWindowDetail[] = []; + const fiveHour = quota.coreUsage?.fiveHour; + if (fiveHour) { + windows.push({ + key: 'five_hour', + label: '5h', + usedPercent: 100 - fiveHour.remainingPercent, + remainingPercent: fiveHour.remainingPercent, + resetAt: fiveHour.resetAt, + windowMinutes: FIVE_HOUR_MINUTES, + }); + } + const weekly = quota.coreUsage?.weekly; + if (weekly) { + windows.push({ + key: 'seven_day', + label: 'week', + usedPercent: 100 - weekly.remainingPercent, + remainingPercent: weekly.remainingPercent, + resetAt: weekly.resetAt, + windowMinutes: SEVEN_DAY_MINUTES, + }); + } + const coreWindows = [fiveHour, weekly].filter((w): w is NonNullable => !!w); + const quotaPercentage = + coreWindows.length > 0 ? Math.min(...coreWindows.map((w) => w.remainingPercent)) : null; + const resets = coreWindows + .map((w) => w.resetAt) + .filter((r): r is string => typeof r === 'string') + .map((r) => ({ iso: r, ms: new Date(r).getTime() })) + .filter((r) => Number.isFinite(r.ms)) + .sort((a, b) => a.ms - b.ms); + const nextReset = resets.length > 0 ? resets[0].iso : null; + return { + account_id: CODEX_PROVIDER, + provider: CODEX_PROVIDER, + displayName: 'Codex', + tier: quota.planType ?? null, + paused: false, + quota_percentage: quotaPercentage, + quotaStatus: 'ok', + next_reset: nextReset, + is_default: false, + last_activity_at: null, + today_cost: null, + health: 'ok', + cached: false, + fetchedAt: new Date(now).toISOString(), + needsReauth: false, + ...(windows.length > 0 ? { quotaWindows: windows } : {}), + }; +} + // ============================================================================ // Public entry point // ============================================================================ /** - * Build the native subscription rows (Claude Code + Codex) for /summary. + * Build the native subscription rows for /summary. * - * Each path is independently try/caught so one failing source never blocks the - * other or the response. Returns only rows that represent real data. + * When profile-enumeration deps are injected (listClaudeProfiles / listCodexProfiles + * etc.), all profiles are enumerated and the active/default profile is live-polled + * while non-default profiles are cache-only (parked). This keeps the 2.5s deadline: + * at most 2 live upstream calls per /summary regardless of profile count. * - * `opts.force` bypasses the TTL short-circuit on both paths so a debounce- - * passing refresh re-pulls native rows live. The circuit breaker is always - * respected regardless of force (account protection). + * When no enumeration deps are injected (legacy mode / old tests that stub only + * readCredentials + getDefaultCodexAccountId), the old single-profile collectors + * are used for backward compatibility. + * + * `opts.force` bypasses the TTL short-circuit on the ACTIVE profile. The circuit + * breaker is always respected regardless of force (account protection). */ export async function getNativeAccountRows( deps: NativeQuotaDeps = {}, opts?: { force?: boolean } ): Promise { const force = opts?.force ?? false; + + // Multi-profile enumeration is the DEFAULT (production) behavior. The legacy + // single-profile path is retained ONLY for backward-compat with old tests that + // inject readCredentials / getDefaultCodexAccountId and assert the original + // single-row output. Those harnesses never inject the enumeration seams; the + // new multi-profile tests pair both seams, and production injects neither — so + // everything except the legacy harness takes the multi-profile path below. + const hasProfileEnumeration = + deps.listClaudeProfiles !== undefined || + deps.listCodexProfiles !== undefined || + deps.defaultClaudeProfile !== undefined || + deps.defaultCodexProfile !== undefined; + + const isLegacyTestHarness = + !hasProfileEnumeration && + (deps.readCredentials !== undefined || deps.getDefaultCodexAccountId !== undefined); + + if (!isLegacyTestHarness) { + return getNativeAccountRowsMultiProfile(deps, force); + } + + // Legacy path: backward-compatible with old tests that only inject + // readCredentials / getDefaultCodexAccountId. Produces the old single-row + // output (account_id='claude-code'/'codex', no surface/profile). const [claude, codex] = await Promise.all([ collectClaudeRow(deps, force).catch(() => null), collectCodexRow(deps, force).catch(() => null), @@ -675,6 +1333,79 @@ export async function getNativeAccountRows( return rows; } +async function getNativeAccountRowsMultiProfile( + deps: NativeQuotaDeps, + force: boolean +): Promise { + const listClaude = deps.listClaudeProfiles ?? listClaudeProfilesFromDisk; + const listCodex = deps.listCodexProfiles ?? listCodexProfilesFromDisk; + const defaultClaude = deps.defaultClaudeProfile ?? getDefaultClaudeProfileFromDisk; + const defaultCodex = deps.defaultCodexProfile ?? getDefaultCodexProfileFromDisk; + + const claudeProfiles = (() => { + try { + return listClaude(); + } catch { + return []; + } + })(); + const codexProfiles = (() => { + try { + return listCodex(); + } catch { + return []; + } + })(); + const claudeDefault = (() => { + try { + return defaultClaude(); + } catch { + return null; + } + })(); + const codexDefault = (() => { + try { + return defaultCodex(); + } catch { + return null; + } + })(); + + const tasks: Promise[] = []; + + // Forced refresh applies to every profile: parked profiles (no creds) short- + // circuit to a parked row with zero network, so forcing them is free, while + // every profile that has a usable token gets live quota — not just the + // default. Per-profile TTL + breaker still protect each account. + for (const p of claudeProfiles) { + const isDefault = p === claudeDefault; + tasks.push( + collectClaudeRowForProfile(p, deps, force) + .then((r) => (r ? markDefault(r, isDefault) : null)) + .catch(() => null) + ); + } + + for (const p of codexProfiles) { + const isDefault = p === codexDefault; + tasks.push( + collectCodexRowForProfile(p, deps, force) + .then((r) => (r ? markDefault(r, isDefault) : null)) + .catch(() => null) + ); + } + + const results = await Promise.all(tasks); + const rows = results.filter((r): r is BarSummaryRow => r !== null); + + // Sort by (surface, profile) for stable ordering. + return rows.sort((a, b) => { + const sa = (a.surface ?? '') + ':' + (a.profile ?? ''); + const sb = (b.surface ?? '') + ':' + (b.profile ?? ''); + return sa.localeCompare(sb); + }); +} + /** * Last-known native rows from cache, WITHOUT any fetch (instant, no network). * @@ -685,7 +1416,11 @@ export async function getNativeAccountRows( */ export function getCachedNativeAccountRows(): BarSummaryRow[] { const rows: BarSummaryRow[] = []; - if (claudeState.cachedRow) rows.push({ ...claudeState.cachedRow, cached: true }); - if (codexState.cachedRow) rows.push({ ...codexState.cachedRow, cached: true }); + for (const state of claudeProfileStates.values()) { + if (state.cachedRow) rows.push({ ...state.cachedRow, cached: true }); + } + for (const state of codexProfileStates.values()) { + if (state.cachedRow) rows.push({ ...state.cachedRow, cached: true }); + } return rows; } diff --git a/tests/unit/web-server/bar-routes.test.ts b/tests/unit/web-server/bar-routes.test.ts index 83ce1956..4890820f 100644 --- a/tests/unit/web-server/bar-routes.test.ts +++ b/tests/unit/web-server/bar-routes.test.ts @@ -1220,3 +1220,151 @@ describe('/summary native subscription rows', () => { expect(body[0].provider).toBe('agy'); }); }); + +// ============================================================================ +// GH-1595: wire contract — native rows carry surface/profile/is_subscription; +// CLIProxy pool rows OMIT all three fields. +// ============================================================================ + +describe('/summary wire contract: surface/profile/is_subscription fields (GH-1595)', () => { + /** + * Extended wire row type that includes the new optional fields. + * BarSummaryRow in the test file omits them; extend locally here. + */ + interface WireRow extends BarSummaryRow { + surface?: string; + profile?: string; + is_subscription?: boolean; + } + + function makeNativeRow( + surface: 'ccs' | 'ccsx', + profile: string, + paused = false + ): BarSummaryRow { + return { + account_id: `${surface}:${profile}`, + provider: surface === 'ccs' ? 'claude-code' : 'codex', + displayName: profile, + tier: 'pro', + paused, + quota_percentage: 55, + quotaStatus: 'ok', + next_reset: null, + is_default: !paused, + last_activity_at: null, + today_cost: null, + health: 'ok', + cached: false, + fetchedAt: '2026-06-23T20:00:00.000Z', + needsReauth: false, + // The TS interface now has these optional fields — set them explicitly. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...(({ surface, profile, is_subscription: true }) as any), + }; + } + + async function buildWireRouter(nativeRows: BarSummaryRow[]) { + const { createBarRouter, resetForceFreshDebounce: resetDebounce } = await import( + '../../../src/web-server/routes/bar-routes' + ); + + const app = express(); + app.use(express.json()); + + const cliproxyAccount = makeAccountInfo({ id: 'pool@example.com', provider: 'agy' }); + + const router = createBarRouter({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getAllAccountsSummary: () => ({ agy: [cliproxyAccount] }) as any, + getCachedQuota: () => makeQuotaResult(), + setCachedQuota: () => {}, + invalidateQuotaCache: () => {}, + fetchAccountQuota: async () => makeQuotaResult(), + getTodayCostByAccount: () => ({}), + loadCliproxyDetails: async () => [], + loadDailyUsage: async () => [], + loadHourlyUsage: async () => [], + runHealthChecks: async () => makeHealthReport(), + getNativeAccountRows: async () => nativeRows, + }); + + app.use('/api/bar', router); + const srv = await new Promise((resolve, reject) => { + const instance = app.listen(0, '127.0.0.1'); + instance.once('error', reject); + instance.once('listening', () => resolve(instance)); + }); + const addr = srv.address(); + if (!addr || typeof addr === 'string') throw new Error('No server address'); + resetDebounce(); + return { srv, url: `http://127.0.0.1:${(addr as { port: number }).port}` }; + } + + it('native rows include surface, profile, is_subscription=true in the JSON response', async () => { + const { srv, url } = await buildWireRouter([ + makeNativeRow('ccs', 'work', false), + makeNativeRow('ccsx', 'personal', false), + ]); + const { body } = await getJson(url, '/api/bar/summary'); + await new Promise((resolve) => srv.close(() => resolve())); + + const claudeRow = body.find((r) => r.provider === 'claude-code'); + expect(claudeRow).toBeDefined(); + expect(claudeRow?.surface).toBe('ccs'); + expect(claudeRow?.profile).toBe('work'); + expect(claudeRow?.is_subscription).toBe(true); + expect(claudeRow?.account_id).toBe('ccs:work'); + + const codexRow = body.find((r) => r.provider === 'codex'); + expect(codexRow).toBeDefined(); + expect(codexRow?.surface).toBe('ccsx'); + expect(codexRow?.profile).toBe('personal'); + expect(codexRow?.is_subscription).toBe(true); + expect(codexRow?.account_id).toBe('ccsx:personal'); + }); + + it('CLIProxy pool rows OMIT surface, profile, is_subscription', async () => { + const { srv, url } = await buildWireRouter([ + makeNativeRow('ccs', 'work', false), + ]); + const { body } = await getJson(url, '/api/bar/summary'); + await new Promise((resolve) => srv.close(() => resolve())); + + // The CLIProxy row (provider 'agy') should NOT have the new fields. + const cliproxyRow = body.find((r) => r.provider === 'agy'); + expect(cliproxyRow).toBeDefined(); + expect(cliproxyRow?.surface).toBeUndefined(); + expect(cliproxyRow?.profile).toBeUndefined(); + expect(cliproxyRow?.is_subscription).toBeUndefined(); + }); + + it('parked native row (paused:true) is present with is_subscription=true and paused=true', async () => { + const { srv, url } = await buildWireRouter([ + makeNativeRow('ccsx', 'ck', true), // parked Codex profile + ]); + const { body } = await getJson(url, '/api/bar/summary'); + await new Promise((resolve) => srv.close(() => resolve())); + + const parked = body.find((r) => r.profile === 'ck'); + expect(parked).toBeDefined(); + expect(parked?.paused).toBe(true); + expect(parked?.is_subscription).toBe(true); + expect(parked?.surface).toBe('ccsx'); + }); + + it('account_id on native rows uses the : scheme', async () => { + const { srv, url } = await buildWireRouter([ + makeNativeRow('ccs', 'ck', false), + makeNativeRow('ccsx', 'ck', true), + ]); + const { body } = await getJson(url, '/api/bar/summary'); + await new Promise((resolve) => srv.close(() => resolve())); + + const claudeRow = body.find((r) => r.surface === 'ccs'); + expect(claudeRow?.account_id).toBe('ccs:ck'); + + const codexRow = body.find((r) => r.surface === 'ccsx'); + expect(codexRow?.account_id).toBe('ccsx:ck'); + }); +}); diff --git a/tests/unit/web-server/native-quota-collector.test.ts b/tests/unit/web-server/native-quota-collector.test.ts index 344c2c7e..22c63654 100644 --- a/tests/unit/web-server/native-quota-collector.test.ts +++ b/tests/unit/web-server/native-quota-collector.test.ts @@ -775,3 +775,433 @@ describe('getCachedNativeAccountRows (instant, no-fetch fallback)', () => { expect(getCachedNativeAccountRows()).toEqual([]); }); }); + +// ============================================================================ +// Multi-profile path tests (GH-1595) +// +// These tests inject listClaudeProfiles / listCodexProfiles / defaultClaudeProfile +// / defaultCodexProfile so the production profile-enumeration path is exercised +// without touching real ~/.ccs or any Keychain. The readClaudeCredentialsForProfile +// and readCodexNativeAuth seams prevent fs access. +// ============================================================================ + +/** + * Build a NativeQuotaDeps for the multi-profile path. + * + * - claudeProfiles: profile names for the Claude surface (ccs) + * - codexProfiles: profile names for the Codex surface (ccsx) + * - claudeDefault / codexDefault: the active profile per surface (paused:false) + * - credsForProfile: map from profile name to credentials (null = parked) + * - claudeFetch: network fetcher for Claude (all profiles share one implementation) + * - codexNativeAuth: map from profile name to {accessToken, accountId} + * - codexNetworkFetch: network fetcher for Codex (all profiles share one impl) + */ +function makeMultiProfileDeps(opts: { + clock: { now: number }; + claudeProfiles: string[]; + codexProfiles: string[]; + claudeDefault?: string | null; + codexDefault?: string | null; + credsForProfile?: (profile: string) => ClaudeNativeCredentials | null; + claudeFetch?: (token: string, accountId?: string) => Promise; + codexNativeAuth?: (profile: string) => { accessToken: string; accountId: string } | null; + codexNetworkFetch?: (accountId: string) => Promise; + codexLocalFallback?: () => Promise; +}): NativeQuotaDeps & { + claudeFetchCount: () => number; + codexNetworkCount: () => number; +} { + let claudeFetches = 0; + let codexNetworkFetches = 0; + + const { + clock, + claudeProfiles, + codexProfiles, + claudeDefault = null, + codexDefault = null, + credsForProfile = () => null, + claudeFetch = async () => successQuota(), + codexNativeAuth = () => null, + codexNetworkFetch = async () => codexSuccessQuota(), + codexLocalFallback = async () => null, + } = opts; + + return { + // Enumeration seams + listClaudeProfiles: () => claudeProfiles, + listCodexProfiles: () => codexProfiles, + defaultClaudeProfile: () => claudeDefault, + defaultCodexProfile: () => codexDefault, + // Credential seams (file-only, no keychain) + readClaudeCredentialsForProfile: credsForProfile, + readCodexNativeAuth: codexNativeAuth, + // Fetch seams + fetchClaudeQuota: async (token: string, accountId?: string) => { + claudeFetches += 1; + return claudeFetch(token, accountId); + }, + fetchCodexNetworkQuota: async (accountId: string) => { + codexNetworkFetches += 1; + return codexNetworkFetch(accountId); + }, + getCodexQuota: codexLocalFallback, + // Disable legacy single-profile paths + readCredentials: () => null, + getDefaultCodexAccountId: () => null, + // Clock + sleep seams + now: () => clock.now, + sleep: async () => {}, + // Counters + claudeFetchCount: () => claudeFetches, + codexNetworkCount: () => codexNetworkFetches, + }; +} + +describe('multi-profile: account_id and wire fields', () => { + it('Claude profile rows carry surface="ccs", account_id="ccs:

", is_subscription=true', async () => { + const clock = { now: 1_000_000 }; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: ['work', 'ck'], + codexProfiles: [], + claudeDefault: 'work', + // 'work' has creds; 'ck' does not (parked) + credsForProfile: (p) => (p === 'work' ? maxCreds() : null), + claudeFetch: async () => successQuota(), + }); + + const rows = await getNativeAccountRows(deps); + expect(rows.length).toBe(2); + + const work = rows.find((r) => r.profile === 'work'); + expect(work).toBeDefined(); + expect(work?.account_id).toBe('ccs:work'); + expect(work?.surface).toBe('ccs'); + expect(work?.is_subscription).toBe(true); + expect(work?.provider).toBe('claude-code'); + + const ck = rows.find((r) => r.profile === 'ck'); + expect(ck).toBeDefined(); + expect(ck?.account_id).toBe('ccs:ck'); + expect(ck?.surface).toBe('ccs'); + expect(ck?.is_subscription).toBe(true); + }); + + it('Codex profile rows carry surface="ccsx", account_id="ccsx:

", is_subscription=true', async () => { + const clock = { now: 1_000_000 }; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: [], + codexProfiles: ['personal', 'ck'], + codexDefault: 'personal', + codexNativeAuth: (p) => ({ accessToken: `tok-${p}`, accountId: `id-${p}` }), + codexNetworkFetch: async () => codexSuccessQuota(), + }); + + const rows = await getNativeAccountRows(deps); + expect(rows.length).toBe(2); + + const personal = rows.find((r) => r.profile === 'personal'); + expect(personal?.account_id).toBe('ccsx:personal'); + expect(personal?.surface).toBe('ccsx'); + expect(personal?.is_subscription).toBe(true); + expect(personal?.provider).toBe('codex'); + + const ck = rows.find((r) => r.profile === 'ck'); + expect(ck?.account_id).toBe('ccsx:ck'); + expect(ck?.surface).toBe('ccsx'); + expect(ck?.is_subscription).toBe(true); + }); + + it('paused reflects liveness (creds present), NOT default-ness; is_default marks the default independently', async () => { + const clock = { now: 1_000_000 }; + const deps = makeMultiProfileDeps({ + clock, + // Claude: work = default + creds (live); ck = non-default + NO creds (parked). + claudeProfiles: ['work', 'ck'], + // Codex: personal = default + creds (live); ck = NON-default + creds (live). + codexProfiles: ['personal', 'ck'], + claudeDefault: 'work', + codexDefault: 'personal', + credsForProfile: (p) => (p === 'work' ? maxCreds() : null), + claudeFetch: async () => successQuota(), + codexNativeAuth: (p) => ({ accessToken: `tok-${p}`, accountId: `id-${p}` }), + codexNetworkFetch: async () => codexSuccessQuota(), + }); + + const rows = await getNativeAccountRows(deps); + + // Claude work: default + creds -> live, not dimmed. + const claudeWork = rows.find((r) => r.surface === 'ccs' && r.profile === 'work'); + expect(claudeWork?.paused).toBe(false); + expect(claudeWork?.is_default).toBe(true); + + // Claude ck: non-default + NO creds -> parked/dimmed. + const claudeCk = rows.find((r) => r.surface === 'ccs' && r.profile === 'ck'); + expect(claudeCk?.paused).toBe(true); + expect(claudeCk?.is_default).toBe(false); + + // Codex personal: default + creds -> live. + const codexPersonal = rows.find((r) => r.surface === 'ccsx' && r.profile === 'personal'); + expect(codexPersonal?.paused).toBe(false); + expect(codexPersonal?.is_default).toBe(true); + + // Codex ck: NON-default but HAS creds -> LIVE, NOT dimmed. This is the key + // correctness guarantee: a valid isolated subscription is never dimmed just + // because it is not the surface default. + const codexCk = rows.find((r) => r.surface === 'ccsx' && r.profile === 'ck'); + expect(codexCk?.paused).toBe(false); + expect(codexCk?.is_default).toBe(false); + }); + + it('N Claude + M Codex profiles produce N+M rows', async () => { + const clock = { now: 1_000_000 }; + const claudeProfiles = ['work', 'ck', 'personal']; + const codexProfiles = ['personal', 'ck']; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles, + codexProfiles, + claudeDefault: 'work', + codexDefault: 'personal', + credsForProfile: () => maxCreds(), + codexNativeAuth: (p) => ({ accessToken: `tok-${p}`, accountId: `id-${p}` }), + }); + + const rows = await getNativeAccountRows(deps); + expect(rows.length).toBe(claudeProfiles.length + codexProfiles.length); + }); + + it('rows are sorted by (surface, profile)', async () => { + const clock = { now: 1_000_000 }; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: ['work', 'ck'], + codexProfiles: ['ck', 'personal'], + claudeDefault: 'work', + codexDefault: 'personal', + credsForProfile: () => maxCreds(), + codexNativeAuth: (p) => ({ accessToken: `tok-${p}`, accountId: `id-${p}` }), + }); + + const rows = await getNativeAccountRows(deps); + const keys = rows.map((r) => `${r.surface}:${r.profile}`); + // ccs:ck < ccs:work < ccsx:ck < ccsx:personal + expect(keys).toEqual(['ccs:ck', 'ccs:work', 'ccsx:ck', 'ccsx:personal']); + }); +}); + +describe('multi-profile: Claude file-only reader', () => { + it('profile with .credentials.json present -> live fetch row (paused:false when default)', async () => { + const clock = { now: 1_000_000 }; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: ['work'], + codexProfiles: [], + claudeDefault: 'work', + credsForProfile: (p) => (p === 'work' ? maxCreds() : null), + claudeFetch: async () => successQuota(), + }); + + const rows = await getNativeAccountRows(deps); + expect(rows.length).toBe(1); + const row = rows[0]; + expect(row?.profile).toBe('work'); + expect(row?.quotaStatus).toBe('ok'); + expect(row?.needsReauth).toBe(false); + expect(row?.paused).toBe(false); + expect(deps.claudeFetchCount()).toBe(1); + }); + + it('profile without .credentials.json -> parked row (needsReauth:true, no live fetch)', async () => { + const clock = { now: 1_000_000 }; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: ['ck'], + codexProfiles: [], + claudeDefault: 'ck', + credsForProfile: () => null, // no file on disk + claudeFetch: async () => successQuota(), + }); + + const rows = await getNativeAccountRows(deps); + expect(rows.length).toBe(1); + const row = rows[0]; + expect(row?.profile).toBe('ck'); + expect(row?.needsReauth).toBe(true); + expect(row?.quota_percentage).toBeNull(); + // No live network call when creds are absent + expect(deps.claudeFetchCount()).toBe(0); + }); + + it('absent creds row has quotaStatus unsupported (honest "needs auth" state)', async () => { + const clock = { now: 1_000_000 }; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: ['ck'], + codexProfiles: [], + claudeDefault: 'ck', + credsForProfile: () => null, + }); + + const rows = await getNativeAccountRows(deps); + const row = rows[0]; + expect(row?.quotaStatus).toBe('unsupported'); + expect(row?.is_subscription).toBe(true); + }); +}); + +describe('multi-profile: per-profile circuit breaker isolation', () => { + it("one profile's 429 does not open another profile's breaker", async () => { + const MAX_COOLDOWN_JUMP_MP = 61_000; + const clock = { now: 1_000_000 }; + let workFails = true; + + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: ['work', 'ck'], + codexProfiles: [], + claudeDefault: 'work', + credsForProfile: () => maxCreds(), + claudeFetch: async (_token, accountId) => { + // 'work' (ccs:work) always 429s; 'ck' always succeeds + if (accountId?.includes('work') && workFails) { + return { + success: false, + windows: [], + coreUsage: { fiveHour: null, weekly: null }, + lastUpdated: Date.now(), + accountId: accountId ?? 'ccs:work', + httpStatus: 429, + retryable: true, + error: 'rate limited', + } as ClaudeQuotaResult; + } + return successQuota(); + }, + }); + + // Trip the work breaker with 3 consecutive 429s. + for (let i = 0; i < 3; i++) { + resetNativeQuotaState(); + clock.now += i === 0 ? 0 : MAX_COOLDOWN_JUMP_MP; + // Re-inject the multi-profile deps after reset so the state maps are fresh. + await getNativeAccountRows({ + ...deps, + listClaudeProfiles: () => ['work'], + listCodexProfiles: () => [], + defaultClaudeProfile: () => 'work', + }); + } + + // After the three 429s on 'work', check that 'ck' still succeeds. + // We reset state to have a clean run where 'ck' has no prior breaker history. + resetNativeQuotaState(); + clock.now += MAX_COOLDOWN_JUMP_MP; + workFails = false; + + const rows = await getNativeAccountRows(deps); + const ckRow = rows.find((r) => r.profile === 'ck'); + const workRow = rows.find((r) => r.profile === 'work'); + + // 'ck' should succeed — its breaker was never tripped. + expect(ckRow?.quotaStatus).toBe('ok'); + // 'work' is also fine after reset (no breaker state). + expect(workRow?.quotaStatus).toBe('ok'); + }); + + it("per-profile breaker: one profile's 429s only block that profile", async () => { + const clock = { now: 1_000_000 }; + let workCall429Count = 0; + + // 'work' returns 429 each call; 'ck' returns success. + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: ['work', 'ck'], + codexProfiles: [], + claudeDefault: 'work', + credsForProfile: () => maxCreds(), + claudeFetch: async (_token, accountId) => { + if (accountId?.includes('work')) { + workCall429Count += 1; + return { + success: false, + windows: [], + coreUsage: { fiveHour: null, weekly: null }, + lastUpdated: clock.now, + accountId: accountId ?? '', + httpStatus: 429, + retryable: true, + error: 'rate limited', + } as ClaudeQuotaResult; + } + return successQuota(); + }, + }); + + // First call: 'work' gets a 429, 'ck' succeeds. + const rows1 = await getNativeAccountRows(deps); + const ck1 = rows1.find((r) => r.profile === 'ck'); + expect(ck1?.quotaStatus).toBe('ok'); + expect(workCall429Count).toBeGreaterThanOrEqual(1); + + // Skip past cooldown for 'work' only; 'ck' is within TTL. + clock.now += 62_000; + + // Second call past 'work' cooldown: work tries again (429 again); ck cached. + const rows2 = await getNativeAccountRows(deps); + const ck2 = rows2.find((r) => r.profile === 'ck'); + // 'ck' still has a good cached row. + expect(ck2?.quotaStatus).toBe('ok'); + }); +}); + +describe('multi-profile: displayName uses profile name', () => { + it('displayName is the profile name, not "Claude Code" or "Codex"', async () => { + const clock = { now: 1_000_000 }; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: ['my-work'], + codexProfiles: ['my-codex'], + claudeDefault: 'my-work', + codexDefault: 'my-codex', + credsForProfile: () => maxCreds(), + codexNativeAuth: (p) => ({ accessToken: `tok-${p}`, accountId: `id-${p}` }), + }); + + const rows = await getNativeAccountRows(deps); + const c = rows.find((r) => r.surface === 'ccs'); + const x = rows.find((r) => r.surface === 'ccsx'); + expect(c?.displayName).toBe('my-work'); + expect(x?.displayName).toBe('my-codex'); + }); +}); + +describe('multi-profile: getCachedNativeAccountRows reflects per-profile maps', () => { + it('returns cached rows from all profiles after a collect', async () => { + const clock = { now: 1_000_000 }; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: ['work', 'ck'], + codexProfiles: ['personal'], + claudeDefault: 'work', + codexDefault: 'personal', + credsForProfile: () => maxCreds(), + codexNativeAuth: (p) => ({ accessToken: `tok-${p}`, accountId: `id-${p}` }), + }); + + await getNativeAccountRows(deps); + + const cached = getCachedNativeAccountRows(); + expect(cached.every((r) => r.cached === true)).toBe(true); + // Should have rows for work, ck, and personal (parked 'ck' has no cached row + // yet because it had creds in this test so it did fetch) + const profiles = cached.map((r) => r.profile); + expect(profiles).toContain('work'); + expect(profiles).toContain('personal'); + + resetNativeQuotaState(); + expect(getCachedNativeAccountRows()).toEqual([]); + }); +});