From da2de600159cffb9db27d6e7bbeef03daae2b3f0 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 8 Aug 2026 21:12:28 -0400 Subject: [PATCH] fix(bar): bound native credential and quota waits --- docs/reports/hardening-inventory.json | 2 +- docs/reports/hardening-inventory.md | 2 +- src/web-server/routes/bar-routes.ts | 34 ++++--- .../usage/claude-native-credentials.ts | 89 ++++++++++++------- .../usage/native-quota-collector.ts | 33 ++++--- tests/unit/web-server/bar-routes.test.ts | 43 +++++++++ .../claude-native-credentials.test.ts | 83 ++++++++++------- .../web-server/native-quota-collector.test.ts | 4 +- 8 files changed, 201 insertions(+), 89 deletions(-) diff --git a/docs/reports/hardening-inventory.json b/docs/reports/hardening-inventory.json index 493070d0..034cc04b 100644 --- a/docs/reports/hardening-inventory.json +++ b/docs/reports/hardening-inventory.json @@ -725,7 +725,7 @@ "topOver400": [ { "file": "src/web-server/usage/native-quota-collector.ts", - "loc": 1730 + "loc": 1758 }, { "file": "src/web-server/routes/cliproxy-auth-routes.ts", diff --git a/docs/reports/hardening-inventory.md b/docs/reports/hardening-inventory.md index e8435342..4b5c09d0 100644 --- a/docs/reports/hardening-inventory.md +++ b/docs/reports/hardening-inventory.md @@ -89,7 +89,7 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` | File | LOC | |---|---:| -| `src/web-server/usage/native-quota-collector.ts` | 1730 | +| `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 | diff --git a/src/web-server/routes/bar-routes.ts b/src/web-server/routes/bar-routes.ts index f68328fc..2cc99fb0 100644 --- a/src/web-server/routes/bar-routes.ts +++ b/src/web-server/routes/bar-routes.ts @@ -243,6 +243,11 @@ function withTimeout(p: Promise, ms: number): Promise { }); } +/** 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 => { 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 = details ? deps.getTodayCostByAccount(details) : {}; @@ -565,24 +581,20 @@ export function createBarRouter(deps: BarRouterDeps): Router { return rows; })(); - const deadline = new Promise((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) { diff --git a/src/web-server/usage/claude-native-credentials.ts b/src/web-server/usage/claude-native-credentials.ts index 9b19a819..c2e84a1a 100644 --- a/src/web-server/usage/claude-native-credentials.ts +++ b/src/web-server/usage/claude-native-credentials.ts @@ -15,7 +15,7 @@ */ 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'; @@ -37,10 +37,20 @@ export interface CredentialReaderDeps { homedir?: string; existsSyncImpl?: (p: string) => boolean; readFileSyncImpl?: (p: string) => string; - execSyncImpl?: (cmd: string, opts: Record) => string | Buffer; + execFileImpl?: ExecFileImpl; + keychainTimeoutMs?: number; } +type ExecFileCallback = (error: Error | null, stdout: string | Buffer) => void; +type ExecFileImpl = ( + file: string, + args: readonly string[], + options: Record, + 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. */ @@ -68,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 { 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)) { @@ -88,7 +97,7 @@ export function readClaudeCredentials( } if (platform === 'darwin') { - const parsed = readCredentialsFromKeychainService(KEYCHAIN_SERVICE, execImpl); + const parsed = await readCredentialsFromKeychainService(KEYCHAIN_SERVICE, deps); if (parsed) return parsed; } @@ -96,25 +105,49 @@ export function readClaudeCredentials( } /** Read + parse one Keychain generic-password item. Returns null on any failure. */ -function readCredentialsFromKeychainService( +async 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; + deps: CredentialReaderDeps +): Promise { + 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 { + 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 { + finish(null); } - } catch { - // no Keychain entry / access denied -> null - } - return null; + }); } /** @@ -135,14 +168,13 @@ export function claudeKeychainServiceForConfigDir(configDir: string): string { * 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( +export async function readClaudeCredentialsForConfigDir( configDir: string, deps: CredentialReaderDeps = {} -): ClaudeNativeCredentials | null { +): Promise { 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)) { @@ -155,10 +187,7 @@ export function readClaudeCredentialsForConfigDir( } if (platform === 'darwin') { - return readCredentialsFromKeychainService( - claudeKeychainServiceForConfigDir(configDir), - execImpl - ); + return readCredentialsFromKeychainService(claudeKeychainServiceForConfigDir(configDir), deps); } return null; diff --git a/src/web-server/usage/native-quota-collector.ts b/src/web-server/usage/native-quota-collector.ts index 308cc939..a5b87fb2 100644 --- a/src/web-server/usage/native-quota-collector.ts +++ b/src/web-server/usage/native-quota-collector.ts @@ -110,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; /** * 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; /** Fetch Claude quota with a directly-supplied native token. */ fetchClaudeQuota?: (accessToken: string, accountId?: string) => Promise; /** @@ -495,14 +497,17 @@ function serveCached(state: ProviderState): BarSummaryRow | null { /** * 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 = readClaudeCredentials +): Promise { try { const instanceDir = path.join(getCcsDir(), 'instances', profile); @@ -510,7 +515,7 @@ function readClaudeCredentialsForProfileFromDisk( // ~/.claude/.credentials.json, falling back to the single global // "Claude Code-credentials" Keychain item that Claude Code itself maintains. if (profile === DEFAULT_PROFILE && !fs.existsSync(instanceDir)) { - return readDefaultCredentials(); + return await readDefaultCredentials(); } // Isolated `ccs auth` instance: /.credentials.json first, then @@ -518,7 +523,7 @@ function readClaudeCredentialsForProfileFromDisk( // 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); + return await readClaudeCredentialsForConfigDir(instanceDir); } catch { return null; } @@ -916,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. @@ -1238,7 +1243,7 @@ async function collectClaudeRow( state.pending = (async (): Promise => { 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); diff --git a/tests/unit/web-server/bar-routes.test.ts b/tests/unit/web-server/bar-routes.test.ts index 4890820f..10fbc1d6 100644 --- a/tests/unit/web-server/bar-routes.test.ts +++ b/tests/unit/web-server/bar-routes.test.ts @@ -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((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( + `http://127.0.0.1:${(addr as { port: number }).port}`, + '/api/bar/summary?refresh=true' + ); + const elapsed = Date.now() - startedAt; + await new Promise((resolve) => srv.close(() => resolve())); + + expect(status).toBe(200); + expect(elapsed).toBeGreaterThanOrEqual(2_200); + expect(elapsed).toBeLessThan(3_200); + }); }); // ============================================================================ diff --git a/tests/unit/web-server/claude-native-credentials.test.ts b/tests/unit/web-server/claude-native-credentials.test.ts index 41462c2b..1de415b6 100644 --- a/tests/unit/web-server/claude-native-credentials.test.ts +++ b/tests/unit/web-server/claude-native-credentials.test.ts @@ -27,14 +27,14 @@ function makeCreds(overrides: Record = {}): 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 ''; }, @@ -44,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 ''; }, @@ -149,16 +157,16 @@ describe('readClaudeCredentialsForConfigDir', () => { const configDir = '/home/test/.ccs/instances/work'; const credFile = `${configDir}/.credentials.json`; - it('reads /.credentials.json when present (file-first, no Keychain)', () => { + it('reads /.credentials.json when present (file-first, no Keychain)', async () => { let keychainCalled = false; - const creds = readClaudeCredentialsForConfigDir(configDir, { + const creds = await readClaudeCredentialsForConfigDir(configDir, { platform: 'darwin', existsSyncImpl: (p: string) => p === credFile, readFileSyncImpl: (p: string) => { expect(p).toBe(credFile); return JSON.stringify(makeCreds()); }, - execSyncImpl: () => { + execFileImpl: () => { keychainCalled = true; return ''; }, @@ -167,46 +175,61 @@ describe('readClaudeCredentialsForConfigDir', () => { 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, { + 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'); }, - execSyncImpl: (cmd: string) => { - keychainCmd = cmd; - return JSON.stringify(makeCreds({ subscriptionType: 'team' })); + execFileImpl: (file, receivedArgs, _options, callback) => { + executable = file; + args = receivedArgs; + callback(null, JSON.stringify(makeCreds({ subscriptionType: 'team' }))); }, }); expect(creds?.claudeAiOauth?.subscriptionType).toBe('team'); - expect(keychainCmd).toContain('Claude Code-credentials-ffeb4b45'); + 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', () => { - const creds = readClaudeCredentialsForConfigDir(configDir, { + 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'); }, - 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('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 = readClaudeCredentialsForConfigDir(configDir, { + const creds = await readClaudeCredentialsForConfigDir(configDir, { platform: 'linux', existsSyncImpl: () => false, readFileSyncImpl: () => { throw new Error('no file'); }, - execSyncImpl: () => { + execFileImpl: () => { keychainCalled = true; return ''; }, diff --git a/tests/unit/web-server/native-quota-collector.test.ts b/tests/unit/web-server/native-quota-collector.test.ts index 193c52bb..add60779 100644 --- a/tests/unit/web-server/native-quota-collector.test.ts +++ b/tests/unit/web-server/native-quota-collector.test.ts @@ -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 @@ -1195,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({