mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-03 20:13:02 +00:00
fix(bar): bound native credential and quota waits
This commit is contained in:
1 parent
833473c5f6
commit
da2de60015
8 files changed
+201
-89
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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,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, 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. */
|
||||
@@ -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<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)) {
|
||||
@@ -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<CredentialReaderDeps['execSyncImpl']>
|
||||
): 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<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 {
|
||||
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<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)) {
|
||||
@@ -155,10 +187,7 @@ export function readClaudeCredentialsForConfigDir(
|
||||
}
|
||||
|
||||
if (platform === 'darwin') {
|
||||
return readCredentialsFromKeychainService(
|
||||
claudeKeychainServiceForConfigDir(configDir),
|
||||
execImpl
|
||||
);
|
||||
return readCredentialsFromKeychainService(claudeKeychainServiceForConfigDir(configDir), deps);
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -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<ClaudeNativeCredentials | null>;
|
||||
/**
|
||||
* 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>;
|
||||
/**
|
||||
@@ -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<ClaudeNativeCredentials | null> = readClaudeCredentials
|
||||
): Promise<ClaudeNativeCredentials | null> {
|
||||
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: <instanceDir>/.credentials.json first, then
|
||||
@@ -518,7 +523,7 @@ function readClaudeCredentialsForProfileFromDisk(
|
||||
// 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 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<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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -27,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 '';
|
||||
},
|
||||
@@ -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 <configDir>/.credentials.json when present (file-first, no Keychain)', () => {
|
||||
it('reads <configDir>/.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 '';
|
||||
},
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user