mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 14:17:11 +00:00
Merge remote-tracking branch 'origin/dev' into kai/review/pr-1691
# Conflicts: # docs/reports/hardening-inventory.json # docs/reports/hardening-inventory.md
This commit is contained in:
commit
4d5449a493
12 files changed
+881
-141
No files matched your search
@@ -321,4 +321,100 @@ describe('ProxyRequestTransformer regressions', () => {
|
||||
|
||||
expect(result.tool_choice).toBe('auto');
|
||||
});
|
||||
|
||||
it('merges the top-level system field with a mid-array system message into one leading system message', () => {
|
||||
// Claude Code sends the main system prompt via the top-level `system`
|
||||
// field AND a skill/plugin listing as a `role: "system"` message inside
|
||||
// `messages` (see #1459). Prepending the top-level field unconditionally
|
||||
// used to leave two non-adjacent `system` messages in the payload, which
|
||||
// strict OpenAI-compatible backends (LiteLLM among them) reject with:
|
||||
// `400 A 'system' message can only appear at index 0 of the messages array.`
|
||||
const result = new ProxyRequestTransformer().transform({
|
||||
system: [{ type: 'text', text: 'You are Claude Code, a CLI tool.' }],
|
||||
messages: [
|
||||
{ role: 'user', content: 'ping' },
|
||||
{
|
||||
role: 'system',
|
||||
content: 'The following skills are available for use with the Skill tool:\n- foo',
|
||||
},
|
||||
{ role: 'user', content: 'pong' },
|
||||
],
|
||||
});
|
||||
|
||||
const systemMessages = result.messages.filter((message) => message.role === 'system');
|
||||
expect(systemMessages).toHaveLength(1);
|
||||
expect(result.messages[0]).toEqual({
|
||||
role: 'system',
|
||||
content:
|
||||
'You are Claude Code, a CLI tool.\n\nThe following skills are available for use with the Skill tool:\n- foo',
|
||||
});
|
||||
expect(result.messages[1]).toEqual({ role: 'user', content: 'ping\npong' });
|
||||
});
|
||||
|
||||
it('hoists a late system message after complete parallel tool results without disturbing tool order', () => {
|
||||
const result = new ProxyRequestTransformer().transform({
|
||||
system: 'base instructions',
|
||||
messages: [
|
||||
{ role: 'user', content: 'inspect both files' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'tool_use', id: 'toolu_1', name: 'read', input: { path: 'a.ts' } },
|
||||
{ type: 'tool_use', id: 'toolu_2', name: 'read', input: { path: 'b.ts' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'a contents' },
|
||||
{ type: 'tool_result', tool_use_id: 'toolu_2', content: 'b contents' },
|
||||
],
|
||||
},
|
||||
{ role: 'system', content: 'late instructions' },
|
||||
{ role: 'user', content: 'compare them' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.messages).toEqual([
|
||||
{ role: 'system', content: 'base instructions\n\nlate instructions' },
|
||||
{ role: 'user', content: 'inspect both files' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'toolu_1',
|
||||
type: 'function',
|
||||
function: { name: 'read', arguments: '{"path":"a.ts"}' },
|
||||
},
|
||||
{
|
||||
id: 'toolu_2',
|
||||
type: 'function',
|
||||
function: { name: 'read', arguments: '{"path":"b.ts"}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'tool', tool_call_id: 'toolu_1', content: 'a contents' },
|
||||
{ role: 'tool', tool_call_id: 'toolu_2', content: 'b contents' },
|
||||
{ role: 'user', content: 'compare them' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects a system message inserted before pending tool results', () => {
|
||||
expect(() =>
|
||||
new ProxyRequestTransformer().transform({
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool_use', id: 'toolu_1', name: 'read', input: { path: 'a.ts' } }],
|
||||
},
|
||||
{ role: 'system', content: 'interrupting instructions' },
|
||||
{
|
||||
role: 'user',
|
||||
content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'a contents' }],
|
||||
},
|
||||
],
|
||||
})
|
||||
).toThrow('role must be "user" with tool_result blocks after assistant tool_use');
|
||||
});
|
||||
});
|
||||
@@ -43,7 +43,7 @@ describe('ProxyRequestTransformer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts Claude Code system messages in the messages array', () => {
|
||||
it('accepts Claude Code system messages in the messages array and hoists them to a single leading system message', () => {
|
||||
const transformer = new ProxyRequestTransformer();
|
||||
const result = transformer.transform({
|
||||
messages: [
|
||||
@@ -53,10 +53,14 @@ describe('ProxyRequestTransformer', () => {
|
||||
],
|
||||
});
|
||||
|
||||
// Strict OpenAI-compatible backends (e.g. LiteLLM) reject any payload
|
||||
// where `system` is not alone at index 0, so a mid-array `system`
|
||||
// message must be hoisted rather than left in place. See #1459 for why
|
||||
// the message must be accepted at all, and the coalesce-duplicate-system
|
||||
// fix for why it can't simply stay where it landed.
|
||||
expect(result.messages).toEqual([
|
||||
{ role: 'user', content: 'hello' },
|
||||
{ role: 'system', content: 'answer tersely' },
|
||||
{ role: 'user', content: 'which model is this?' },
|
||||
{ role: 'user', content: 'hello\nwhich model is this?' },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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