mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 03:13:12 +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": [
|
"topOver400": [
|
||||||
{
|
{
|
||||||
"file": "src/web-server/usage/native-quota-collector.ts",
|
"file": "src/web-server/usage/native-quota-collector.ts",
|
||||||
"loc": 1730
|
"loc": 1758
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "src/web-server/routes/cliproxy-auth-routes.ts",
|
"file": "src/web-server/routes/cliproxy-auth-routes.ts",
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}`
|
|||||||
|
|
||||||
| File | LOC |
|
| 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/web-server/routes/cliproxy-auth-routes.ts` | 1531 |
|
||||||
| `src/cliproxy/auth/oauth-handler.ts` | 1510 |
|
| `src/cliproxy/auth/oauth-handler.ts` | 1510 |
|
||||||
| `src/cursor/cursor-executor.ts` | 1234 |
|
| `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
|
* 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
|
* 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> => {
|
router.get('/summary', async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
|
const deadlineAt = Date.now() + REQUEST_DEADLINE_MS;
|
||||||
const wantsRefresh = req.query['refresh'] === 'true';
|
const wantsRefresh = req.query['refresh'] === 'true';
|
||||||
|
|
||||||
// Determine effective refresh mode after applying debounce.
|
// 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
|
// 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
|
// 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 —
|
// glance. (Health is per-account, derived from each quota result below —
|
||||||
// no blocking system audit on the request path.)
|
// 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
|
const costByAccount: Record<string, number> = details
|
||||||
? deps.getTodayCostByAccount(details)
|
? deps.getTodayCostByAccount(details)
|
||||||
: {};
|
: {};
|
||||||
@@ -565,24 +581,20 @@ export function createBarRouter(deps: BarRouterDeps): Router {
|
|||||||
return rows;
|
return rows;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const deadline = new Promise<BarSummaryRow[]>((resolve) => {
|
const rows = (await withTimeout(gather, remainingRequestBudget(deadlineAt))) ?? cacheRows();
|
||||||
setTimeout(() => resolve(cacheRows()), REQUEST_DEADLINE_MS);
|
|
||||||
});
|
|
||||||
|
|
||||||
const rows = await Promise.race([gather, deadline]);
|
// Native subscription rows (Claude Code + Codex) are joined after the
|
||||||
|
|
||||||
// Native subscription rows (Claude Code + Codex) are side-loaded AFTER the
|
|
||||||
// CLIProxy rows resolve, bounded so a slow/failed native fetch degrades
|
// CLIProxy rows resolve, bounded so a slow/failed native fetch degrades
|
||||||
// rather than blocking or erroring the response. Pass force so a
|
// rather than blocking or erroring the response. Pass force so a
|
||||||
// debounce-passing refresh also re-pulls native rows live. On timeout fall
|
// 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
|
// 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
|
// re-pull never momentarily drops the Claude/Codex cards; the in-flight
|
||||||
// fetch keeps warming the cache for the next poll.
|
// fetch keeps warming the cache for the next poll.
|
||||||
const getNative = deps.getNativeAccountRows ?? (async () => [] as BarSummaryRow[]);
|
|
||||||
const getCachedNative = deps.getCachedNativeRows ?? (() => [] as BarSummaryRow[]);
|
|
||||||
const nativeRows =
|
const nativeRows =
|
||||||
(await withTimeout(getNative({ force: doForceRefresh }), NATIVE_SIDELOAD_TIMEOUT_MS)) ??
|
(await withTimeout(
|
||||||
getCachedNative();
|
nativePromise,
|
||||||
|
Math.min(NATIVE_SIDELOAD_TIMEOUT_MS, remainingRequestBudget(deadlineAt))
|
||||||
|
)) ?? getCachedNative();
|
||||||
|
|
||||||
res.json([...rows, ...nativeRows].map(serializeBarRow));
|
res.json([...rows, ...nativeRows].map(serializeBarRow));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { existsSync, readFileSync } from 'node:fs';
|
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 crypto from 'node:crypto';
|
||||||
import * as os from 'node:os';
|
import * as os from 'node:os';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
@@ -37,10 +37,20 @@ export interface CredentialReaderDeps {
|
|||||||
homedir?: string;
|
homedir?: string;
|
||||||
existsSyncImpl?: (p: string) => boolean;
|
existsSyncImpl?: (p: string) => boolean;
|
||||||
readFileSyncImpl?: (p: string) => string;
|
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 KEYCHAIN_SERVICE = 'Claude Code-credentials';
|
||||||
|
const SECURITY_PATH = '/usr/bin/security';
|
||||||
const KEYCHAIN_TIMEOUT_MS = 5000;
|
const KEYCHAIN_TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
/** Subscription types that mean "no real subscription" -> skip the fetch. */
|
/** 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
|
* Keychain as a fallback. Returns null when neither source yields a parseable
|
||||||
* object.
|
* object.
|
||||||
*/
|
*/
|
||||||
export function readClaudeCredentials(
|
export async function readClaudeCredentials(
|
||||||
deps: CredentialReaderDeps = {}
|
deps: CredentialReaderDeps = {}
|
||||||
): ClaudeNativeCredentials | null {
|
): Promise<ClaudeNativeCredentials | null> {
|
||||||
const platform = deps.platform ?? os.platform();
|
const platform = deps.platform ?? os.platform();
|
||||||
const homedir = deps.homedir ?? os.homedir();
|
const homedir = deps.homedir ?? os.homedir();
|
||||||
const existsImpl = deps.existsSyncImpl ?? existsSync;
|
const existsImpl = deps.existsSyncImpl ?? existsSync;
|
||||||
const readImpl = deps.readFileSyncImpl ?? ((p: string) => readFileSync(p, 'utf8'));
|
const readImpl = deps.readFileSyncImpl ?? ((p: string) => readFileSync(p, 'utf8'));
|
||||||
const execImpl = deps.execSyncImpl ?? execSync;
|
|
||||||
|
|
||||||
const credentialsPath = path.join(homedir, '.claude', '.credentials.json');
|
const credentialsPath = path.join(homedir, '.claude', '.credentials.json');
|
||||||
if (existsImpl(credentialsPath)) {
|
if (existsImpl(credentialsPath)) {
|
||||||
@@ -88,7 +97,7 @@ export function readClaudeCredentials(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (platform === 'darwin') {
|
if (platform === 'darwin') {
|
||||||
const parsed = readCredentialsFromKeychainService(KEYCHAIN_SERVICE, execImpl);
|
const parsed = await readCredentialsFromKeychainService(KEYCHAIN_SERVICE, deps);
|
||||||
if (parsed) return parsed;
|
if (parsed) return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,25 +105,49 @@ export function readClaudeCredentials(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Read + parse one Keychain generic-password item. Returns null on any failure. */
|
/** Read + parse one Keychain generic-password item. Returns null on any failure. */
|
||||||
function readCredentialsFromKeychainService(
|
async function readCredentialsFromKeychainService(
|
||||||
service: string,
|
service: string,
|
||||||
execImpl: NonNullable<CredentialReaderDeps['execSyncImpl']>
|
deps: CredentialReaderDeps
|
||||||
): ClaudeNativeCredentials | null {
|
): Promise<ClaudeNativeCredentials | null> {
|
||||||
try {
|
const timeoutMs = deps.keychainTimeoutMs ?? KEYCHAIN_TIMEOUT_MS;
|
||||||
const out = execImpl(`security find-generic-password -s "${service}" -w`, {
|
const execImpl: ExecFileImpl =
|
||||||
timeout: KEYCHAIN_TIMEOUT_MS,
|
deps.execFileImpl ??
|
||||||
encoding: 'utf8',
|
((file, args, options, callback) =>
|
||||||
stdio: ['pipe', 'pipe', 'ignore'],
|
execFile(file, [...args], options, (error, stdout) => callback(error, stdout)));
|
||||||
});
|
|
||||||
const raw = (typeof out === 'string' ? out : out.toString('utf8')).trim();
|
return new Promise((resolve) => {
|
||||||
if (raw) {
|
let settled = false;
|
||||||
const parsed = parseCredentials(raw);
|
let child: { kill?: () => void } | void;
|
||||||
if (parsed) return parsed;
|
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
|
* OAuth tokens in the Keychain by default, so without the Keychain fallback
|
||||||
* every isolated profile looks permanently logged-out to the bar.
|
* every isolated profile looks permanently logged-out to the bar.
|
||||||
*/
|
*/
|
||||||
export function readClaudeCredentialsForConfigDir(
|
export async function readClaudeCredentialsForConfigDir(
|
||||||
configDir: string,
|
configDir: string,
|
||||||
deps: CredentialReaderDeps = {}
|
deps: CredentialReaderDeps = {}
|
||||||
): ClaudeNativeCredentials | null {
|
): Promise<ClaudeNativeCredentials | null> {
|
||||||
const platform = deps.platform ?? os.platform();
|
const platform = deps.platform ?? os.platform();
|
||||||
const existsImpl = deps.existsSyncImpl ?? existsSync;
|
const existsImpl = deps.existsSyncImpl ?? existsSync;
|
||||||
const readImpl = deps.readFileSyncImpl ?? ((p: string) => readFileSync(p, 'utf8'));
|
const readImpl = deps.readFileSyncImpl ?? ((p: string) => readFileSync(p, 'utf8'));
|
||||||
const execImpl = deps.execSyncImpl ?? execSync;
|
|
||||||
|
|
||||||
const credentialsPath = path.join(configDir, '.credentials.json');
|
const credentialsPath = path.join(configDir, '.credentials.json');
|
||||||
if (existsImpl(credentialsPath)) {
|
if (existsImpl(credentialsPath)) {
|
||||||
@@ -155,10 +187,7 @@ export function readClaudeCredentialsForConfigDir(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (platform === 'darwin') {
|
if (platform === 'darwin') {
|
||||||
return readCredentialsFromKeychainService(
|
return readCredentialsFromKeychainService(claudeKeychainServiceForConfigDir(configDir), deps);
|
||||||
claudeKeychainServiceForConfigDir(configDir),
|
|
||||||
execImpl
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -110,13 +110,15 @@ const CODEX_PROVIDER = CODEX_NATIVE_PROVIDER;
|
|||||||
|
|
||||||
export interface NativeQuotaDeps {
|
export interface NativeQuotaDeps {
|
||||||
/** Read the native Claude Code credentials (global default path). */
|
/** 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).
|
* Read credentials for a specific Claude profile (file-first, Keychain fallback).
|
||||||
* Injected so tests never touch real fs or Keychain.
|
* Injected so tests never touch real fs or Keychain.
|
||||||
* profile: the profile name (e.g. "work"); returns null when absent/unparseable.
|
* 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. */
|
/** Fetch Claude quota with a directly-supplied native token. */
|
||||||
fetchClaudeQuota?: (accessToken: string, accountId?: string) => Promise<ClaudeQuotaResult>;
|
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).
|
* 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
|
* Looks for .credentials.json in the profile's instance directory, then uses
|
||||||
* is absent or unparseable, returns null — the caller emits a parked row.
|
* the bounded per-config-dir macOS Keychain fallback. If neither yields a
|
||||||
* Never calls security/Keychain — zero new keychain access from this feature.
|
* parseable credential object, the caller emits a parked row.
|
||||||
*/
|
*/
|
||||||
function readClaudeCredentialsForProfileFromDisk(
|
async function readClaudeCredentialsForProfileFromDisk(
|
||||||
profile: string,
|
profile: string,
|
||||||
readDefaultCredentials: () => ClaudeNativeCredentials | null = readClaudeCredentials
|
readDefaultCredentials: () =>
|
||||||
): ClaudeNativeCredentials | null {
|
| ClaudeNativeCredentials
|
||||||
|
| null
|
||||||
|
| Promise<ClaudeNativeCredentials | null> = readClaudeCredentials
|
||||||
|
): Promise<ClaudeNativeCredentials | null> {
|
||||||
try {
|
try {
|
||||||
const instanceDir = path.join(getCcsDir(), 'instances', profile);
|
const instanceDir = path.join(getCcsDir(), 'instances', profile);
|
||||||
|
|
||||||
@@ -510,7 +515,7 @@ function readClaudeCredentialsForProfileFromDisk(
|
|||||||
// ~/.claude/.credentials.json, falling back to the single global
|
// ~/.claude/.credentials.json, falling back to the single global
|
||||||
// "Claude Code-credentials" Keychain item that Claude Code itself maintains.
|
// "Claude Code-credentials" Keychain item that Claude Code itself maintains.
|
||||||
if (profile === DEFAULT_PROFILE && !fs.existsSync(instanceDir)) {
|
if (profile === DEFAULT_PROFILE && !fs.existsSync(instanceDir)) {
|
||||||
return readDefaultCredentials();
|
return await readDefaultCredentials();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Isolated `ccs auth` instance: <instanceDir>/.credentials.json first, then
|
// 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
|
// CLAUDE_CONFIG_DIR ("Claude Code-credentials-<sha256(dir)[0..8]>"). On
|
||||||
// macOS Claude Code stores tokens in the Keychain by default, so without
|
// macOS Claude Code stores tokens in the Keychain by default, so without
|
||||||
// the Keychain read every isolated profile is permanently parked.
|
// the Keychain read every isolated profile is permanently parked.
|
||||||
return readClaudeCredentialsForConfigDir(instanceDir);
|
return await readClaudeCredentialsForConfigDir(instanceDir);
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -916,12 +921,12 @@ async function collectClaudeRowForProfile(
|
|||||||
// pending DURING assignment, leaving a stale resolved promise that the
|
// pending DURING assignment, leaving a stale resolved promise that the
|
||||||
// next call's coalescing check would return instead of re-evaluating.
|
// next call's coalescing check would return instead of re-evaluating.
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
const creds = readCreds(profile);
|
const creds = await readCreds(profile);
|
||||||
|
|
||||||
// No credentials file found -> emit parked row (needs auth, file absent).
|
// 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
|
// 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
|
// user has not logged in via 'ccs auth' for this machine or neither the
|
||||||
// are stored only in keychain (which we deliberately do not access here).
|
// profile file nor its bounded macOS Keychain fallback yielded credentials.
|
||||||
if (!creds) {
|
if (!creds) {
|
||||||
const parkedRow = buildParkedClaudeProfileRow(profile, now);
|
const parkedRow = buildParkedClaudeProfileRow(profile, now);
|
||||||
// Cache the parked row so repeated calls don't re-stat the fs.
|
// 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> => {
|
state.pending = (async (): Promise<BarSummaryRow | null> => {
|
||||||
try {
|
try {
|
||||||
const creds = readCredentialsFn();
|
const creds = await readCredentialsFn();
|
||||||
// No token / unsupported subscription -> never spend a call, omit the row.
|
// No token / unsupported subscription -> never spend a call, omit the row.
|
||||||
if (!creds || !hasSupportedSubscription(creds)) {
|
if (!creds || !hasSupportedSubscription(creds)) {
|
||||||
return serveCached(state);
|
return serveCached(state);
|
||||||
|
|||||||
@@ -1116,6 +1116,49 @@ describe('/summary force flag passed to getNativeAccountRows', () => {
|
|||||||
expect(status).toBe(200);
|
expect(status).toBe(200);
|
||||||
expect(body.some((r) => r.provider === 'codex')).toBe(true);
|
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', () => {
|
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;
|
let keychainCalled = false;
|
||||||
const creds = readClaudeCredentials({
|
const creds = await readClaudeCredentials({
|
||||||
platform: 'darwin',
|
platform: 'darwin',
|
||||||
homedir: '/home/test',
|
homedir: '/home/test',
|
||||||
existsSyncImpl: () => true,
|
existsSyncImpl: () => true,
|
||||||
readFileSyncImpl: () => JSON.stringify(makeCreds()),
|
readFileSyncImpl: () => JSON.stringify(makeCreds()),
|
||||||
execSyncImpl: () => {
|
execFileImpl: () => {
|
||||||
keychainCalled = true;
|
keychainCalled = true;
|
||||||
return '';
|
return '';
|
||||||
},
|
},
|
||||||
@@ -44,44 +44,52 @@ describe('readClaudeCredentials', () => {
|
|||||||
expect(keychainCalled).toBe(false);
|
expect(keychainCalled).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to the macOS Keychain when the file is absent', () => {
|
it('falls back to the macOS Keychain when the file is absent', async () => {
|
||||||
const creds = readClaudeCredentials({
|
let executable = '';
|
||||||
|
let args: readonly string[] = [];
|
||||||
|
const creds = await readClaudeCredentials({
|
||||||
platform: 'darwin',
|
platform: 'darwin',
|
||||||
homedir: '/home/test',
|
homedir: '/home/test',
|
||||||
existsSyncImpl: () => false,
|
existsSyncImpl: () => false,
|
||||||
readFileSyncImpl: () => {
|
readFileSyncImpl: () => {
|
||||||
throw new Error('should not read file');
|
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(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', () => {
|
it('returns null when both file and Keychain are absent', async () => {
|
||||||
const creds = readClaudeCredentials({
|
const creds = await readClaudeCredentials({
|
||||||
platform: 'darwin',
|
platform: 'darwin',
|
||||||
homedir: '/home/test',
|
homedir: '/home/test',
|
||||||
existsSyncImpl: () => false,
|
existsSyncImpl: () => false,
|
||||||
readFileSyncImpl: () => {
|
readFileSyncImpl: () => {
|
||||||
throw new Error('no file');
|
throw new Error('no file');
|
||||||
},
|
},
|
||||||
execSyncImpl: () => {
|
execFileImpl: (_file, _args, _options, callback) => {
|
||||||
throw new Error('no keychain entry');
|
callback(new Error('no keychain entry'), '');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(creds).toBeNull();
|
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;
|
let keychainCalled = false;
|
||||||
const creds = readClaudeCredentials({
|
const creds = await readClaudeCredentials({
|
||||||
platform: 'linux',
|
platform: 'linux',
|
||||||
homedir: '/home/test',
|
homedir: '/home/test',
|
||||||
existsSyncImpl: () => false,
|
existsSyncImpl: () => false,
|
||||||
readFileSyncImpl: () => {
|
readFileSyncImpl: () => {
|
||||||
throw new Error('no file');
|
throw new Error('no file');
|
||||||
},
|
},
|
||||||
execSyncImpl: () => {
|
execFileImpl: () => {
|
||||||
keychainCalled = true;
|
keychainCalled = true;
|
||||||
return '';
|
return '';
|
||||||
},
|
},
|
||||||
@@ -149,16 +157,16 @@ describe('readClaudeCredentialsForConfigDir', () => {
|
|||||||
const configDir = '/home/test/.ccs/instances/work';
|
const configDir = '/home/test/.ccs/instances/work';
|
||||||
const credFile = `${configDir}/.credentials.json`;
|
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;
|
let keychainCalled = false;
|
||||||
const creds = readClaudeCredentialsForConfigDir(configDir, {
|
const creds = await readClaudeCredentialsForConfigDir(configDir, {
|
||||||
platform: 'darwin',
|
platform: 'darwin',
|
||||||
existsSyncImpl: (p: string) => p === credFile,
|
existsSyncImpl: (p: string) => p === credFile,
|
||||||
readFileSyncImpl: (p: string) => {
|
readFileSyncImpl: (p: string) => {
|
||||||
expect(p).toBe(credFile);
|
expect(p).toBe(credFile);
|
||||||
return JSON.stringify(makeCreds());
|
return JSON.stringify(makeCreds());
|
||||||
},
|
},
|
||||||
execSyncImpl: () => {
|
execFileImpl: () => {
|
||||||
keychainCalled = true;
|
keychainCalled = true;
|
||||||
return '';
|
return '';
|
||||||
},
|
},
|
||||||
@@ -167,46 +175,61 @@ describe('readClaudeCredentialsForConfigDir', () => {
|
|||||||
expect(keychainCalled).toBe(false);
|
expect(keychainCalled).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to the per-config-dir Keychain service when the file is absent', () => {
|
it('uses absolute security path and argument array for the per-config-dir Keychain item', async () => {
|
||||||
let keychainCmd = '';
|
let executable = '';
|
||||||
const creds = readClaudeCredentialsForConfigDir(configDir, {
|
let args: readonly string[] = [];
|
||||||
|
const creds = await readClaudeCredentialsForConfigDir(configDir, {
|
||||||
platform: 'darwin',
|
platform: 'darwin',
|
||||||
existsSyncImpl: () => false,
|
existsSyncImpl: () => false,
|
||||||
readFileSyncImpl: () => {
|
readFileSyncImpl: () => {
|
||||||
throw new Error('should not read file');
|
throw new Error('should not read file');
|
||||||
},
|
},
|
||||||
execSyncImpl: (cmd: string) => {
|
execFileImpl: (file, receivedArgs, _options, callback) => {
|
||||||
keychainCmd = cmd;
|
executable = file;
|
||||||
return JSON.stringify(makeCreds({ subscriptionType: 'team' }));
|
args = receivedArgs;
|
||||||
|
callback(null, JSON.stringify(makeCreds({ subscriptionType: 'team' })));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(creds?.claudeAiOauth?.subscriptionType).toBe('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', () => {
|
it('returns null when both file and Keychain are absent', async () => {
|
||||||
const creds = readClaudeCredentialsForConfigDir(configDir, {
|
const creds = await readClaudeCredentialsForConfigDir(configDir, {
|
||||||
platform: 'darwin',
|
platform: 'darwin',
|
||||||
existsSyncImpl: () => false,
|
existsSyncImpl: () => false,
|
||||||
readFileSyncImpl: () => {
|
readFileSyncImpl: () => {
|
||||||
throw new Error('no file');
|
throw new Error('no file');
|
||||||
},
|
},
|
||||||
execSyncImpl: () => {
|
execFileImpl: (_file, _args, _options, callback) => {
|
||||||
throw new Error('no keychain entry');
|
callback(new Error('no keychain entry'), '');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(creds).toBeNull();
|
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;
|
let keychainCalled = false;
|
||||||
const creds = readClaudeCredentialsForConfigDir(configDir, {
|
const creds = await readClaudeCredentialsForConfigDir(configDir, {
|
||||||
platform: 'linux',
|
platform: 'linux',
|
||||||
existsSyncImpl: () => false,
|
existsSyncImpl: () => false,
|
||||||
readFileSyncImpl: () => {
|
readFileSyncImpl: () => {
|
||||||
throw new Error('no file');
|
throw new Error('no file');
|
||||||
},
|
},
|
||||||
execSyncImpl: () => {
|
execFileImpl: () => {
|
||||||
keychainCalled = true;
|
keychainCalled = true;
|
||||||
return '';
|
return '';
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1032,7 +1032,7 @@ function makeMultiProfileDeps(opts: {
|
|||||||
listCodexProfiles: () => codexProfiles,
|
listCodexProfiles: () => codexProfiles,
|
||||||
defaultClaudeProfile: () => claudeDefault,
|
defaultClaudeProfile: () => claudeDefault,
|
||||||
defaultCodexProfile: () => codexDefault,
|
defaultCodexProfile: () => codexDefault,
|
||||||
// Credential seams (file-only, no keychain)
|
// Credential seams (fully injected; no real filesystem or Keychain access)
|
||||||
readClaudeCredentialsForProfile: credsForProfile,
|
readClaudeCredentialsForProfile: credsForProfile,
|
||||||
readCodexNativeAuth: codexNativeAuth,
|
readCodexNativeAuth: codexNativeAuth,
|
||||||
// Fetch seams
|
// 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 () => {
|
it('profile with .credentials.json present -> live fetch row (paused:false when default)', async () => {
|
||||||
const clock = { now: 1_000_000 };
|
const clock = { now: 1_000_000 };
|
||||||
const deps = makeMultiProfileDeps({
|
const deps = makeMultiProfileDeps({
|
||||||
|
|||||||
Reference in new issue
Block a user