From a5a5b742e4255051b160001985c4c545e85b0cf8 Mon Sep 17 00:00:00 2001 From: poomsc Date: Sat, 8 Aug 2026 14:06:14 +0700 Subject: [PATCH] fix(bar): stop false re-auth on non-default native subscription profiles Fixes the two collector-side root causes of #1601: 1. Claude per-profile credential reads were file-only, but on macOS Claude Code stores the OAuth token for an isolated CLAUDE_CONFIG_DIR in a per-directory Keychain item ("Claude Code-credentials-"). The .credentials.json file never exists, so every isolated profile was parked with needsReauth:true forever. The reader now falls back to that Keychain item (file-first, same security-CLI read the shipped global fallback already performs; TTL-gated so it is not on every /summary). 2. Non-default profiles were cache-only forever, so they could never leave the parked state even with valid credentials. getNativeAccountRows now gives each surface ONE rotating live slot: the stalest eligible non-default profile is refreshed per pass, skipping profiles inside breaker/reauth cooldowns. Every account converges to real quota within a few polls while the per-pass upstream budget stays constant (<= 2 calls per surface regardless of profile count). Codex named profiles with valid auth but sparse payloads now yield an active quota-less row instead of a false needsReauth row. Non-default rows keep paused:true (dimmed) even when freshly refreshed so only the default renders active and rows do not flicker between polls. Co-Authored-By: Claude Fable 5 --- docs/reports/hardening-inventory.json | 6 +- docs/reports/hardening-inventory.md | 6 +- .../usage/claude-native-credentials.ts | 84 ++++++++-- .../usage/native-quota-collector.ts | 132 ++++++++++++---- .../claude-native-credentials.test.ts | 91 +++++++++++ .../web-server/native-quota-collector.test.ts | 149 ++++++++++++++++-- 6 files changed, 401 insertions(+), 67 deletions(-) diff --git a/docs/reports/hardening-inventory.json b/docs/reports/hardening-inventory.json index 976dbcc2..493070d0 100644 --- a/docs/reports/hardening-inventory.json +++ b/docs/reports/hardening-inventory.json @@ -1,9 +1,9 @@ { "scope": "src/**/*.{ts,tsx,js,jsx,mjs,cjs}", "syncFs": { - "totalOccurrences": 2427, + "totalOccurrences": 2426, "filesAffected": 258, - "hotpathOccurrences": 1142, + "hotpathOccurrences": 1141, "hotpathFilesAffected": 152, "topHotpathFiles": [ { @@ -725,7 +725,7 @@ "topOver400": [ { "file": "src/web-server/usage/native-quota-collector.ts", - "loc": 1662 + "loc": 1730 }, { "file": "src/web-server/routes/cliproxy-auth-routes.ts", diff --git a/docs/reports/hardening-inventory.md b/docs/reports/hardening-inventory.md index 0b8a3082..e8435342 100644 --- a/docs/reports/hardening-inventory.md +++ b/docs/reports/hardening-inventory.md @@ -6,9 +6,9 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` | Metric | Value | |---|---:| -| Sync fs occurrences (all) | 2427 | +| Sync fs occurrences (all) | 2426 | | Sync fs files affected (all) | 258 | -| Sync fs occurrences (runtime hotpaths) | 1142 | +| Sync fs occurrences (runtime hotpaths) | 1141 | | Sync fs files affected (runtime hotpaths) | 152 | | Legacy shim markers | 458 | | Legacy shim files affected | 173 | @@ -89,7 +89,7 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` | File | LOC | |---|---:| -| `src/web-server/usage/native-quota-collector.ts` | 1662 | +| `src/web-server/usage/native-quota-collector.ts` | 1730 | | `src/web-server/routes/cliproxy-auth-routes.ts` | 1531 | | `src/cliproxy/auth/oauth-handler.ts` | 1510 | | `src/cursor/cursor-executor.ts` | 1234 | diff --git a/src/web-server/usage/claude-native-credentials.ts b/src/web-server/usage/claude-native-credentials.ts index c4da718f..9b19a819 100644 --- a/src/web-server/usage/claude-native-credentials.ts +++ b/src/web-server/usage/claude-native-credentials.ts @@ -16,6 +16,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { execSync } from 'node:child_process'; +import * as crypto from 'node:crypto'; import * as os from 'node:os'; import * as path from 'node:path'; @@ -87,20 +88,77 @@ export function readClaudeCredentials( } if (platform === 'darwin') { - try { - const out = execImpl(`security find-generic-password -s "${KEYCHAIN_SERVICE}" -w`, { - timeout: KEYCHAIN_TIMEOUT_MS, - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'ignore'], - }); - const raw = (typeof out === 'string' ? out : out.toString('utf8')).trim(); - if (raw) { - const parsed = parseCredentials(raw); - if (parsed) return parsed; - } - } catch { - // no Keychain entry / access denied -> null + const parsed = readCredentialsFromKeychainService(KEYCHAIN_SERVICE, execImpl); + if (parsed) return parsed; + } + + return null; +} + +/** Read + parse one Keychain generic-password item. Returns null on any failure. */ +function readCredentialsFromKeychainService( + service: string, + execImpl: NonNullable +): ClaudeNativeCredentials | null { + try { + const out = execImpl(`security find-generic-password -s "${service}" -w`, { + timeout: KEYCHAIN_TIMEOUT_MS, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + }); + const raw = (typeof out === 'string' ? out : out.toString('utf8')).trim(); + if (raw) { + const parsed = parseCredentials(raw); + if (parsed) return parsed; } + } catch { + // no Keychain entry / access denied -> null + } + return null; +} + +/** + * Keychain service name Claude Code uses for a non-default CLAUDE_CONFIG_DIR: + * "Claude Code-credentials-". + */ +export function claudeKeychainServiceForConfigDir(configDir: string): string { + const hash = crypto.createHash('sha256').update(configDir).digest('hex').slice(0, 8); + return `${KEYCHAIN_SERVICE}-${hash}`; +} + +/** + * Read the Claude Code credentials for a specific CLAUDE_CONFIG_DIR (e.g. an + * isolated `ccs auth` instance directory). + * + * File-first (/.credentials.json, no prompt), then the macOS + * Keychain item derived from the config dir path. On macOS Claude Code stores + * OAuth tokens in the Keychain by default, so without the Keychain fallback + * every isolated profile looks permanently logged-out to the bar. + */ +export function readClaudeCredentialsForConfigDir( + configDir: string, + deps: CredentialReaderDeps = {} +): ClaudeNativeCredentials | null { + const platform = deps.platform ?? os.platform(); + const existsImpl = deps.existsSyncImpl ?? existsSync; + const readImpl = deps.readFileSyncImpl ?? ((p: string) => readFileSync(p, 'utf8')); + const execImpl = deps.execSyncImpl ?? execSync; + + const credentialsPath = path.join(configDir, '.credentials.json'); + if (existsImpl(credentialsPath)) { + try { + const parsed = parseCredentials(readImpl(credentialsPath)); + if (parsed) return parsed; + } catch { + // fall through to Keychain + } + } + + if (platform === 'darwin') { + return readCredentialsFromKeychainService( + claudeKeychainServiceForConfigDir(configDir), + execImpl + ); } return null; diff --git a/src/web-server/usage/native-quota-collector.ts b/src/web-server/usage/native-quota-collector.ts index ecf942c3..58fe41cd 100644 --- a/src/web-server/usage/native-quota-collector.ts +++ b/src/web-server/usage/native-quota-collector.ts @@ -14,9 +14,9 @@ * - 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 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. + * Claude path: reads per-profile .credentials.json, then the per-config-dir + * Keychain item, and polls api.anthropic.com/api/oauth/usage. If neither source + * yields credentials the profile is emitted as a parked row (paused:true). * * Codex path: PRIMARY = live network (chatgpt.com/backend-api/wham/usage, via * fetchCodexQuota), FALLBACK = local session logs (getCodexLocalQuota), mirroring @@ -29,13 +29,15 @@ * 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. + * Claude per-profile reads are file-first with a per-config-dir macOS Keychain + * fallback (Claude Code stores OAuth tokens in the Keychain by default on + * macOS). The old global-default Claude reader (readClaudeCredentials) is kept + * for back-compat but is no longer used by the multi-profile path. */ import { readClaudeCredentials, + readClaudeCredentialsForConfigDir, getAccessToken, getSubscriptionTier, hasSupportedSubscription, @@ -110,7 +112,7 @@ export interface NativeQuotaDeps { /** Read the native Claude Code credentials (global default path). */ readCredentials?: () => ClaudeNativeCredentials | null; /** - * Read credentials for a specific Claude profile (file-only, no keychain). + * Read credentials for a specific Claude profile (file-first, Keychain fallback). * Injected so tests never touch real fs or Keychain. * profile: the profile name (e.g. "work"); returns null when absent/unparseable. */ @@ -487,11 +489,11 @@ function serveCached(state: ProviderState): BarSummaryRow | null { } // ============================================================================ -// File-only Claude credentials reader for per-profile paths (NO keychain) +// Per-profile Claude credentials reader (file-first, Keychain fallback) // ============================================================================ /** - * Read credentials for a specific Claude Code profile (file-only, no keychain). + * Read credentials for a specific Claude Code profile (file-first, Keychain fallback). * * 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. @@ -503,27 +505,20 @@ function readClaudeCredentialsForProfileFromDisk( ): ClaudeNativeCredentials | null { try { const instanceDir = path.join(getCcsDir(), 'instances', profile); - const credFile = path.join(instanceDir, '.credentials.json'); - if (fs.existsSync(credFile)) { - 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; - } // The bare `ccs` default login uses the standard global credential lookup: // ~/.claude/.credentials.json, falling back to the single global // "Claude Code-credentials" Keychain item that Claude Code itself maintains. - // This is the ONE pre-existing global read the shipped Bar already performs -- - // NOT a per-profile Keychain scan. Isolated `ccs auth` profiles stay - // file-only and never touch the Keychain; a real instance directory named - // "default" is therefore parked when its file is absent. if (profile === DEFAULT_PROFILE && !fs.existsSync(instanceDir)) { return readDefaultCredentials(); } - return null; + + // Isolated `ccs auth` instance: /.credentials.json first, then + // the per-config-dir Keychain item Claude Code maintains for this + // CLAUDE_CONFIG_DIR ("Claude Code-credentials-"). On + // macOS Claude Code stores tokens in the Keychain by default, so without + // the Keychain read every isolated profile is permanently parked. + return readClaudeCredentialsForConfigDir(instanceDir); } catch { return null; } @@ -787,6 +782,56 @@ function markDefault(row: BarSummaryRow, isDefault: boolean): BarSummaryRow { return { ...row, is_default: isDefault }; } +/** + * Force paused:true on a non-default row AND its cached copy. Non-default rows + * always render dimmed (only the default profile is "active"), including the + * pass where the rotating live slot refreshed them — otherwise the row would + * flicker active for one poll and dim again on the next. + */ +function markPausedAndSyncCache( + map: Map, + profile: string, + row: BarSummaryRow +): BarSummaryRow { + const state = map.get(profile); + if (state?.cachedRow) { + state.cachedRow = { ...state.cachedRow, paused: true }; + } + return { ...row, paused: true }; +} + +/** + * Pick the non-default profile the rotating live slot should refresh this pass: + * the stalest profile whose cached row is missing or past its TTL, skipping + * profiles inside a breaker/cooldown window (their collector would refuse the + * fetch anyway, wasting the slot). Returns null when every profile is fresh. + */ +function pickRotatingLiveProfile( + map: Map, + profiles: string[], + defaultProfile: string | null, + now: number +): string | null { + let picked: string | null = null; + let pickedAt = Number.POSITIVE_INFINITY; + for (const p of profiles) { + if (p === defaultProfile) continue; + const state = map.get(p); + if (state && (now < state.breakerOpenUntil || now < state.cooldownUntil)) continue; + const cachedRow = state?.cachedRow ?? null; + const cachedAt = state?.cachedAt ?? 0; + if (cachedRow) { + const ttl = cachedRow.quotaStatus === 'unsupported' ? PARKED_TTL_MS : NATIVE_QUOTA_TTL_MS; + if (now - cachedAt < ttl) continue; + } + if (cachedAt < pickedAt) { + pickedAt = cachedAt; + picked = p; + } + } + return picked; +} + /** * Tag the row with is_default AND write the flag back onto the cached copy. The * collector caches a row before the default profile is known (the default is @@ -835,7 +880,7 @@ async function collectClaudeRowForProfile( return state.pending; } - // For per-profile reads: use the injected seam (file-only, no keychain). + // For per-profile reads: use the injected seam (file-first, Keychain fallback). const readDefaultCredentials = deps.readCredentials ?? readClaudeCredentials; const readCreds = deps.readClaudeCredentialsForProfile ?? @@ -1579,13 +1624,28 @@ async function getNativeAccountRowsMultiProfile( const results: (BarSummaryRow | null)[] = []; const now = (deps.now ?? Date.now)(); - // Preserve the safety budget: only the active/default profile for each surface - // may perform a live refresh. Non-default profiles are cache-only (or parked) - // so one /summary request can trigger at most one Claude and one Codex live - // upstream call regardless of configured profile count. + // Preserve the safety budget: the active/default profile for each surface is + // live-polled every pass, plus ONE rotating live slot for the stalest + // non-default profile. All other profiles are cache-only (or parked), so one + // /summary request triggers at most two Claude and two Codex live upstream + // calls regardless of configured profile count — every account converges to + // real quota within a few polls without per-profile fan-out. + const claudeRotating = pickRotatingLiveProfile( + claudeProfileStates, + claudeProfiles, + claudeDefault, + now + ); + const codexRotating = pickRotatingLiveProfile( + codexProfileStates, + codexProfiles, + codexDefault, + now + ); + for (const p of claudeProfiles) { const isDefault = p === claudeDefault; - if (!isDefault) { + if (!isDefault && p !== claudeRotating) { results.push( markDefaultAndSyncCache( claudeProfileStates, @@ -1602,14 +1662,18 @@ async function getNativeAccountRowsMultiProfile( deps, force || claudeProfileStates.get(p)?.cachedRow?.quotaStatus === 'unsupported' ) - .then((r) => (r ? markDefaultAndSyncCache(claudeProfileStates, p, r, true) : null)) + .then((r) => { + if (!r) return null; + const marked = markDefaultAndSyncCache(claudeProfileStates, p, r, isDefault); + return isDefault ? marked : markPausedAndSyncCache(claudeProfileStates, p, marked); + }) .catch(() => null) ); } for (const p of codexProfiles) { const isDefault = p === codexDefault; - if (!isDefault) { + if (!isDefault && p !== codexRotating) { results.push( markDefaultAndSyncCache( codexProfileStates, @@ -1626,7 +1690,11 @@ async function getNativeAccountRowsMultiProfile( deps, force || codexProfileStates.get(p)?.cachedRow?.quotaStatus === 'unsupported' ) - .then((r) => (r ? markDefaultAndSyncCache(codexProfileStates, p, r, true) : null)) + .then((r) => { + if (!r) return null; + const marked = markDefaultAndSyncCache(codexProfileStates, p, r, isDefault); + return isDefault ? marked : markPausedAndSyncCache(codexProfileStates, p, marked); + }) .catch(() => null) ); } diff --git a/tests/unit/web-server/claude-native-credentials.test.ts b/tests/unit/web-server/claude-native-credentials.test.ts index 4a4643eb..41462c2b 100644 --- a/tests/unit/web-server/claude-native-credentials.test.ts +++ b/tests/unit/web-server/claude-native-credentials.test.ts @@ -8,6 +8,8 @@ import { describe, expect, it } from 'bun:test'; import { readClaudeCredentials, + readClaudeCredentialsForConfigDir, + claudeKeychainServiceForConfigDir, getAccessToken, getSubscriptionTier, hasSupportedSubscription, @@ -124,3 +126,92 @@ describe('token + tier extraction', () => { expect(getSubscriptionTier(null)).toBeNull(); }); }); + +// --------------------------------------------------------------------------- +// Per-config-dir credential reading (isolated `ccs auth` profiles on macOS) +// +// Claude Code stores OAuth credentials for a non-default CLAUDE_CONFIG_DIR in +// a per-directory Keychain item: service "Claude Code-credentials-", +// where is the first 8 hex chars of sha256(configDir). These tests pin +// that derivation and the file-first / Keychain-fallback read order. +// --------------------------------------------------------------------------- + +describe('claudeKeychainServiceForConfigDir', () => { + it('derives the service name from sha256 of the config dir path (first 8 hex chars)', () => { + // sha256("/home/test/.ccs/instances/work") = ffeb4b45... + expect(claudeKeychainServiceForConfigDir('/home/test/.ccs/instances/work')).toBe( + 'Claude Code-credentials-ffeb4b45' + ); + }); +}); + +describe('readClaudeCredentialsForConfigDir', () => { + const configDir = '/home/test/.ccs/instances/work'; + const credFile = `${configDir}/.credentials.json`; + + it('reads /.credentials.json when present (file-first, no Keychain)', () => { + let keychainCalled = false; + const creds = readClaudeCredentialsForConfigDir(configDir, { + platform: 'darwin', + existsSyncImpl: (p: string) => p === credFile, + readFileSyncImpl: (p: string) => { + expect(p).toBe(credFile); + return JSON.stringify(makeCreds()); + }, + execSyncImpl: () => { + keychainCalled = true; + return ''; + }, + }); + expect(creds?.claudeAiOauth?.accessToken).toBe('tok-abc'); + expect(keychainCalled).toBe(false); + }); + + it('falls back to the per-config-dir Keychain service when the file is absent', () => { + let keychainCmd = ''; + const creds = readClaudeCredentialsForConfigDir(configDir, { + platform: 'darwin', + existsSyncImpl: () => false, + readFileSyncImpl: () => { + throw new Error('should not read file'); + }, + execSyncImpl: (cmd: string) => { + keychainCmd = cmd; + return JSON.stringify(makeCreds({ subscriptionType: 'team' })); + }, + }); + expect(creds?.claudeAiOauth?.subscriptionType).toBe('team'); + expect(keychainCmd).toContain('Claude Code-credentials-ffeb4b45'); + }); + + it('returns null when both file and Keychain are absent', () => { + const creds = readClaudeCredentialsForConfigDir(configDir, { + platform: 'darwin', + existsSyncImpl: () => false, + readFileSyncImpl: () => { + throw new Error('no file'); + }, + execSyncImpl: () => { + throw new Error('no keychain entry'); + }, + }); + expect(creds).toBeNull(); + }); + + it('does not consult the Keychain on non-darwin platforms', () => { + let keychainCalled = false; + const creds = readClaudeCredentialsForConfigDir(configDir, { + platform: 'linux', + existsSyncImpl: () => false, + readFileSyncImpl: () => { + throw new Error('no file'); + }, + execSyncImpl: () => { + keychainCalled = true; + return ''; + }, + }); + expect(creds).toBeNull(); + expect(keychainCalled).toBe(false); + }); +}); diff --git a/tests/unit/web-server/native-quota-collector.test.ts b/tests/unit/web-server/native-quota-collector.test.ts index 9c24fa0f..42feb053 100644 --- a/tests/unit/web-server/native-quota-collector.test.ts +++ b/tests/unit/web-server/native-quota-collector.test.ts @@ -1170,8 +1170,10 @@ describe('multi-profile: account_id and wire fields', () => { const rows = await getNativeAccountRows(deps); expect(rows.length).toBe(claudeProfiles.length + codexProfiles.length); - expect(deps.claudeFetchCount()).toBe(1); - expect(deps.codexNetworkCount()).toBe(1); + // Budget per pass: the default + one rotating non-default live slot per + // surface — constant regardless of profile count. + expect(deps.claudeFetchCount()).toBe(2); + expect(deps.codexNetworkCount()).toBe(2); }); it('rows are sorted by (surface, profile)', async () => { @@ -1396,8 +1398,9 @@ describe('multi-profile: per-profile circuit breaker isolation', () => { const ckRow = rows.find((r) => r.profile === 'ck'); const workRow = rows.find((r) => r.profile === 'work'); - // 'ck' stays cache-only, independent of work's breaker. - expect(ckRow?.quotaStatus).toBe('unsupported'); + // 'ck' gets its own live row via the rotating slot, independent of work's + // breaker history. + expect(ckRow?.quotaStatus).toBe('ok'); // 'work' is also fine after reset (no breaker state). expect(workRow?.quotaStatus).toBe('ok'); }); @@ -1431,10 +1434,11 @@ describe('multi-profile: per-profile circuit breaker isolation', () => { }, }); - // First call: 'work' gets a 429, 'ck' remains cache-only. + // First call: 'work' gets a 429; 'ck' is refreshed by the rotating slot and + // succeeds — work's failures do not leak into ck's state. const rows1 = await getNativeAccountRows(deps); const ck1 = rows1.find((r) => r.profile === 'ck'); - expect(ck1?.quotaStatus).toBe('unsupported'); + expect(ck1?.quotaStatus).toBe('ok'); expect(workCall429Count).toBeGreaterThanOrEqual(1); // Skip past cooldown for 'work' only; 'ck' is within TTL. @@ -1443,8 +1447,8 @@ describe('multi-profile: per-profile circuit breaker isolation', () => { // 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' remains cache-only and is not affected by work's breaker. - expect(ck2?.quotaStatus).toBe('unsupported'); + // 'ck' keeps its healthy cached row and is not affected by work's breaker. + expect(ck2?.quotaStatus).toBe('ok'); }); }); @@ -1537,15 +1541,18 @@ describe('review focus areas: reauth caching + codex local fallback', () => { codexNetworkFetch: async () => ({ success: false, needsReauth: true }) as CodexQuotaResult, }); + // The rotating slot polls 'ck' once; the 401 parks it into the reauth + // cooldown. const first = await getNativeAccountRows(deps); const r1 = first.find((r) => r.profile === 'ck'); expect(r1?.needsReauth).toBe(true); expect(r1?.paused).toBe(true); - expect(deps.codexNetworkCount()).toBe(0); + expect(deps.codexNetworkCount()).toBe(1); + // Within the cooldown it is NOT re-polled (no repeated 401s), even forced. const second = await getNativeAccountRows(deps, { force: true }); expect(second.find((r) => r.profile === 'ck')?.cached).toBe(true); - expect(deps.codexNetworkCount()).toBe(0); + expect(deps.codexNetworkCount()).toBe(1); }); it('Codex named profile without on-disk auth is parked, never filled from global local data', async () => { @@ -1655,7 +1662,7 @@ describe('review focus areas: reauth caching + codex local fallback', () => { expect(deps.claudeFetchCount()).toBe(1); // re-checked -> fetched }); - it('Codex named profile with valid auth but sparse payload stays parked when not default', async () => { + it('Codex named profile with valid auth but sparse payload yields an active quota-less row', async () => { resetNativeQuotaState(); const clock = { now: 7_000_000 }; const deps = makeMultiProfileDeps({ @@ -1668,13 +1675,15 @@ describe('review focus areas: reauth caching + codex local fallback', () => { codexNetworkFetch: async () => ({ success: true }) as CodexQuotaResult, }); + // The rotating slot polls 'ck'; the token authenticated, so it is a valid + // active subscription with a sparse payload — an active quota-less row, + // still dimmed because it is not the default profile. const rows = await getNativeAccountRows(deps); const ck = rows.find((r) => r.profile === 'ck'); expect(ck).toBeDefined(); - expect(ck?.paused).toBe(true); // cache-only, parked until selected as default - expect(ck?.needsReauth).toBe(true); - expect(ck?.quotaStatus).toBe('unsupported'); - expect(ck?.quota_percentage).toBeNull(); // no windows while parked + expect(ck?.paused).toBe(true); // non-default rows always render dimmed + expect(ck?.needsReauth).toBe(false); + expect(ck?.quota_percentage).toBeNull(); // no windows in the payload }); it('non-default cache-only rows do not overwrite the canonical live cache', async () => { @@ -1691,9 +1700,11 @@ describe('review focus areas: reauth caching + codex local fallback', () => { }); deps.defaultCodexProfile = () => codexDefault; + // First pass: 'ck' (default) is live-polled and the rotating slot also + // refreshes 'default' — two upstream calls. const first = await getNativeAccountRows(deps); expect(first.find((r) => r.profile === 'ck')?.paused).toBe(false); - expect(deps.codexNetworkCount()).toBe(1); + expect(deps.codexNetworkCount()).toBe(2); codexDefault = 'default'; const second = await getNativeAccountRows(deps); @@ -1710,3 +1721,109 @@ describe('review focus areas: reauth caching + codex local fallback', () => { expect(deps.codexNetworkCount()).toBe(2); }); }); + +// ============================================================================ +// Multi-profile: rotating live slot for non-default profiles +// +// Non-default profiles used to be cache-only forever, so accounts other than +// the default never showed real quota — they sat parked ("needs re-auth") for +// the lifetime of the server. One rotating live slot per surface refreshes the +// stalest eligible non-default profile per pass, so every account converges to +// real data within a few polls while the per-pass upstream budget stays +// constant (<= 2 calls per surface) regardless of profile count. +// ============================================================================ + +describe('multi-profile: rotating live slot for non-default profiles', () => { + it('live-polls the default plus one stale non-default Claude profile per pass', async () => { + const clock = { now: 1_000_000 }; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: ['work', 'personal', 'fc'], + codexProfiles: [], + claudeDefault: 'work', + credsForProfile: () => maxCreds(), + claudeFetch: async () => successQuota(), + }); + + // Pass 1: default (work) + one stale non-default get live data. + let rows = await getNativeAccountRows(deps); + expect(deps.claudeFetchCount()).toBe(2); + expect(rows.filter((r) => r.quotaStatus === 'ok').length).toBe(2); + + // Pass 2 after the parked TTL: the remaining profile gets its live row. + clock.now += 31_000; + rows = await getNativeAccountRows(deps); + expect(deps.claudeFetchCount()).toBe(3); + expect(rows.filter((r) => r.quotaStatus === 'ok').length).toBe(3); + + // Pass 3 while everything is within TTL: fully cached, zero upstream calls. + clock.now += 1_000; + rows = await getNativeAccountRows(deps); + expect(deps.claudeFetchCount()).toBe(3); + expect(rows.filter((r) => r.quotaStatus === 'ok').length).toBe(3); + }); + + it('rotated non-default rows keep paused:true (only the default renders active)', async () => { + const clock = { now: 1_000_000 }; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: ['work', 'personal'], + codexProfiles: [], + claudeDefault: 'work', + credsForProfile: () => maxCreds(), + claudeFetch: async () => successQuota(), + }); + + const rows = await getNativeAccountRows(deps); + const personal = rows.find((r) => r.profile === 'personal'); + expect(personal?.quotaStatus).toBe('ok'); + expect(personal?.needsReauth).toBe(false); + expect(personal?.paused).toBe(true); + expect(personal?.is_default).toBe(false); + }); + + it('codex non-default profiles also get one rotating live slot per pass', 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(deps.codexNetworkCount()).toBe(2); + const ck = rows.find((r) => r.profile === 'ck'); + expect(ck?.paused).toBe(true); + expect(ck?.needsReauth).toBe(false); + }); + + it('profiles in reauth cooldown are skipped by the rotating slot', async () => { + const clock = { now: 1_000_000 }; + let failProfile: string | null = 'personal'; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: ['work', 'personal'], + codexProfiles: [], + claudeDefault: 'work', + credsForProfile: () => maxCreds(), + claudeFetch: async (_token: string, accountId?: string) => + accountId === `ccs:${failProfile}` + ? ({ success: false, needsReauth: true, retryable: false } as ClaudeQuotaResult) + : successQuota(), + }); + + // Pass 1: personal is rotated in, 401s, and enters the reauth cooldown. + let rows = await getNativeAccountRows(deps); + expect(rows.find((r) => r.profile === 'personal')?.needsReauth).toBe(true); + expect(deps.claudeFetchCount()).toBe(2); + + // Pass 2 inside the cooldown: the slot must NOT re-poll (and re-401) it. + clock.now += 31_000; + rows = await getNativeAccountRows(deps); + expect(deps.claudeFetchCount()).toBe(2); + expect(rows.find((r) => r.profile === 'personal')?.needsReauth).toBe(true); + }); +});