diff --git a/src/web-server/usage/native-quota-collector.ts b/src/web-server/usage/native-quota-collector.ts index 8c9e8188..78a59d58 100644 --- a/src/web-server/usage/native-quota-collector.ts +++ b/src/web-server/usage/native-quota-collector.ts @@ -43,7 +43,7 @@ import { } from './claude-native-credentials'; import { fetchClaudeQuotaWithToken } from '../../cliproxy/quota/quota-fetcher-claude'; import { fetchCodexQuota } from '../../cliproxy/quota/quota-fetcher-codex'; -import { getDefaultAccount } from '../../cliproxy/accounts/query'; +import { getDefaultAccount, getProviderAccounts } from '../../cliproxy/accounts/query'; import { getCodexLocalQuota, type CodexLocalQuota } from './codex-local-quota-collector'; import type { ClaudeQuotaResult, CodexQuotaResult } from '../../cliproxy/quota/quota-types'; import type { BarSummaryRow, QuotaWindowDetail } from '../routes/bar-routes'; @@ -618,10 +618,17 @@ function listCodexProfilesFromDisk(): string[] { const { CodexProfileRegistry } = require('../../codex-auth/codex-profile-registry') as { CodexProfileRegistry: new () => { listProfiles: () => string[]; + getProfile: (name: string) => { email?: string; account_id?: string }; }; }; const registry = new CodexProfileRegistry(); - const profiles = registry.listProfiles(); + const pausedIdentities = getPausedCodexAccountIdentities(); + const profiles = registry + .listProfiles() + .filter( + (name) => + !codexProfileMatchesPausedAccount(name, registry.getProfile(name), pausedIdentities) + ); // Add the bare ~/.codex/auth.json (the default `ccsx` invocation) as the // DEFAULT_PROFILE account when it exists. It is the "default way of running", // distinct from named `ccsx ` profiles. @@ -643,11 +650,23 @@ function getDefaultCodexProfileFromDisk(): string | null { const { CodexProfileRegistry } = require('../../codex-auth/codex-profile-registry') as { CodexProfileRegistry: new () => { getDefault: () => string | null; + getProfile: (name: string) => { email?: string; account_id?: string }; + listProfiles: () => string[]; }; }; const registry = new CodexProfileRegistry(); + const pausedIdentities = getPausedCodexAccountIdentities(); const def = registry.getDefault(); - if (def) return def; + if (def && !codexProfileMatchesPausedAccount(def, registry.getProfile(def), pausedIdentities)) { + return def; + } + const fallback = registry + .listProfiles() + .find( + (name) => + !codexProfileMatchesPausedAccount(name, registry.getProfile(name), pausedIdentities) + ); + if (fallback) return fallback; // Fall back to the bare ~/.codex account (DEFAULT_PROFILE) when no registry // default is set — it is the default `ccsx` invocation. if (fs.existsSync(path.join(os.homedir(), '.codex', 'auth.json'))) return DEFAULT_PROFILE; @@ -657,6 +676,33 @@ function getDefaultCodexProfileFromDisk(): string | null { } } +function getPausedCodexAccountIdentities(): Set | null { + try { + const accounts = getProviderAccounts('codex'); + if (accounts.length === 0) return null; + const identities = new Set(); + for (const account of accounts) { + if (!account.paused) continue; + identities.add(account.id); + if (account.email) identities.add(account.email); + } + return identities; + } catch { + return null; + } +} + +function codexProfileMatchesPausedAccount( + profileName: string, + profile: { email?: string; account_id?: string }, + pausedIdentities: Set | null +): boolean { + if (!pausedIdentities || pausedIdentities.size === 0) return false; + return [profileName, profile.email, profile.account_id].some( + (identity): identity is string => typeof identity === 'string' && pausedIdentities.has(identity) + ); +} + // ============================================================================ // Per-profile collectors with full safety controls // ============================================================================ diff --git a/tests/unit/web-server/native-quota-collector.test.ts b/tests/unit/web-server/native-quota-collector.test.ts index 2cf892a8..d66abb5f 100644 --- a/tests/unit/web-server/native-quota-collector.test.ts +++ b/tests/unit/web-server/native-quota-collector.test.ts @@ -7,6 +7,9 @@ */ import { beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; import { getNativeAccountRows, getCachedNativeAccountRows, @@ -467,6 +470,202 @@ function makeCodexDeps( // ============================================================================ describe('Codex network path', () => { + it('production profile enumeration skips paused Codex accounts before live network refresh', async () => { + const originalCcsHome = process.env.CCS_HOME; + const originalHome = process.env.HOME; + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-bar-paused-codex-')); + process.env.CCS_HOME = tempHome; + process.env.HOME = tempHome; + + try { + const ccsDir = path.join(tempHome, '.ccs'); + const cliproxyDir = path.join(ccsDir, 'cliproxy'); + fs.mkdirSync(cliproxyDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'codex-profiles.yaml'), + [ + 'version: "1.0"', + 'default: paused', + 'profiles:', + ' paused:', + ' type: codex', + ' created: "2026-01-01T00:00:00.000Z"', + ' last_used: null', + ' email: paused-codex@example.com', + ' account_id: paused-codex@example.com', + ' active:', + ' type: codex', + ' created: "2026-01-02T00:00:00.000Z"', + ' last_used: null', + ' email: active-codex@example.com', + ' account_id: active-codex@example.com', + '', + ].join('\n') + ); + fs.writeFileSync( + path.join(cliproxyDir, 'accounts.json'), + JSON.stringify( + { + version: 1, + providers: { + codex: { + default: 'paused-codex@example.com', + accounts: { + 'paused-codex@example.com': { + email: 'paused-codex@example.com', + tokenFile: 'paused-codex@example.com.json', + paused: true, + }, + 'active-codex@example.com': { + email: 'active-codex@example.com', + tokenFile: 'active-codex@example.com.json', + }, + }, + }, + }, + }, + null, + 2 + ) + ); + fs.mkdirSync(path.join(cliproxyDir, 'auth'), { recursive: true }); + fs.mkdirSync(path.join(cliproxyDir, 'auth-paused'), { recursive: true }); + fs.writeFileSync( + path.join(cliproxyDir, 'auth', 'active-codex@example.com.json'), + JSON.stringify({ type: 'codex' }) + ); + fs.writeFileSync( + path.join(cliproxyDir, 'auth-paused', 'paused-codex@example.com.json'), + JSON.stringify({ type: 'codex' }) + ); + + const fetchedAccountIds: string[] = []; + const deps = makeCodexDeps({ + clock: { now: 1_000_000 }, + listClaudeProfiles: () => [], + readCredentials: undefined, + getDefaultCodexAccountId: undefined, + readCodexNativeAuth: (profile: string) => ({ + accessToken: `token-${profile}`, + accountId: profile === 'active' ? 'active-codex@example.com' : 'paused-codex@example.com', + }), + fetchCodexNetworkQuota: async (accountId: string) => { + fetchedAccountIds.push(accountId); + return { ...codexSuccessQuota(), accountId }; + }, + }); + + const rows = await getNativeAccountRows(deps); + + expect(fetchedAccountIds).toEqual(['active-codex@example.com']); + expect(rows.find((row) => row.profile === 'paused')).toBeUndefined(); + const active = rows.find((row) => row.profile === 'active'); + expect(active?.paused).toBe(false); + expect(active?.is_default).toBe(true); + } finally { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + if (originalHome !== undefined) { + process.env.HOME = originalHome; + } else { + delete process.env.HOME; + } + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + it('production profile enumeration does not live-refresh when all Codex accounts are paused', async () => { + const originalCcsHome = process.env.CCS_HOME; + const originalHome = process.env.HOME; + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-bar-all-paused-codex-')); + process.env.CCS_HOME = tempHome; + process.env.HOME = tempHome; + + try { + const ccsDir = path.join(tempHome, '.ccs'); + const cliproxyDir = path.join(ccsDir, 'cliproxy'); + fs.mkdirSync(cliproxyDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'codex-profiles.yaml'), + [ + 'version: "1.0"', + 'default: paused', + 'profiles:', + ' paused:', + ' type: codex', + ' created: "2026-01-01T00:00:00.000Z"', + ' last_used: null', + ' email: paused-codex@example.com', + ' account_id: paused-codex@example.com', + '', + ].join('\n') + ); + fs.writeFileSync( + path.join(cliproxyDir, 'accounts.json'), + JSON.stringify( + { + version: 1, + providers: { + codex: { + default: 'paused-codex@example.com', + accounts: { + 'paused-codex@example.com': { + email: 'paused-codex@example.com', + tokenFile: 'paused-codex@example.com.json', + paused: true, + }, + }, + }, + }, + }, + null, + 2 + ) + ); + fs.mkdirSync(path.join(cliproxyDir, 'auth-paused'), { recursive: true }); + fs.writeFileSync( + path.join(cliproxyDir, 'auth-paused', 'paused-codex@example.com.json'), + JSON.stringify({ type: 'codex' }) + ); + + let networkCalls = 0; + const deps = makeCodexDeps({ + clock: { now: 1_000_000 }, + listClaudeProfiles: () => [], + readCredentials: undefined, + getDefaultCodexAccountId: undefined, + readCodexNativeAuth: (profile: string) => ({ + accessToken: `token-${profile}`, + accountId: 'paused-codex@example.com', + }), + fetchCodexNetworkQuota: async () => { + networkCalls += 1; + return codexSuccessQuota(); + }, + }); + + const rows = await getNativeAccountRows(deps); + + expect(networkCalls).toBe(0); + expect(deps.localCount()).toBe(0); + expect(rows.find((row) => row.profile === 'paused')).toBeUndefined(); + } finally { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + if (originalHome !== undefined) { + process.env.HOME = originalHome; + } else { + delete process.env.HOME; + } + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); it('network success builds a fresh row from coreUsage — no staleAsOf, health ok, correct windows', async () => { const clock = { now: 1_000_000 }; const deps = makeCodexDeps({ clock });