mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-12 22:14:22 +00:00
Merge pull request #1690 from poomsc/fix/bar-native-profile-reauth
fix(bar): stop false re-auth on non-default native subscription profiles
This commit is contained in:
8 files changed
+705
-111
No files matched your search
@@ -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": 1758
|
||||
},
|
||||
{
|
||||
"file": "src/web-server/routes/cliproxy-auth-routes.ts",
|
||||
|
||||
@@ -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` | 1758 |
|
||||
| `src/web-server/routes/cliproxy-auth-routes.ts` | 1531 |
|
||||
| `src/cliproxy/auth/oauth-handler.ts` | 1510 |
|
||||
| `src/cursor/cursor-executor.ts` | 1234 |
|
||||
|
||||
@@ -243,6 +243,11 @@ function withTimeout<T>(p: Promise<T>, ms: number): Promise<T | null> {
|
||||
});
|
||||
}
|
||||
|
||||
/** Remaining milliseconds before one absolute request deadline. */
|
||||
function remainingRequestBudget(deadlineAt: number): number {
|
||||
return Math.max(0, deadlineAt - Date.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a row to its wire shape. The native-only additions use snake_case parent
|
||||
* keys ("quota_windows" / "stale_as_of") to match the existing payload's mixed
|
||||
@@ -486,6 +491,7 @@ export function createBarRouter(deps: BarRouterDeps): Router {
|
||||
*/
|
||||
router.get('/summary', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const deadlineAt = Date.now() + REQUEST_DEADLINE_MS;
|
||||
const wantsRefresh = req.query['refresh'] === 'true';
|
||||
|
||||
// Determine effective refresh mode after applying debounce.
|
||||
@@ -503,10 +509,20 @@ export function createBarRouter(deps: BarRouterDeps): Router {
|
||||
// else: debounce active — fall through to cache path
|
||||
}
|
||||
|
||||
// Start the native side-load immediately. It shares the same absolute
|
||||
// response deadline as cost and CLIProxy quota work, so their individual
|
||||
// fallback waits cannot stack into a multi-second tail.
|
||||
const getNative = deps.getNativeAccountRows ?? (async () => [] as BarSummaryRow[]);
|
||||
const getCachedNative = deps.getCachedNativeRows ?? (() => [] as BarSummaryRow[]);
|
||||
const nativePromise = Promise.resolve().then(() => getNative({ force: doForceRefresh }));
|
||||
|
||||
// Cost side-load is bounded so a slow usage-snapshot read can't stall the
|
||||
// glance. (Health is per-account, derived from each quota result below —
|
||||
// no blocking system audit on the request path.)
|
||||
const details = await withTimeout(deps.loadCliproxyDetails(), SIDELOAD_TIMEOUT_MS);
|
||||
const details = await withTimeout(
|
||||
deps.loadCliproxyDetails(),
|
||||
Math.min(SIDELOAD_TIMEOUT_MS, remainingRequestBudget(deadlineAt))
|
||||
);
|
||||
const costByAccount: Record<string, number> = details
|
||||
? deps.getTodayCostByAccount(details)
|
||||
: {};
|
||||
@@ -565,24 +581,20 @@ export function createBarRouter(deps: BarRouterDeps): Router {
|
||||
return rows;
|
||||
})();
|
||||
|
||||
const deadline = new Promise<BarSummaryRow[]>((resolve) => {
|
||||
setTimeout(() => resolve(cacheRows()), REQUEST_DEADLINE_MS);
|
||||
});
|
||||
const rows = (await withTimeout(gather, remainingRequestBudget(deadlineAt))) ?? cacheRows();
|
||||
|
||||
const rows = await Promise.race([gather, deadline]);
|
||||
|
||||
// Native subscription rows (Claude Code + Codex) are side-loaded AFTER the
|
||||
// Native subscription rows (Claude Code + Codex) are joined after the
|
||||
// CLIProxy rows resolve, bounded so a slow/failed native fetch degrades
|
||||
// rather than blocking or erroring the response. Pass force so a
|
||||
// debounce-passing refresh also re-pulls native rows live. On timeout fall
|
||||
// back to the last-known cached native rows (NOT []) so a slow forced
|
||||
// re-pull never momentarily drops the Claude/Codex cards; the in-flight
|
||||
// fetch keeps warming the cache for the next poll.
|
||||
const getNative = deps.getNativeAccountRows ?? (async () => [] as BarSummaryRow[]);
|
||||
const getCachedNative = deps.getCachedNativeRows ?? (() => [] as BarSummaryRow[]);
|
||||
const nativeRows =
|
||||
(await withTimeout(getNative({ force: doForceRefresh }), NATIVE_SIDELOAD_TIMEOUT_MS)) ??
|
||||
getCachedNative();
|
||||
(await withTimeout(
|
||||
nativePromise,
|
||||
Math.min(NATIVE_SIDELOAD_TIMEOUT_MS, remainingRequestBudget(deadlineAt))
|
||||
)) ?? getCachedNative();
|
||||
|
||||
res.json([...rows, ...nativeRows].map(serializeBarRow));
|
||||
} catch (err) {
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { execFile } from 'node:child_process';
|
||||
import * as crypto from 'node:crypto';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
@@ -36,10 +37,20 @@ export interface CredentialReaderDeps {
|
||||
homedir?: string;
|
||||
existsSyncImpl?: (p: string) => boolean;
|
||||
readFileSyncImpl?: (p: string) => string;
|
||||
execSyncImpl?: (cmd: string, opts: Record<string, unknown>) => string | Buffer;
|
||||
execFileImpl?: ExecFileImpl;
|
||||
keychainTimeoutMs?: number;
|
||||
}
|
||||
|
||||
type ExecFileCallback = (error: Error | null, stdout: string | Buffer) => void;
|
||||
type ExecFileImpl = (
|
||||
file: string,
|
||||
args: readonly string[],
|
||||
options: Record<string, unknown>,
|
||||
callback: ExecFileCallback
|
||||
) => { kill?: () => void } | void;
|
||||
|
||||
const KEYCHAIN_SERVICE = 'Claude Code-credentials';
|
||||
const SECURITY_PATH = '/usr/bin/security';
|
||||
const KEYCHAIN_TIMEOUT_MS = 5000;
|
||||
|
||||
/** Subscription types that mean "no real subscription" -> skip the fetch. */
|
||||
@@ -67,14 +78,13 @@ function parseCredentials(raw: string): ClaudeNativeCredentials | null {
|
||||
* Keychain as a fallback. Returns null when neither source yields a parseable
|
||||
* object.
|
||||
*/
|
||||
export function readClaudeCredentials(
|
||||
export async function readClaudeCredentials(
|
||||
deps: CredentialReaderDeps = {}
|
||||
): ClaudeNativeCredentials | null {
|
||||
): Promise<ClaudeNativeCredentials | null> {
|
||||
const platform = deps.platform ?? os.platform();
|
||||
const homedir = deps.homedir ?? os.homedir();
|
||||
const existsImpl = deps.existsSyncImpl ?? existsSync;
|
||||
const readImpl = deps.readFileSyncImpl ?? ((p: string) => readFileSync(p, 'utf8'));
|
||||
const execImpl = deps.execSyncImpl ?? execSync;
|
||||
|
||||
const credentialsPath = path.join(homedir, '.claude', '.credentials.json');
|
||||
if (existsImpl(credentialsPath)) {
|
||||
@@ -87,20 +97,97 @@ export function readClaudeCredentials(
|
||||
}
|
||||
|
||||
if (platform === 'darwin') {
|
||||
const parsed = await readCredentialsFromKeychainService(KEYCHAIN_SERVICE, deps);
|
||||
if (parsed) return parsed;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Read + parse one Keychain generic-password item. Returns null on any failure. */
|
||||
async function readCredentialsFromKeychainService(
|
||||
service: string,
|
||||
deps: CredentialReaderDeps
|
||||
): Promise<ClaudeNativeCredentials | null> {
|
||||
const timeoutMs = deps.keychainTimeoutMs ?? KEYCHAIN_TIMEOUT_MS;
|
||||
const execImpl: ExecFileImpl =
|
||||
deps.execFileImpl ??
|
||||
((file, args, options, callback) =>
|
||||
execFile(file, [...args], options, (error, stdout) => callback(error, stdout)));
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
let child: { kill?: () => void } | void;
|
||||
const finish = (credentials: ClaudeNativeCredentials | null): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve(credentials);
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
child?.kill?.();
|
||||
finish(null);
|
||||
}, timeoutMs);
|
||||
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;
|
||||
}
|
||||
child = execImpl(
|
||||
SECURITY_PATH,
|
||||
['find-generic-password', '-s', service, '-w'],
|
||||
{
|
||||
timeout: timeoutMs,
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
maxBuffer: 1024 * 1024,
|
||||
},
|
||||
(error, stdout) => {
|
||||
if (error) return finish(null);
|
||||
const raw = (typeof stdout === 'string' ? stdout : stdout.toString('utf8')).trim();
|
||||
finish(raw ? parseCredentials(raw) : null);
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
// no Keychain entry / access denied -> null
|
||||
finish(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Keychain service name Claude Code uses for a non-default CLAUDE_CONFIG_DIR:
|
||||
* "Claude Code-credentials-<first 8 hex chars of sha256(configDir)>".
|
||||
*/
|
||||
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 (<configDir>/.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 async function readClaudeCredentialsForConfigDir(
|
||||
configDir: string,
|
||||
deps: CredentialReaderDeps = {}
|
||||
): Promise<ClaudeNativeCredentials | null> {
|
||||
const platform = deps.platform ?? os.platform();
|
||||
const existsImpl = deps.existsSyncImpl ?? existsSync;
|
||||
const readImpl = deps.readFileSyncImpl ?? ((p: string) => readFileSync(p, 'utf8'));
|
||||
|
||||
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), deps);
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -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,
|
||||
@@ -108,13 +110,15 @@ const CODEX_PROVIDER = CODEX_NATIVE_PROVIDER;
|
||||
|
||||
export interface NativeQuotaDeps {
|
||||
/** Read the native Claude Code credentials (global default path). */
|
||||
readCredentials?: () => ClaudeNativeCredentials | null;
|
||||
readCredentials?: () => ClaudeNativeCredentials | null | Promise<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.
|
||||
*/
|
||||
readClaudeCredentialsForProfile?: (profile: string) => ClaudeNativeCredentials | null;
|
||||
readClaudeCredentialsForProfile?: (
|
||||
profile: string
|
||||
) => ClaudeNativeCredentials | null | Promise<ClaudeNativeCredentials | null>;
|
||||
/** Fetch Claude quota with a directly-supplied native token. */
|
||||
fetchClaudeQuota?: (accessToken: string, accountId?: string) => Promise<ClaudeQuotaResult>;
|
||||
/**
|
||||
@@ -487,43 +491,39 @@ 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.
|
||||
* Never calls security/Keychain — zero new keychain access from this feature.
|
||||
* Looks for .credentials.json in the profile's instance directory, then uses
|
||||
* the bounded per-config-dir macOS Keychain fallback. If neither yields a
|
||||
* parseable credential object, the caller emits a parked row.
|
||||
*/
|
||||
function readClaudeCredentialsForProfileFromDisk(
|
||||
async function readClaudeCredentialsForProfileFromDisk(
|
||||
profile: string,
|
||||
readDefaultCredentials: () => ClaudeNativeCredentials | null = readClaudeCredentials
|
||||
): ClaudeNativeCredentials | null {
|
||||
readDefaultCredentials: () =>
|
||||
| ClaudeNativeCredentials
|
||||
| null
|
||||
| Promise<ClaudeNativeCredentials | null> = readClaudeCredentials
|
||||
): Promise<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 await readDefaultCredentials();
|
||||
}
|
||||
return null;
|
||||
|
||||
// Isolated `ccs auth` instance: <instanceDir>/.credentials.json first, then
|
||||
// the per-config-dir Keychain item Claude Code maintains for this
|
||||
// CLAUDE_CONFIG_DIR ("Claude Code-credentials-<sha256(dir)[0..8]>"). On
|
||||
// macOS Claude Code stores tokens in the Keychain by default, so without
|
||||
// the Keychain read every isolated profile is permanently parked.
|
||||
return await readClaudeCredentialsForConfigDir(instanceDir);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -787,6 +787,72 @@ 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<string, ProviderState>,
|
||||
profile: string,
|
||||
row: BarSummaryRow
|
||||
): BarSummaryRow {
|
||||
const state = map.get(profile);
|
||||
if (state?.cachedRow) {
|
||||
state.cachedRow = { ...state.cachedRow, paused: true };
|
||||
}
|
||||
return { ...row, paused: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the cached row was fetched BEFORE its own next_reset boundary and
|
||||
* that boundary has now passed — the row describes the previous quota window,
|
||||
* so its values (and the reset time itself) are visibly wrong in the bar.
|
||||
* The cachedAt guard means a post-reset payload that still reports a past
|
||||
* reset cannot cause a refetch loop: once re-fetched, normal TTL applies.
|
||||
*/
|
||||
function isCachedRowStaleByReset(state: ProviderState, now: number): boolean {
|
||||
const nextReset = state.cachedRow?.next_reset;
|
||||
if (!nextReset) return false;
|
||||
const resetMs = Date.parse(nextReset);
|
||||
if (!Number.isFinite(resetMs)) return false;
|
||||
return resetMs <= now && state.cachedAt < resetMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the non-default profile the rotating live slot should refresh this pass:
|
||||
* the stalest profile whose cached row is missing, past its TTL, or past its
|
||||
* own quota reset, 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<string, ProviderState>,
|
||||
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 && state) {
|
||||
const ttl = cachedRow.quotaStatus === 'unsupported' ? PARKED_TTL_MS : NATIVE_QUOTA_TTL_MS;
|
||||
if (now - cachedAt < ttl && !isCachedRowStaleByReset(state, now)) 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
|
||||
@@ -819,9 +885,13 @@ async function collectClaudeRowForProfile(
|
||||
// Serve from cache while within TTL — force bypasses the short-circuit. Parked
|
||||
// rows (no creds -> quotaStatus 'unsupported') use a short TTL so a fresh login
|
||||
// is picked up within seconds instead of staying dimmed for the full quota TTL.
|
||||
// A row whose own next_reset has passed is stale regardless of TTL — the
|
||||
// quota snapped back at the boundary and the cached values are visibly wrong.
|
||||
if (!force && state.cachedRow) {
|
||||
const ttl = state.cachedRow.quotaStatus === 'unsupported' ? PARKED_TTL_MS : NATIVE_QUOTA_TTL_MS;
|
||||
if (now - state.cachedAt < ttl) return serveCached(state);
|
||||
if (now - state.cachedAt < ttl && !isCachedRowStaleByReset(state, now)) {
|
||||
return serveCached(state);
|
||||
}
|
||||
}
|
||||
|
||||
// Breaker open or cooldown active -> zero network, serve stale (may be null).
|
||||
@@ -835,7 +905,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 ??
|
||||
@@ -851,12 +921,12 @@ async function collectClaudeRowForProfile(
|
||||
// pending DURING assignment, leaving a stale resolved promise that the
|
||||
// next call's coalescing check would return instead of re-evaluating.
|
||||
await Promise.resolve();
|
||||
const creds = readCreds(profile);
|
||||
const creds = await 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).
|
||||
// user has not logged in via 'ccs auth' for this machine or neither the
|
||||
// profile file nor its bounded macOS Keychain fallback yielded credentials.
|
||||
if (!creds) {
|
||||
const parkedRow = buildParkedClaudeProfileRow(profile, now);
|
||||
// Cache the parked row so repeated calls don't re-stat the fs.
|
||||
@@ -967,9 +1037,12 @@ async function collectCodexRowForProfile(
|
||||
// Serve from cache while within TTL — force bypasses the short-circuit. Parked
|
||||
// rows (no auth -> quotaStatus 'unsupported') use a short TTL so a fresh login
|
||||
// is picked up within seconds instead of staying dimmed for the full quota TTL.
|
||||
// A row whose own next_reset has passed is stale regardless of TTL.
|
||||
if (!force && state.cachedRow) {
|
||||
const ttl = state.cachedRow.quotaStatus === 'unsupported' ? PARKED_TTL_MS : NATIVE_QUOTA_TTL_MS;
|
||||
if (now - state.cachedAt < ttl) return serveCached(state);
|
||||
if (now - state.cachedAt < ttl && !isCachedRowStaleByReset(state, now)) {
|
||||
return serveCached(state);
|
||||
}
|
||||
}
|
||||
|
||||
// Breaker open or cooldown active -> skip network, go to LOCAL fallback.
|
||||
@@ -1170,7 +1243,7 @@ async function collectClaudeRow(
|
||||
|
||||
state.pending = (async (): Promise<BarSummaryRow | null> => {
|
||||
try {
|
||||
const creds = readCredentialsFn();
|
||||
const creds = await readCredentialsFn();
|
||||
// No token / unsupported subscription -> never spend a call, omit the row.
|
||||
if (!creds || !hasSupportedSubscription(creds)) {
|
||||
return serveCached(state);
|
||||
@@ -1579,13 +1652,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 +1690,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 +1718,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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1116,6 +1116,49 @@ describe('/summary force flag passed to getNativeAccountRows', () => {
|
||||
expect(status).toBe(200);
|
||||
expect(body.some((r) => r.provider === 'codex')).toBe(true);
|
||||
});
|
||||
|
||||
it('enforces one absolute deadline across cost, quota, and blocked native work', async () => {
|
||||
const { createBarRouter, resetForceFreshDebounce: resetDebounce } = await import(
|
||||
'../../../src/web-server/routes/bar-routes'
|
||||
);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
const router = createBarRouter({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
getAllAccountsSummary: () => ({ agy: [makeAccountInfo()] }) as any,
|
||||
getCachedQuota: () => makeQuotaResult(),
|
||||
setCachedQuota: () => {},
|
||||
invalidateQuotaCache: () => {},
|
||||
fetchAccountQuota: () => new Promise(() => {}),
|
||||
getTodayCostByAccount: () => ({}),
|
||||
loadCliproxyDetails: () => new Promise((resolve) => setTimeout(() => resolve([]), 1_400)),
|
||||
loadDailyUsage: async () => [],
|
||||
loadHourlyUsage: async () => [],
|
||||
getNativeAccountRows: () => new Promise(() => {}),
|
||||
getCachedNativeRows: () => [],
|
||||
});
|
||||
app.use('/api/bar', router);
|
||||
const srv = await new Promise<Server>((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();
|
||||
|
||||
const startedAt = Date.now();
|
||||
const { status } = await getJson<BarSummaryRow[]>(
|
||||
`http://127.0.0.1:${(addr as { port: number }).port}`,
|
||||
'/api/bar/summary?refresh=true'
|
||||
);
|
||||
const elapsed = Date.now() - startedAt;
|
||||
await new Promise<void>((resolve) => srv.close(() => resolve()));
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(elapsed).toBeGreaterThanOrEqual(2_200);
|
||||
expect(elapsed).toBeLessThan(3_200);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
readClaudeCredentials,
|
||||
readClaudeCredentialsForConfigDir,
|
||||
claudeKeychainServiceForConfigDir,
|
||||
getAccessToken,
|
||||
getSubscriptionTier,
|
||||
hasSupportedSubscription,
|
||||
@@ -25,14 +27,14 @@ function makeCreds(overrides: Record<string, unknown> = {}): ClaudeNativeCredent
|
||||
}
|
||||
|
||||
describe('readClaudeCredentials', () => {
|
||||
it('parses the on-disk credentials file when present (file-first, no Keychain)', () => {
|
||||
it('parses the on-disk credentials file when present (file-first, no Keychain)', async () => {
|
||||
let keychainCalled = false;
|
||||
const creds = readClaudeCredentials({
|
||||
const creds = await readClaudeCredentials({
|
||||
platform: 'darwin',
|
||||
homedir: '/home/test',
|
||||
existsSyncImpl: () => true,
|
||||
readFileSyncImpl: () => JSON.stringify(makeCreds()),
|
||||
execSyncImpl: () => {
|
||||
execFileImpl: () => {
|
||||
keychainCalled = true;
|
||||
return '';
|
||||
},
|
||||
@@ -42,44 +44,52 @@ describe('readClaudeCredentials', () => {
|
||||
expect(keychainCalled).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to the macOS Keychain when the file is absent', () => {
|
||||
const creds = readClaudeCredentials({
|
||||
it('falls back to the macOS Keychain when the file is absent', async () => {
|
||||
let executable = '';
|
||||
let args: readonly string[] = [];
|
||||
const creds = await readClaudeCredentials({
|
||||
platform: 'darwin',
|
||||
homedir: '/home/test',
|
||||
existsSyncImpl: () => false,
|
||||
readFileSyncImpl: () => {
|
||||
throw new Error('should not read file');
|
||||
},
|
||||
execSyncImpl: () => JSON.stringify(makeCreds({ subscriptionType: 'pro' })),
|
||||
execFileImpl: (file, receivedArgs, _options, callback) => {
|
||||
executable = file;
|
||||
args = receivedArgs;
|
||||
callback(null, JSON.stringify(makeCreds({ subscriptionType: 'pro' })));
|
||||
},
|
||||
});
|
||||
expect(creds?.claudeAiOauth?.subscriptionType).toBe('pro');
|
||||
expect(executable).toBe('/usr/bin/security');
|
||||
expect(args).toEqual(['find-generic-password', '-s', 'Claude Code-credentials', '-w']);
|
||||
});
|
||||
|
||||
it('returns null when both file and Keychain are absent', () => {
|
||||
const creds = readClaudeCredentials({
|
||||
it('returns null when both file and Keychain are absent', async () => {
|
||||
const creds = await readClaudeCredentials({
|
||||
platform: 'darwin',
|
||||
homedir: '/home/test',
|
||||
existsSyncImpl: () => false,
|
||||
readFileSyncImpl: () => {
|
||||
throw new Error('no file');
|
||||
},
|
||||
execSyncImpl: () => {
|
||||
throw new Error('no keychain entry');
|
||||
execFileImpl: (_file, _args, _options, callback) => {
|
||||
callback(new Error('no keychain entry'), '');
|
||||
},
|
||||
});
|
||||
expect(creds).toBeNull();
|
||||
});
|
||||
|
||||
it('does not consult the Keychain on non-darwin platforms', () => {
|
||||
it('does not consult the Keychain on non-darwin platforms', async () => {
|
||||
let keychainCalled = false;
|
||||
const creds = readClaudeCredentials({
|
||||
const creds = await readClaudeCredentials({
|
||||
platform: 'linux',
|
||||
homedir: '/home/test',
|
||||
existsSyncImpl: () => false,
|
||||
readFileSyncImpl: () => {
|
||||
throw new Error('no file');
|
||||
},
|
||||
execSyncImpl: () => {
|
||||
execFileImpl: () => {
|
||||
keychainCalled = true;
|
||||
return '';
|
||||
},
|
||||
@@ -124,3 +134,107 @@ 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-<hash>",
|
||||
// where <hash> 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 <configDir>/.credentials.json when present (file-first, no Keychain)', async () => {
|
||||
let keychainCalled = false;
|
||||
const creds = await readClaudeCredentialsForConfigDir(configDir, {
|
||||
platform: 'darwin',
|
||||
existsSyncImpl: (p: string) => p === credFile,
|
||||
readFileSyncImpl: (p: string) => {
|
||||
expect(p).toBe(credFile);
|
||||
return JSON.stringify(makeCreds());
|
||||
},
|
||||
execFileImpl: () => {
|
||||
keychainCalled = true;
|
||||
return '';
|
||||
},
|
||||
});
|
||||
expect(creds?.claudeAiOauth?.accessToken).toBe('tok-abc');
|
||||
expect(keychainCalled).toBe(false);
|
||||
});
|
||||
|
||||
it('uses absolute security path and argument array for the per-config-dir Keychain item', async () => {
|
||||
let executable = '';
|
||||
let args: readonly string[] = [];
|
||||
const creds = await readClaudeCredentialsForConfigDir(configDir, {
|
||||
platform: 'darwin',
|
||||
existsSyncImpl: () => false,
|
||||
readFileSyncImpl: () => {
|
||||
throw new Error('should not read file');
|
||||
},
|
||||
execFileImpl: (file, receivedArgs, _options, callback) => {
|
||||
executable = file;
|
||||
args = receivedArgs;
|
||||
callback(null, JSON.stringify(makeCreds({ subscriptionType: 'team' })));
|
||||
},
|
||||
});
|
||||
expect(creds?.claudeAiOauth?.subscriptionType).toBe('team');
|
||||
expect(executable).toBe('/usr/bin/security');
|
||||
expect(args).toEqual(['find-generic-password', '-s', 'Claude Code-credentials-ffeb4b45', '-w']);
|
||||
});
|
||||
|
||||
it('returns null when both file and Keychain are absent', async () => {
|
||||
const creds = await readClaudeCredentialsForConfigDir(configDir, {
|
||||
platform: 'darwin',
|
||||
existsSyncImpl: () => false,
|
||||
readFileSyncImpl: () => {
|
||||
throw new Error('no file');
|
||||
},
|
||||
execFileImpl: (_file, _args, _options, callback) => {
|
||||
callback(new Error('no keychain entry'), '');
|
||||
},
|
||||
});
|
||||
expect(creds).toBeNull();
|
||||
});
|
||||
|
||||
it('bounds a blocked Keychain lookup without exposing a credential payload', async () => {
|
||||
const startedAt = Date.now();
|
||||
const creds = await readClaudeCredentialsForConfigDir(configDir, {
|
||||
platform: 'darwin',
|
||||
existsSyncImpl: () => false,
|
||||
keychainTimeoutMs: 20,
|
||||
execFileImpl: () => ({ kill: () => {} }),
|
||||
});
|
||||
expect(creds).toBeNull();
|
||||
expect(Date.now() - startedAt).toBeLessThan(250);
|
||||
});
|
||||
|
||||
it('does not consult the Keychain on non-darwin platforms', async () => {
|
||||
let keychainCalled = false;
|
||||
const creds = await readClaudeCredentialsForConfigDir(configDir, {
|
||||
platform: 'linux',
|
||||
existsSyncImpl: () => false,
|
||||
readFileSyncImpl: () => {
|
||||
throw new Error('no file');
|
||||
},
|
||||
execFileImpl: () => {
|
||||
keychainCalled = true;
|
||||
return '';
|
||||
},
|
||||
});
|
||||
expect(creds).toBeNull();
|
||||
expect(keychainCalled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1032,7 +1032,7 @@ function makeMultiProfileDeps(opts: {
|
||||
listCodexProfiles: () => codexProfiles,
|
||||
defaultClaudeProfile: () => claudeDefault,
|
||||
defaultCodexProfile: () => codexDefault,
|
||||
// Credential seams (file-only, no keychain)
|
||||
// Credential seams (fully injected; no real filesystem or Keychain access)
|
||||
readClaudeCredentialsForProfile: credsForProfile,
|
||||
readCodexNativeAuth: codexNativeAuth,
|
||||
// Fetch seams
|
||||
@@ -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 () => {
|
||||
@@ -1193,7 +1195,7 @@ describe('multi-profile: account_id and wire fields', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('multi-profile: Claude file-only reader', () => {
|
||||
describe('multi-profile: Claude credential reader', () => {
|
||||
it('profile with .credentials.json present -> live fetch row (paused:false when default)', async () => {
|
||||
const clock = { now: 1_000_000 };
|
||||
const deps = makeMultiProfileDeps({
|
||||
@@ -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,234 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Quota reset invalidates cached rows
|
||||
//
|
||||
// A cached row whose next_reset has passed no longer describes the current
|
||||
// window — the quota snapped back at the reset boundary. Serving it for the
|
||||
// rest of the 10-min TTL makes the bar visibly wrong right after a reset, so
|
||||
// a passed reset marks the row stale (guarded: only when the row was fetched
|
||||
// BEFORE the reset, so a post-reset payload that still reports a past reset
|
||||
// cannot cause a refetch loop).
|
||||
// ============================================================================
|
||||
|
||||
describe('multi-profile: quota reset invalidates cached rows', () => {
|
||||
function quotaResettingAt(resetIso: string): ClaudeQuotaResult {
|
||||
const base = successQuota();
|
||||
return {
|
||||
...base,
|
||||
coreUsage: {
|
||||
fiveHour: { ...base.coreUsage!.fiveHour!, resetAt: resetIso },
|
||||
weekly: base.coreUsage!.weekly,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it('re-fetches the default Claude profile once its next_reset passes, before TTL expiry', async () => {
|
||||
const clock = { now: Date.parse('2026-06-09T10:00:00.000Z') };
|
||||
const resetIso = '2026-06-09T10:01:00.000Z'; // 60s ahead
|
||||
const deps = makeMultiProfileDeps({
|
||||
clock,
|
||||
claudeProfiles: ['work'],
|
||||
codexProfiles: [],
|
||||
claudeDefault: 'work',
|
||||
credsForProfile: () => maxCreds(),
|
||||
claudeFetch: async () => quotaResettingAt(resetIso),
|
||||
});
|
||||
|
||||
await getNativeAccountRows(deps);
|
||||
expect(deps.claudeFetchCount()).toBe(1);
|
||||
|
||||
// 90s later: past the reset but far inside the 10-min TTL.
|
||||
clock.now += 90_000;
|
||||
await getNativeAccountRows(deps);
|
||||
expect(deps.claudeFetchCount()).toBe(2);
|
||||
});
|
||||
|
||||
it('does not refetch-loop when a post-reset payload still reports a past reset', async () => {
|
||||
const clock = { now: Date.parse('2026-06-09T10:00:00.000Z') };
|
||||
const resetIso = '2026-06-09T10:01:00.000Z';
|
||||
const deps = makeMultiProfileDeps({
|
||||
clock,
|
||||
claudeProfiles: ['work'],
|
||||
codexProfiles: [],
|
||||
claudeDefault: 'work',
|
||||
credsForProfile: () => maxCreds(),
|
||||
claudeFetch: async () => quotaResettingAt(resetIso),
|
||||
});
|
||||
|
||||
await getNativeAccountRows(deps);
|
||||
clock.now += 90_000;
|
||||
await getNativeAccountRows(deps); // refetch fires; payload STILL says 10:01
|
||||
expect(deps.claudeFetchCount()).toBe(2);
|
||||
|
||||
// Another pass within TTL: the row was fetched after the reset passed, so
|
||||
// the stale-by-reset rule must not apply again.
|
||||
clock.now += 30_000;
|
||||
await getNativeAccountRows(deps);
|
||||
expect(deps.claudeFetchCount()).toBe(2);
|
||||
});
|
||||
|
||||
it('rotating slot treats a non-default profile with a passed reset as stale', async () => {
|
||||
const clock = { now: Date.parse('2026-06-09T10:00:00.000Z') };
|
||||
const resetIso = '2026-06-09T10:01:00.000Z';
|
||||
const farFuture = '2026-06-16T00:00:00.000Z';
|
||||
const deps = makeMultiProfileDeps({
|
||||
clock,
|
||||
claudeProfiles: ['work', 'personal'],
|
||||
codexProfiles: [],
|
||||
claudeDefault: 'work',
|
||||
credsForProfile: () => maxCreds(),
|
||||
claudeFetch: async (_t, accountId) =>
|
||||
quotaResettingAt(accountId === 'ccs:personal' ? resetIso : farFuture),
|
||||
});
|
||||
|
||||
// Pass 1: work (default) + personal (rotating slot, never fetched).
|
||||
await getNativeAccountRows(deps);
|
||||
expect(deps.claudeFetchCount()).toBe(2);
|
||||
|
||||
// 90s later: personal's reset passed; work is fresh. The rotating slot
|
||||
// must pick personal again even though its row is inside the 10-min TTL.
|
||||
clock.now += 90_000;
|
||||
await getNativeAccountRows(deps);
|
||||
expect(deps.claudeFetchCount()).toBe(3);
|
||||
});
|
||||
|
||||
it('Codex rows honour the same reset-staleness rule', async () => {
|
||||
const clock = { now: Date.parse('2026-06-09T10:00:00.000Z') };
|
||||
const codexQuota: CodexQuotaResult = {
|
||||
...codexSuccessQuota(),
|
||||
coreUsage: {
|
||||
fiveHour: {
|
||||
label: 'Primary',
|
||||
remainingPercent: 60,
|
||||
resetAfterSeconds: 60,
|
||||
resetAt: '2026-06-09T10:01:00.000Z',
|
||||
},
|
||||
weekly: codexSuccessQuota().coreUsage!.weekly,
|
||||
},
|
||||
};
|
||||
const deps = makeMultiProfileDeps({
|
||||
clock,
|
||||
claudeProfiles: [],
|
||||
codexProfiles: ['personal'],
|
||||
codexDefault: 'personal',
|
||||
codexNativeAuth: (p) => ({ accessToken: `tok-${p}`, accountId: `id-${p}` }),
|
||||
codexNetworkFetch: async () => codexQuota,
|
||||
});
|
||||
|
||||
await getNativeAccountRows(deps);
|
||||
expect(deps.codexNetworkCount()).toBe(1);
|
||||
|
||||
clock.now += 90_000;
|
||||
await getNativeAccountRows(deps);
|
||||
expect(deps.codexNetworkCount()).toBe(2);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user