fix(cliproxy): harden account pools per pre-merge usefulness review

- pool --enable/--disable: refuse remote targets with manual config
  guidance; resolve the lifecycle port instead of assuming 8317
- enablePoolRouting: roll back the pool flag when config regeneration
  fails; already-enabled path re-runs regeneration (repairable state)
- pool opt-in prompt: gate on hasUnifiedConfig (legacy installs skip);
  never auto-accept consent under --yes/CCS_YES
- order show (file mode): render selector pick order incl. residual
  on-disk priorities and surface drift instead of alphabetical order
- order --reset: clear residual priority fields via management-API
  PATCH when proxy runs, atomic direct write when stopped
- quota pool section: classify in-proxy 429 cooldowns as cooling with
  reset times (graceful degradation when proxy or endpoint is absent);
  honest paused label plus resume hint
- routing state: remote targets report pool as not manageable instead
  of echoing the local flag; strategy/affinity apply warns when pool
  routing overrides the change (CLI, API message, and dashboard)
- claude model-neutral: --config explicit pins survive the stale-pin
  migration; remote env path filters historical default pins read from
  claude.settings.json without mutating the file
- cross-lane guard: also checks account-profile CLAUDE_CONFIG_DIR lanes
  for email overlap, not only the ambient ~/.claude login
- onboarding hint: opt-in copy naming ccs cliproxy pool --enable
- dashboard card: pool-ON shows drain-order pointer, pool-OFF shows the
  enable command, local-only note for remote proxies (i18n x5)
This commit is contained in:
Tam Nhu Tran
2026-06-11 18:50:12 -04:00
parent 2b7cfa32c8
commit 1c90388b5d
25 changed files with 2489 additions and 125 deletions
@@ -383,8 +383,8 @@ describe('Phase 5: Pool Onboarding Hint', () => {
}
});
// ── 13. Hint copy includes 'ccs claude' and quota trade-off language ──────
it("hint copy mentions 'ccs claude' and quota trade-off", async () => {
// ── 13. Hint copy is opt-in framed and names the enable command ──────────
it("hint copy mentions 'ccs claude' and the opt-in enable command", async () => {
const ccsDir = path.join(tempHome, '.ccs');
writeProfiles(ccsDir, ['work', 'personal']);
@@ -397,7 +397,12 @@ describe('Phase 5: Pool Onboarding Hint', () => {
expect(result.printed).toBe(true);
const allOutput = consoleSpy.mock.calls.map((c) => String(c[0])).join('\n');
expect(allOutput).toContain('ccs claude');
expect(allOutput).toContain('share quota');
// Copy must be opt-in (not declarative "already pools") and name the
// actual enable command so the user has an actionable next step.
expect(allOutput).toContain('ccs cliproxy pool --enable');
expect(allOutput.toLowerCase()).toContain('can auto-continue');
// Must NOT read as already-active behavior.
expect(allOutput).not.toContain('pool auto-continues');
} finally {
consoleSpy.mockRestore();
}
@@ -793,6 +793,118 @@ describe('Phase 3: cross-lane email overlap guard', () => {
consoleErrorSpy.mockRestore();
});
// ── Account-profile lane enumeration (isolated CLAUDE_CONFIG_DIR lanes) ──
// The ambient ~/.claude check alone misses the isolated lanes of CCS account
// profiles — exactly the multi-account population the guard protects. These
// tests prove the guard also reads each profile lane's .claude.json email.
/** Write a profiles.json account entry + its instance-lane .claude.json email. */
function seedAccountLane(ccsDir: string, name: string, email: string | null): void {
const profilesPath = path.join(ccsDir, 'profiles.json');
let payload: { version: string; profiles: Record<string, unknown>; default: string | null };
try {
payload = JSON.parse(fs.readFileSync(profilesPath, 'utf-8'));
} catch {
payload = { version: '2.0.0', profiles: {}, default: null };
}
payload.profiles[name] = {
type: 'account',
created: new Date().toISOString(),
last_used: null,
};
fs.writeFileSync(profilesPath, JSON.stringify(payload, null, 2), 'utf8');
const instanceDir = path.join(ccsDir, 'instances', name);
fs.mkdirSync(instanceDir, { recursive: true });
if (email !== null) {
fs.writeFileSync(
path.join(instanceDir, '.claude.json'),
JSON.stringify({ oauthAccount: { emailAddress: email } }, null, 2),
'utf8'
);
}
}
it('warns when a CCS account-profile lane email matches, even though ambient ~/.claude is logged out', () => {
const ccsDir = path.join(tempHome, '.ccs');
seedAccountLane(ccsDir, 'work', 'lane@example.com');
// Ambient default lane is logged OUT — old guard would stay silent.
const spy = spyOn(claudeDetector, 'getClaudeAuthStatus').mockReturnValue({
loggedIn: false,
email: null,
authMethod: null,
apiProvider: null,
orgId: null,
orgName: null,
subscriptionType: null,
});
const consoleErrorSpy = spyOn(console, 'error').mockImplementation((msg?: unknown) => {
if (typeof msg === 'string') stderrOutput.push(msg);
});
checkCrossLaneEmailOverlap('agy', 'lane@example.com');
const combined = stderrOutput.join('\n');
expect(combined).toContain('cross-lane email overlap');
// The matching profile lane is named so the user knows which lane overlaps.
expect(combined).toContain('profile "work"');
spy.mockRestore();
consoleErrorSpy.mockRestore();
});
it('does not warn when no account-profile lane email matches', () => {
const ccsDir = path.join(tempHome, '.ccs');
seedAccountLane(ccsDir, 'work', 'someone-else@example.com');
const spy = spyOn(claudeDetector, 'getClaudeAuthStatus').mockReturnValue({
loggedIn: false,
email: null,
authMethod: null,
apiProvider: null,
orgId: null,
orgName: null,
subscriptionType: null,
});
const consoleErrorSpy = spyOn(console, 'error').mockImplementation((msg?: unknown) => {
if (typeof msg === 'string') stderrOutput.push(msg);
});
checkCrossLaneEmailOverlap('agy', 'mine@example.com');
expect(stderrOutput.join('\n')).not.toContain('cross-lane');
spy.mockRestore();
consoleErrorSpy.mockRestore();
});
it('silently skips an account-profile lane with a missing/unreadable .claude.json', () => {
const ccsDir = path.join(tempHome, '.ccs');
// Account profile exists but its lane has no .claude.json (email === null).
seedAccountLane(ccsDir, 'broken', null);
const spy = spyOn(claudeDetector, 'getClaudeAuthStatus').mockReturnValue({
loggedIn: false,
email: null,
authMethod: null,
apiProvider: null,
orgId: null,
orgName: null,
subscriptionType: null,
});
const consoleErrorSpy = spyOn(console, 'error').mockImplementation((msg?: unknown) => {
if (typeof msg === 'string') stderrOutput.push(msg);
});
// Must not warn and must not throw on the unreadable lane.
expect(() => checkCrossLaneEmailOverlap('agy', 'lane@example.com')).not.toThrow();
expect(stderrOutput.join('\n')).not.toContain('cross-lane');
spy.mockRestore();
consoleErrorSpy.mockRestore();
});
// ── 15. Safety invariant: disablePoolRouting always restores cooling=true ─
it('safety invariant: disablePoolRouting ALWAYS writes disable-cooling: true', async () => {
const { enablePoolRouting, disablePoolRouting } = await import(
@@ -1280,3 +1392,194 @@ describe('Phase 3: routing-subcommand pool-active warning regression', () => {
affinitySpy.mockRestore();
});
});
// ── PR #1514 review fixes ────────────────────────────────────────────────────
// Legacy-config gate, automation bypass, enable rollback-on-regenerate-failure,
// remote pool-state manageable flag, and apply-message pool override note.
describe('PR #1514: maybeOfferPoolRouting legacy-config and automation guards', () => {
let tempHome: string;
let originalCcsHome: string | undefined;
beforeEach(() => {
tempHome = createTestHome();
originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tempHome;
invalidateSharedConfigCache();
});
afterEach(() => {
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
fs.rmSync(tempHome, { recursive: true, force: true });
delete process.env.CCS_YES;
});
// Fix index 5: legacy profiles.json-only install (no config.yaml) must skip the
// prompt before any prompting OR dismissal persistence, so we never implicitly
// create config.yaml and silently flip isUnifiedMode().
it('skips with legacy-config and does not create config.yaml (decline path not reached)', async () => {
// Remove the config.yaml createTestHome wrote so hasUnifiedConfig() is false.
const configYaml = path.join(tempHome, '.ccs', 'config.yaml');
fs.rmSync(configYaml, { force: true });
invalidateSharedConfigCache();
const result = await poolOptInModule.maybeOfferPoolRouting('claude', 1);
expect(result.skipped).toBe(true);
expect(result.skipReason).toBe('legacy-config');
// Critical: the dismissal/accept paths (which write config.yaml) must NOT run.
expect(fs.existsSync(configYaml)).toBe(false);
});
// Fix index 9: --yes / CCS_YES must NOT auto-accept this instance-global consent.
// It must skip WITHOUT printing the prompt and WITHOUT persisting dismissal.
it('skips with automation-bypass under CCS_YES=1 without prompting or dismissing', async () => {
process.env.CCS_YES = '1';
// 2 accounts so we are past the post-add count check and reach the TTY/bypass guards.
const ccsDir = path.join(tempHome, '.ccs');
const accountsPath = path.join(ccsDir, 'cliproxy', 'accounts.json');
const authDir = path.join(ccsDir, 'cliproxy', 'auth');
fs.mkdirSync(path.dirname(accountsPath), { recursive: true });
fs.mkdirSync(authDir, { recursive: true });
fs.writeFileSync(
accountsPath,
JSON.stringify({
providers: {
claude: {
default: 'a1',
accounts: {
a1: { email: 'a@b.com', tokenFile: 'a.json', nickname: 'a', createdAt: 0 },
a2: { email: 'c@d.com', tokenFile: 'c.json', nickname: 'c', createdAt: 0 },
},
},
},
}),
'utf-8'
);
fs.writeFileSync(path.join(authDir, 'a.json'), '{}', 'utf-8');
fs.writeFileSync(path.join(authDir, 'c.json'), '{}', 'utf-8');
// Force TTY so only the automation guard (not the non-tty guard) can fire.
const origStdinTTY = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY');
const origStderrTTY = Object.getOwnPropertyDescriptor(process.stderr, 'isTTY');
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
Object.defineProperty(process.stderr, 'isTTY', { value: true, configurable: true });
// confirm must NEVER be called on the bypass path.
const confirmSpy = spyOn(promptModule.InteractivePrompt, 'confirm').mockResolvedValue(true);
const result = await poolOptInModule.maybeOfferPoolRouting('claude', 1, 8317);
expect(result.skipped).toBe(true);
expect(result.skipReason).toBe('automation-bypass');
expect(result.enabled).toBe(false);
expect(confirmSpy).not.toHaveBeenCalled();
// Dismissal must NOT be persisted (future interactive run should still offer).
expect(poolOptInModule.isPoolPromptDismissed()).toBe(false);
confirmSpy.mockRestore();
if (origStdinTTY) {
Object.defineProperty(process.stdin, 'isTTY', origStdinTTY);
} else {
delete (process.stdin as { isTTY?: boolean }).isTTY;
}
if (origStderrTTY) {
Object.defineProperty(process.stderr, 'isTTY', origStderrTTY);
} else {
delete (process.stderr as { isTTY?: boolean }).isTTY;
}
});
});
describe('PR #1514: enablePoolRouting rollback on regenerate failure', () => {
let tempHome: string;
let originalCcsHome: string | undefined;
beforeEach(() => {
tempHome = createTestHome();
originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tempHome;
invalidateSharedConfigCache();
});
afterEach(() => {
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
fs.rmSync(tempHome, { recursive: true, force: true });
});
// Fix index 6: if regenerateConfig throws, the pool_routing.enabled flag must be
// rolled back so status surfaces do not lie, and a recovery message is returned.
// Force a real fs failure (no mock): place a regular FILE where the config
// directory must be, so regenerateConfig's mkdirSync(dirname) throws ENOTDIR.
it('rolls back enabled flag and returns failure recovery copy when regenerate throws', async () => {
const ts = Date.now();
const { enablePoolRouting } = await import(`../routing/routing-strategy?p1514fail=${ts}`);
const { loadOrCreateUnifiedConfig } = await import(
`../../config/config-loader-facade?p1514fail=${ts}`
);
const ccsDir = path.join(tempHome, '.ccs');
const cliproxyDir = path.join(ccsDir, 'cliproxy');
fs.mkdirSync(cliproxyDir, { recursive: true });
// Block the config dir: create a FILE named "blocked" then aim the config path
// at blocked/config.yaml so mkdirSync(dirname) hits ENOTDIR.
const blockedFile = path.join(cliproxyDir, 'blocked');
fs.writeFileSync(blockedFile, 'not a dir', 'utf-8');
const configPath = path.join(blockedFile, 'config.yaml');
const authDir = path.join(cliproxyDir, 'auth');
const result = enablePoolRouting(8317, { configPath, authDir });
expect(result.failed).toBe(true);
expect(result.changed).toBe(false);
expect(result.message).toContain('[X] Could not write CLIProxy config');
expect(result.message).toContain('ccs cliproxy pool --enable');
// The flag must be rolled back to NOT-enabled so status surfaces do not lie.
const cfg = loadOrCreateUnifiedConfig();
expect(cfg.cliproxy?.pool_routing?.enabled).not.toBe(true);
});
// Repair path: when the flag is already true (a prior regenerate failed),
// pool --enable must re-run regenerateConfig instead of a dead no-op.
it('already-enabled path re-runs regenerateConfig (idempotent repair)', async () => {
const ts = Date.now();
// Persist enabled=true WITHOUT a regenerated config.yaml (simulate prior failure).
const { mutateConfig, invalidateConfigCache } = await import(
`../../config/config-loader-facade?p1514repair=${ts}`
);
mutateConfig((cfg: { cliproxy?: { pool_routing?: Record<string, unknown> } }) => {
cfg.cliproxy = cfg.cliproxy ?? {};
cfg.cliproxy.pool_routing = { enabled: true, max_retry_credentials: 3 };
});
invalidateConfigCache();
invalidateSharedConfigCache();
const { enablePoolRouting } = await import(`../routing/routing-strategy?p1514repair=${ts}`);
const ccsDir = path.join(tempHome, '.ccs');
const configPath = path.join(ccsDir, 'cliproxy', 'config.yaml');
const authDir = path.join(ccsDir, 'cliproxy', 'auth');
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.mkdirSync(authDir, { recursive: true });
// Config does NOT exist yet — the repair must create it.
expect(fs.existsSync(configPath)).toBe(false);
const result = enablePoolRouting(8317, { configPath, authDir });
// Idempotent (changed=false) but the config was regenerated with pool rails.
expect(result.changed).toBe(false);
expect(result.failed).toBeFalsy();
expect(fs.existsSync(configPath)).toBe(true);
const content = fs.readFileSync(configPath, 'utf-8');
expect(content).toContain('disable-cooling: false');
expect(content).toContain('strategy: fill-first');
});
});
@@ -721,3 +721,215 @@ describe('resolveEffectiveDrainOrder', () => {
});
});
});
// ---------------------------------------------------------------------------
// clearAuthFilePriorityDirect + clearDrainOrderPriorities
// (the missing half of `order --reset`: actually strip residual priorities)
// ---------------------------------------------------------------------------
describe('clearAuthFilePriorityDirect', () => {
it('removes the priority field and preserves all other fields', async () => {
await withIsolatedHome(async (homeDir) => {
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
writeAuthFile(authDir, 'a.json', { email: 'a@x.com', priority: 4, keep: 'me' });
const { clearAuthFilePriorityDirect } = await loadDrainOrder();
const removed = clearAuthFilePriorityDirect('a.json');
expect(removed).toBe(true);
const data = readAuthFile(authDir, 'a.json');
expect('priority' in data).toBe(false);
expect(data.keep).toBe('me');
expect(data.email).toBe('a@x.com');
});
});
it('returns false (no-op) when the file has no priority field', async () => {
await withIsolatedHome(async (homeDir) => {
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
writeAuthFile(authDir, 'a.json', { email: 'a@x.com' });
const { clearAuthFilePriorityDirect } = await loadDrainOrder();
expect(clearAuthFilePriorityDirect('a.json')).toBe(false);
});
});
it('throws when the file does not exist', async () => {
await withIsolatedHome(async () => {
const { clearAuthFilePriorityDirect } = await loadDrainOrder();
expect(() => clearAuthFilePriorityDirect('ghost.json')).toThrow();
});
});
});
describe('clearDrainOrderPriorities - direct path (proxy stopped)', () => {
it('clears residual priorities from all files via direct write', async () => {
await withIsolatedHome(async (homeDir) => {
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
writeAuthFile(authDir, 'a.json', { email: 'a@x.com', priority: 3 });
writeAuthFile(authDir, 'b.json', { email: 'b@x.com' }); // already clear
const { clearDrainOrderPriorities } = await loadDrainOrder();
const result = await clearDrainOrderPriorities(['a.json', 'b.json'], false);
expect(result.usedManagementApi).toBe(false);
expect(result.cleared).toEqual(['a.json']);
expect(result.alreadyClear).toEqual(['b.json']);
expect(result.failed).toHaveLength(0);
expect('priority' in readAuthFile(authDir, 'a.json')).toBe(false);
});
});
it('records a failure for a missing file', async () => {
await withIsolatedHome(async () => {
const { clearDrainOrderPriorities } = await loadDrainOrder();
const result = await clearDrainOrderPriorities(['ghost.json'], false);
expect(result.failed).toHaveLength(1);
expect(result.cleared).toHaveLength(0);
});
});
});
describe('clearDrainOrderPriorities - management API path (proxy running)', () => {
it('PATCHes priority:0 (delete) for files with a residual priority and skips clear ones', async () => {
await withIsolatedHome(async (homeDir) => {
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
writeAuthFile(authDir, 'a.json', { email: 'a@x.com', priority: 3 });
writeAuthFile(authDir, 'b.json', { email: 'b@x.com' }); // already clear -> no API call
const calls: Array<{ url: string; body: unknown }> = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url =
typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
calls.push({ url, body: JSON.parse(String(init?.body ?? '{}')) });
return new Response('{}', { status: 200 });
}) as typeof fetch;
try {
const { clearDrainOrderPriorities } = await loadDrainOrder();
const result = await clearDrainOrderPriorities(['a.json', 'b.json'], true);
expect(result.usedManagementApi).toBe(true);
expect(result.cleared).toEqual(['a.json']);
expect(result.alreadyClear).toEqual(['b.json']);
expect(result.failed).toHaveLength(0);
// Only a.json (which had a residual priority) triggered an API call,
// and it patched priority:0 (the delete sentinel).
expect(calls).toHaveLength(1);
expect(calls[0].url).toContain('/v0/management/auth-files/fields');
expect(calls[0].body).toEqual({ name: 'a.json', priority: 0 });
} finally {
globalThis.fetch = originalFetch;
}
});
});
it('records a failure when the PATCH returns a non-ok status', async () => {
await withIsolatedHome(async (homeDir) => {
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
writeAuthFile(authDir, 'a.json', { email: 'a@x.com', priority: 3 });
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => new Response('nope', { status: 500 })) as typeof fetch;
try {
const { clearDrainOrderPriorities } = await loadDrainOrder();
const result = await clearDrainOrderPriorities(['a.json'], true);
expect(result.cleared).toHaveLength(0);
expect(result.failed).toHaveLength(1);
expect(result.failed[0].tokenFile).toBe('a.json');
} finally {
globalThis.fetch = originalFetch;
}
});
});
});
// ---------------------------------------------------------------------------
// fetchProxyAuthCooldowns - live in-proxy 429 cooldowns
// ---------------------------------------------------------------------------
describe('fetchProxyAuthCooldowns', () => {
it('classifies unavailable entries as cooldowns and parses next_retry_after', async () => {
await withIsolatedHome(async () => {
const next = '2026-06-11T12:00:00.000Z';
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
files: [
{ name: 'cooling.json', unavailable: true, next_retry_after: next },
{ name: 'no-retry.json', unavailable: true },
{ name: 'healthy.json', unavailable: false },
],
}),
{ status: 200 }
)) as typeof fetch;
try {
const { fetchProxyAuthCooldowns } = await loadDrainOrder();
const cooldowns = await fetchProxyAuthCooldowns();
// Only the two unavailable entries are returned; healthy.json is excluded.
expect(cooldowns.map((c: { tokenFile: string }) => c.tokenFile).sort()).toEqual([
'cooling.json',
'no-retry.json',
]);
const cooling = cooldowns.find(
(c: { tokenFile: string }) => c.tokenFile === 'cooling.json'
);
expect(cooling?.cooldownUntil).toBe(Date.parse(next));
const noRetry = cooldowns.find(
(c: { tokenFile: string }) => c.tokenFile === 'no-retry.json'
);
expect(noRetry?.cooldownUntil).toBeUndefined();
} finally {
globalThis.fetch = originalFetch;
}
});
});
it('degrades silently to [] on a non-ok response', async () => {
await withIsolatedHome(async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => new Response('err', { status: 404 })) as typeof fetch;
try {
const { fetchProxyAuthCooldowns } = await loadDrainOrder();
expect(await fetchProxyAuthCooldowns()).toEqual([]);
} finally {
globalThis.fetch = originalFetch;
}
});
});
it('degrades silently to [] on a network error', async () => {
await withIsolatedHome(async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => {
throw new TypeError('network down');
}) as typeof fetch;
try {
const { fetchProxyAuthCooldowns } = await loadDrainOrder();
expect(await fetchProxyAuthCooldowns()).toEqual([]);
} finally {
globalThis.fetch = originalFetch;
}
});
});
it('degrades silently to [] on an unexpected response shape', async () => {
await withIsolatedHome(async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(JSON.stringify({ unexpected: 'shape' }), { status: 200 })) as typeof fetch;
try {
const { fetchProxyAuthCooldowns } = await loadDrainOrder();
expect(await fetchProxyAuthCooldowns()).toEqual([]);
} finally {
globalThis.fetch = originalFetch;
}
});
});
});
@@ -37,6 +37,13 @@ async function withIsolatedHome<T>(fn: (homeDir: string) => Promise<T> | T): Pro
}
}
const SETTINGS_ON: PoolRoutingSettings = {
poolEnabled: true,
strategy: 'fill-first',
sessionAffinityEnabled: true,
sessionAffinityTtl: '1h',
};
async function loadPoolState() {
return import(`../pool-state?pool-state=${Date.now()}`);
}
@@ -260,3 +267,157 @@ describe('resolvePoolState - drain order', () => {
});
});
});
describe('resolvePoolState - in-proxy cooldown classification', () => {
it('classifies an injected proxy cooldown as cooling with source "proxy"', async () => {
await withIsolatedHome(async () => {
const { resolvePoolState } = await loadPoolState();
const until = 2_000_000;
const pool = resolvePoolState({
provider: 'claude',
settings: SETTINGS_ON,
accounts: [account({ id: 'a@x.com', tokenFile: 'claude-a.json' })],
proxyCooldowns: [{ tokenFile: 'claude-a.json', cooldownUntil: until }],
now: 1_000_000,
});
expect(pool.states[0].state).toBe('cooling');
expect(pool.states[0].cooldownUntil).toBe(until);
expect(pool.states[0].cooldownSource).toBe('proxy');
});
});
it('treats a proxy cooldown with no next_retry_after as cooling (no reset time)', async () => {
await withIsolatedHome(async () => {
const { resolvePoolState } = await loadPoolState();
const pool = resolvePoolState({
provider: 'claude',
settings: SETTINGS_ON,
accounts: [account({ id: 'a@x.com', tokenFile: 'claude-a.json' })],
proxyCooldowns: [{ tokenFile: 'claude-a.json' }],
now: 1_000_000,
});
expect(pool.states[0].state).toBe('cooling');
expect(pool.states[0].cooldownUntil).toBeUndefined();
expect(pool.states[0].cooldownSource).toBe('proxy');
});
});
it('proxy cooldown wins over a persisted quota cooldown for the same account', async () => {
await withIsolatedHome(async (homeDir) => {
const quotaPausedPath = path.join(homeDir, '.ccs', 'cliproxy', 'quota-paused.json');
fs.mkdirSync(path.dirname(quotaPausedPath), { recursive: true });
fs.writeFileSync(
quotaPausedPath,
JSON.stringify({
entries: [
{
provider: 'claude',
accountId: 'a@x.com',
pausedAt: '2026-06-10T12:00:00.000Z',
until: 1_500_000,
reason: 'quota_exhausted',
},
],
})
);
const { resolvePoolState } = await loadPoolState();
const pool = resolvePoolState({
provider: 'claude',
settings: SETTINGS_ON,
accounts: [account({ id: 'a@x.com', tokenFile: 'claude-a.json' })],
proxyCooldowns: [{ tokenFile: 'claude-a.json', cooldownUntil: 9_000_000 }],
now: 1_000_000,
});
// Proxy (live routing truth) wins: source 'proxy', its reset time, not the
// persisted quota-paused.json one.
expect(pool.states[0].cooldownSource).toBe('proxy');
expect(pool.states[0].cooldownUntil).toBe(9_000_000);
});
});
it('leaves accounts available when no proxy cooldown is provided (graceful degradation)', async () => {
await withIsolatedHome(async () => {
const { resolvePoolState, hasProxySourcedCooling } = await loadPoolState();
const pool = resolvePoolState({
provider: 'claude',
settings: SETTINGS_ON,
accounts: [account({ id: 'a@x.com', tokenFile: 'claude-a.json' })],
// proxyCooldowns omitted -> CCS-side view only.
now: 1_000_000,
});
expect(pool.states[0].state).toBe('available');
expect(hasProxySourcedCooling(pool)).toBe(false);
});
});
it('hasProxySourcedCooling reports true only when a proxy-sourced cooling exists', async () => {
await withIsolatedHome(async () => {
const { resolvePoolState, hasProxySourcedCooling } = await loadPoolState();
const pool = resolvePoolState({
provider: 'claude',
settings: SETTINGS_ON,
accounts: [
account({ id: 'a@x.com', tokenFile: 'claude-a.json' }),
account({ id: 'b@x.com', tokenFile: 'claude-b.json' }),
],
proxyCooldowns: [{ tokenFile: 'claude-b.json', cooldownUntil: 2_000_000 }],
now: 1_000_000,
});
expect(hasProxySourcedCooling(pool)).toBe(true);
});
});
});
describe('resolvePoolStateWithProxyCooldowns - fetch + degradation', () => {
it('skips the proxy fetch and stays available when pool routing is OFF', async () => {
await withIsolatedHome(async () => {
const { resolvePoolStateWithProxyCooldowns } = await loadPoolState();
let fetched = false;
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => {
fetched = true;
return new Response('{}', { status: 200 });
}) as typeof fetch;
try {
const pool = await resolvePoolStateWithProxyCooldowns({
provider: 'claude',
settings: SETTINGS_OFF,
accounts: [account({ id: 'a@x.com', tokenFile: 'claude-a.json' })],
now: 1_000_000,
});
expect(pool.states[0].state).toBe('available');
// Pool OFF -> no management API call at all.
expect(fetched).toBe(false);
} finally {
globalThis.fetch = originalFetch;
}
});
});
it('honours injected proxyCooldowns without fetching, even when pool is ON', async () => {
await withIsolatedHome(async () => {
const { resolvePoolStateWithProxyCooldowns } = await loadPoolState();
let fetched = false;
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => {
fetched = true;
return new Response('{}', { status: 200 });
}) as typeof fetch;
try {
const pool = await resolvePoolStateWithProxyCooldowns({
provider: 'claude',
settings: SETTINGS_ON,
accounts: [account({ id: 'a@x.com', tokenFile: 'claude-a.json' })],
proxyCooldowns: [{ tokenFile: 'claude-a.json', cooldownUntil: 2_000_000 }],
now: 1_000_000,
});
expect(pool.states[0].cooldownSource).toBe('proxy');
// Injected cooldowns short-circuit the fetch.
expect(fetched).toBe(false);
} finally {
globalThis.fetch = originalFetch;
}
});
});
});
@@ -8,30 +8,107 @@
* Google/Anthropic treat as suspicious.
*
* Scope of check: compare the newly registered CLIProxy account email against
* the email of the currently active native Claude Code profile (via `claude
* auth status`). The profiles.json v3.0 schema removed the email field,
* so we rely on the live auth status command which is already used by
* quota-fetcher-claude.ts.
* the email of every native Claude Code lane the user has:
* 1. The ambient ~/.claude login (via `claude auth status`).
* 2. Each isolated CCS account profile lane (per-profile CLAUDE_CONFIG_DIR
* instance dir). These are the exact multi-account population the guard
* exists to protect, so they MUST be inspected, not just the default lane.
*
* Lane emails are read directly from each lane's `.claude.json`
* (`oauthAccount.emailAddress`) cheap file reads, no CLI spawn. The
* profiles.json v3.0 schema removed the email field, so the per-lane config is
* the source of truth for an account profile's logged-in email.
*
* This guard is advisory only: it warns on stderr but does not block the add.
* The user may intentionally separate accounts; a false positive is less harmful
* than silently allowing a true overlap.
* than silently allowing a true overlap. Missing or unreadable lane files are
* silently skipped a corrupt profile must never block or crash auth.
*/
import * as fs from 'fs';
import * as path from 'path';
import { warn } from '../../utils/ui';
import { getClaudeAuthStatus } from '../../utils/claude-detector';
import { maskEmail } from './account-safety';
import InstanceManager from '../../management/instance-manager';
import { ProfileRegistry } from '../../auth/profile-registry';
import type { CLIProxyProvider } from '../types';
/** Providers where CLIProxy OAuth could create a cross-lane conflict with native Claude */
const CROSS_LANE_RISK_PROVIDERS: CLIProxyProvider[] = ['claude', 'agy', 'gemini', 'codex'];
/** A native Claude lane that overlaps with the new CLIProxy account email. */
interface LaneOverlap {
/** The lane's masked email (for display). */
maskedEmail: string;
/** Human label for the lane, e.g. "native Claude Code login" or `profile "work"`. */
laneLabel: string;
}
/**
* Check whether the newly added CLIProxy account email matches the email of
* the currently active native Claude Code profile.
* Read the OAuth email stored in a native Claude lane's `.claude.json`.
*
* Emits a warning to stderr if an overlap is detected. Silent on errors
* (CLI not found, not logged in, etc.) the check is best-effort.
* Returns the lowercased/trimmed email, or null when the file is missing,
* unreadable, malformed, or has no logged-in account. Never throws a corrupt
* profile is silently skipped (the guard is advisory).
*/
function readLaneEmail(claudeConfigDir: string): string | null {
try {
const claudeJsonPath = path.join(claudeConfigDir, '.claude.json');
const raw = fs.readFileSync(claudeJsonPath, 'utf-8');
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== 'object') return null;
const oauthAccount = (parsed as Record<string, unknown>).oauthAccount;
if (!oauthAccount || typeof oauthAccount !== 'object') return null;
const email = (oauthAccount as Record<string, unknown>).emailAddress;
if (typeof email !== 'string' || email.trim().length === 0) return null;
return email.toLowerCase().trim();
} catch {
// Missing / unreadable / malformed lane config — skip silently.
return null;
}
}
/**
* Enumerate CCS account profile lanes and collect any whose stored OAuth email
* matches the newly added CLIProxy account email.
*
* Pure file reads (one `.claude.json` per profile) no CLI spawn. Best-effort:
* any failure to enumerate or read a lane is swallowed so the guard can never
* block or crash the add.
*/
function findAccountProfileOverlaps(normalizedEmail: string): LaneOverlap[] {
const overlaps: LaneOverlap[] = [];
try {
const registry = new ProfileRegistry();
const profiles = registry.getAllProfilesMerged();
const accountNames = Object.entries(profiles)
.filter(([, meta]) => meta.type === 'account')
.map(([name]) => name);
if (accountNames.length === 0) return overlaps;
const instanceMgr = new InstanceManager();
for (const name of accountNames) {
const instancePath = instanceMgr.getInstancePath(name);
const laneEmail = readLaneEmail(instancePath);
if (laneEmail && laneEmail === normalizedEmail) {
overlaps.push({ maskedEmail: maskEmail(laneEmail), laneLabel: `profile "${name}"` });
}
}
} catch {
// Registry / instance-manager construction failed — skip lane enumeration.
}
return overlaps;
}
/**
* Check whether the newly added CLIProxy account email matches the email of any
* native Claude Code lane: the ambient ~/.claude login or any isolated CCS
* account profile lane.
*
* Emits a warning to stderr for each overlapping lane. Silent on errors
* (CLI not found, not logged in, unreadable profile, etc.) the check is
* best-effort and never blocks the add.
*
* @param provider - The CLIProxy provider being added
* @param email - Email address of the account that was just registered
@@ -40,21 +117,36 @@ export function checkCrossLaneEmailOverlap(provider: CLIProxyProvider, email: st
if (!CROSS_LANE_RISK_PROVIDERS.includes(provider)) return;
try {
const status = getClaudeAuthStatus();
if (!status?.loggedIn || !status.email) return;
const normalized = email.toLowerCase().trim();
const nativeNormalized = status.email.toLowerCase().trim();
if (normalized.length === 0) return;
if (normalized !== nativeNormalized) return;
const overlaps: LaneOverlap[] = [];
// 1. Ambient ~/.claude login (default lane).
const status = getClaudeAuthStatus();
if (status?.loggedIn && status.email) {
const ambientNormalized = status.email.toLowerCase().trim();
if (ambientNormalized === normalized) {
overlaps.push({
maskedEmail: maskEmail(status.email),
laneLabel: 'native Claude Code login',
});
}
}
// 2. Isolated CCS account profile lanes (per-profile CLAUDE_CONFIG_DIR).
overlaps.push(...findAccountProfileOverlaps(normalized));
if (overlaps.length === 0) return;
const masked = maskEmail(email);
const nativeMasked = maskEmail(status.email);
console.error('');
console.error(warn(`Account safety: cross-lane email overlap detected for ${provider}`));
console.error(` CLIProxy account: ${masked} (${provider})`);
console.error(` Native Claude Code profile: ${nativeMasked} (logged in)`);
for (const overlap of overlaps) {
console.error(` Native Claude Code lane: ${overlap.maskedEmail} (${overlap.laneLabel})`);
}
console.error(
' Same account active in both CLIProxy and native Claude lanes is a known ban risk.'
);
+216
View File
@@ -29,9 +29,30 @@ export const MIN_PRIORITY = 1;
/** Management API path for patching auth file fields */
const AUTH_FILES_FIELDS_PATH = '/v0/management/auth-files/fields';
/** Management API path for listing auth files (and their runtime status). */
const AUTH_FILES_PATH = '/v0/management/auth-files';
/** Timeout for management API calls in ms */
const MGMT_TIMEOUT_MS = 5000;
/**
* One in-proxy cooldown reading, keyed by auth file basename.
*
* Source of truth: GET /v0/management/auth-files. Verified field names from
* CLIProxyAPIPlus internal/api/handlers/management/auth_files.go buildAuthFileEntry:
* - "name" -> the token file basename
* - "unavailable" -> bool, set when a credential is cooling after a 429
* - "next_retry_after" -> RFC3339 timestamp, present only when non-zero
* CLIProxyAPI (the base upstream) exposes the same fields via its own
* auth-files handler (sdk/cliproxy/auth/types.go: Unavailable / NextRetryAfter).
*/
export interface ProxyAuthCooldown {
/** Token file basename (matches AccountInfo.tokenFile). */
tokenFile: string;
/** Epoch ms when the in-proxy cooldown is eligible to lift, if known. */
cooldownUntil?: number;
}
/**
* Tier rank for priority derivation.
* Higher rank = higher priority = drained first.
@@ -258,6 +279,29 @@ export async function writeAuthFilePriorityViaApi(
throw new Error(`Priority must be >= ${MIN_PRIORITY}, got ${priority}`);
}
return patchAuthFilePriority(tokenFile, priority);
}
/**
* Remove the priority attribute via CLIProxy management API (proxy running).
* Sends PATCH /v0/management/auth-files/fields with priority:0, which the
* management layer treats as "delete the attribute" (verified upstream:
* CLIProxyAPIPlus syncAuthFilePriorityAttribute deletes the attribute when the
* incoming priority is 0 or absent, which then persists the auth file without a
* top-level priority field).
*
* Using the API path is mandatory while the proxy is running: a direct file
* write would be clobbered by the proxy's whole-file MarkResult persist.
*
* @param tokenFile File name (basename, e.g. "antigravity-foo.json")
* @returns true on success, false on failure
*/
export async function clearAuthFilePriorityViaApi(tokenFile: string): Promise<boolean> {
return patchAuthFilePriority(tokenFile, 0);
}
/** Shared PATCH-fields call for setting (>=1) or clearing (0) a file's priority. */
async function patchAuthFilePriority(tokenFile: string, priority: number): Promise<boolean> {
const target = getProxyTarget();
const url = buildProxyUrl(target, AUTH_FILES_FIELDS_PATH);
const headers = buildManagementHeaders(target, { 'Content-Type': 'application/json' });
@@ -283,6 +327,105 @@ export async function writeAuthFilePriorityViaApi(
}
}
/**
* Remove the priority field directly from an auth JSON file (proxy stopped).
* No-op (returns false) when the file has no priority field. Preserves all
* other fields; uses the same atomic temp-rename as writeAuthFilePriorityDirect.
*
* @returns true if a priority field was present and removed, false otherwise
*/
export function clearAuthFilePriorityDirect(tokenFile: string): boolean {
const authDir = getAuthDir();
const filePath = path.join(authDir, tokenFile);
if (!fs.existsSync(filePath)) {
throw new Error(`Auth file not found: ${tokenFile}`);
}
const content = fs.readFileSync(filePath, 'utf-8');
const data = JSON.parse(content) as Record<string, unknown>;
if (!('priority' in data)) {
return false;
}
delete data.priority;
const tempPath = `${filePath}.tmp.${process.pid}`;
fs.writeFileSync(tempPath, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
fs.renameSync(tempPath, filePath);
return true;
}
/** Result of clearing residual priorities across a set of auth files. */
export interface ClearDrainOrderPrioritiesResult {
/** Token files where a priority field was present and removed. */
cleared: string[];
/** Token files that had no priority field (nothing to do). */
alreadyClear: string[];
/** Token files that failed, with the reason. */
failed: Array<{ tokenFile: string; reason: string }>;
/** Whether the management API path was used (proxy was running). */
usedManagementApi: boolean;
}
/**
* Clear residual on-disk priority fields for the given auth files.
*
* Mirrors applyDrainOrder's dual write path:
* - Proxy running: PATCH priority:0 via the management API (the only safe path;
* a direct write is clobbered by the proxy's whole-file persist).
* - Proxy stopped: delete the field directly with an atomic temp-rename.
*
* Idempotent: files with no priority field are reported as alreadyClear, not
* failures. The running path always reports the file as cleared on a 200 (the
* API treats a missing-attribute delete as a successful no-op), so this is safe
* to run repeatedly.
*
* @param tokenFiles Auth file basenames to clear.
* @param proxyRunning Whether the proxy is currently running.
*/
export async function clearDrainOrderPriorities(
tokenFiles: string[],
proxyRunning: boolean
): Promise<ClearDrainOrderPrioritiesResult> {
const result: ClearDrainOrderPrioritiesResult = {
cleared: [],
alreadyClear: [],
failed: [],
usedManagementApi: proxyRunning,
};
for (const tokenFile of tokenFiles) {
if (proxyRunning) {
// Skip the API round-trip when the file already has no priority on disk.
if (readAuthFilePriority(tokenFile) === undefined) {
result.alreadyClear.push(tokenFile);
continue;
}
const ok = await clearAuthFilePriorityViaApi(tokenFile);
if (ok) {
result.cleared.push(tokenFile);
} else {
result.failed.push({ tokenFile, reason: 'management API PATCH failed' });
}
} else {
try {
const removed = clearAuthFilePriorityDirect(tokenFile);
if (removed) {
result.cleared.push(tokenFile);
} else {
result.alreadyClear.push(tokenFile);
}
} catch (err) {
result.failed.push({ tokenFile, reason: (err as Error).message });
}
}
}
return result;
}
/**
* Result of a drain order apply operation.
*/
@@ -496,3 +639,76 @@ export function resolveEffectiveDrainOrder(
return { mode, entries, hasDrift };
}
/** Raw auth-file entry shape we parse out of the management listing. */
interface RawManagementAuthFile {
name?: unknown;
unavailable?: unknown;
next_retry_after?: unknown;
}
/**
* Fetch in-proxy cooldowns from the running CLIProxy.
*
* Pool routing turns cooling ON, so when a credential hits a 429 the proxy
* marks it Unavailable and rotates to a healthy one. That cooldown lives inside
* the proxy process (not in CCS's quota-paused.json), so the only way the quota
* Pool section can explain a session hop is to read it here.
*
* GET /v0/management/auth-files returns { files: [{ name, unavailable,
* next_retry_after, ... }] }. We classify any entry with unavailable=true as a
* cooling credential and parse next_retry_after (RFC3339) into epoch ms when
* present.
*
* Degrades silently to an empty array on any failure (proxy not running,
* endpoint missing on an older binary, malformed/unknown response shape,
* timeout): the caller falls back to its CCS-side cooldown view with no error
* spam in quota output. Latency is bounded by a single request and MGMT_TIMEOUT_MS.
*
* @returns One entry per auth file the proxy reports unavailable.
*/
export async function fetchProxyAuthCooldowns(): Promise<ProxyAuthCooldown[]> {
const target = getProxyTarget();
const url = buildProxyUrl(target, AUTH_FILES_PATH);
const headers = buildManagementHeaders(target);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), MGMT_TIMEOUT_MS);
try {
const response = await fetch(url, { method: 'GET', headers, signal: controller.signal });
if (!response.ok) {
return [];
}
const data = (await response.json()) as { files?: unknown };
if (!Array.isArray(data.files)) {
return [];
}
const cooldowns: ProxyAuthCooldown[] = [];
for (const raw of data.files as RawManagementAuthFile[]) {
// Defensive parse: only entries the proxy explicitly flags unavailable.
if (!raw || raw.unavailable !== true) {
continue;
}
const name = typeof raw.name === 'string' ? raw.name : undefined;
if (!name) {
continue;
}
let cooldownUntil: number | undefined;
if (typeof raw.next_retry_after === 'string') {
const parsed = Date.parse(raw.next_retry_after);
if (Number.isFinite(parsed)) {
cooldownUntil = parsed;
}
}
cooldowns.push({ tokenFile: name, cooldownUntil });
}
return cooldowns;
} catch {
return [];
} finally {
clearTimeout(timeoutId);
}
}
+100 -8
View File
@@ -15,6 +15,10 @@
* with "it ran out of quota".
*
* Cooldown source precedence:
* - The in-proxy cooldown (read from GET /v0/management/auth-files) is the real
* hop mechanism when pool routing is ON: the proxy cools a credential on a 429
* and rotates. It wins over everything else because it is the live routing
* truth that actually caused the session to move.
* - The proxy/monitor process writes quota-paused.json (cross-process truth).
* When pool routing's cooling flip is OFF (stock disable-cooling: true) there
* is simply no cooldown data to show; this resolver reports that honestly.
@@ -23,10 +27,18 @@
* both exist, because that is the routing truth across processes.
*/
import { resolveEffectiveDrainOrder, type DrainOrderInput } from './drain-order';
import {
resolveEffectiveDrainOrder,
fetchProxyAuthCooldowns,
type DrainOrderInput,
type ProxyAuthCooldown,
} from './drain-order';
import { getProviderAccounts } from './query';
import { readQuotaCooldownEntries } from './account-safety';
import { getCooldownUntil } from '../quota/quota-manager';
import { getProxyTarget } from '../proxy/proxy-target-resolver';
import { detectRunningProxy } from '../proxy/proxy-detector';
import { resolveLifecyclePort } from '../config/port-manager';
import type { AccountInfo, AccountTier } from './types';
import type { CLIProxyProvider } from '../types';
@@ -54,9 +66,11 @@ export interface PoolAccountState {
cooldownUntil?: number;
/**
* For state 'cooling': where the cooldown reading came from.
* 'persisted' = quota-paused.json (cross-process), 'memory' = in-process map.
* 'proxy' = live in-proxy 429 cooldown (GET /v0/management/auth-files),
* 'persisted' = quota-paused.json (cross-process),
* 'memory' = in-process quota-manager map.
*/
cooldownSource?: 'persisted' | 'memory';
cooldownSource?: 'proxy' | 'persisted' | 'memory';
/** ISO timestamp the account was paused (manual pause), when state is 'paused'. */
pausedAt?: string;
}
@@ -103,23 +117,34 @@ export interface ResolvePoolStateInput {
accounts?: AccountInfo[];
/** Override for tests; defaults to Date.now(). */
now?: number;
/**
* Live in-proxy cooldowns (GET /v0/management/auth-files), keyed nowhere -
* passed as a flat list and indexed by tokenFile internally. When provided,
* an entry takes precedence over CCS-side cooldown views for the matching
* auth file. Defaults to none (CCS-side classification only).
*/
proxyCooldowns?: ProxyAuthCooldown[];
}
/**
* Classify a single account's pool state.
*
* Precedence:
* 1. A persisted quota cooldown whose pausedAt matches the account's pausedAt is
* 1. A live in-proxy cooldown (proxy reports the credential unavailable after a
* 429) -> 'cooling' source 'proxy'. This is the actual hop mechanism when
* pool routing is ON, so it wins over the CCS-side views below.
* 2. A persisted quota cooldown whose pausedAt matches the account's pausedAt is
* a quota cooldown -> 'cooling' (cross-process truth wins).
* 2. A paused account without a matching cooldown record is a manual/safety pause
* 3. A paused account without a matching cooldown record is a manual/safety pause
* -> 'paused'.
* 3. An unpaused account with an in-memory cooldown still in effect -> 'cooling'.
* 4. Otherwise -> 'available'.
* 4. An unpaused account with an in-memory cooldown still in effect -> 'cooling'.
* 5. Otherwise -> 'available'.
*/
function classifyAccount(
provider: CLIProxyProvider,
account: AccountInfo,
cooldownByAccount: Map<string, { until: number; pausedAt: string }>,
proxyCooldownByTokenFile: Map<string, ProxyAuthCooldown>,
now: number
): PoolAccountState {
const base: PoolAccountState = {
@@ -130,6 +155,22 @@ function classifyAccount(
state: 'available',
};
// Live in-proxy cooldown wins: this is the credential the proxy actually
// rotated out on a 429. A missing/zero next_retry_after still means cooling;
// we just cannot show a reset time for it.
const proxyCooldown = proxyCooldownByTokenFile.get(account.tokenFile);
if (
proxyCooldown &&
(proxyCooldown.cooldownUntil === undefined || proxyCooldown.cooldownUntil > now)
) {
return {
...base,
state: 'cooling',
cooldownUntil: proxyCooldown.cooldownUntil,
cooldownSource: 'proxy',
};
}
const persisted = cooldownByAccount.get(account.id);
if (account.paused) {
@@ -194,6 +235,10 @@ function resolveDrainOrder(
/**
* Resolve the full observable pool state for a provider.
*
* Synchronous: callers that want live in-proxy 429 cooldowns surfaced (the real
* hop mechanism when pool routing is ON) pass them in via input.proxyCooldowns,
* or use resolvePoolStateWithProxyCooldowns() which fetches them first.
*/
export function resolvePoolState(input: ResolvePoolStateInput): PoolState {
const { provider, settings } = input;
@@ -207,8 +252,14 @@ export function resolvePoolState(input: ResolvePoolStateInput): PoolState {
cooldownByAccount.set(entry.accountId, { until: entry.until, pausedAt: entry.pausedAt });
}
// Index live in-proxy cooldowns by token file (the proxy reports by file name).
const proxyCooldownByTokenFile = new Map<string, ProxyAuthCooldown>();
for (const entry of input.proxyCooldowns ?? []) {
proxyCooldownByTokenFile.set(entry.tokenFile, entry);
}
const states = accounts.map((account) =>
classifyAccount(provider, account, cooldownByAccount, now)
classifyAccount(provider, account, cooldownByAccount, proxyCooldownByTokenFile, now)
);
// Drain order is computed over accounts that are in rotation (not paused).
@@ -222,3 +273,44 @@ export function resolvePoolState(input: ResolvePoolStateInput): PoolState {
return { provider, states, drainOrder, settings };
}
/**
* Whether any account was classified 'cooling' from the live in-proxy source.
* Used by the renderer to decide whether the "in-proxy cooldowns not shown"
* honesty note still applies.
*/
export function hasProxySourcedCooling(pool: PoolState): boolean {
return pool.states.some((s) => s.state === 'cooling' && s.cooldownSource === 'proxy');
}
/**
* Resolve pool state and, when pool routing is ON and the local proxy is
* reachable, fold in live in-proxy 429 cooldowns (the actual session-hop
* mechanism). Remote targets and a stopped/older proxy degrade silently to the
* synchronous CCS-side view with no error output.
*
* Kept separate from resolvePoolState() so the pure, synchronous resolver stays
* test-friendly and free of network/process detection.
*/
export async function resolvePoolStateWithProxyCooldowns(
input: ResolvePoolStateInput
): Promise<PoolState> {
// Only the local proxy exposes a management API we can read here; remote
// drain/cooldown management is out of scope (v1), so skip the fetch.
const shouldFetch = input.settings.poolEnabled && !getProxyTarget().isRemote;
let proxyCooldowns: ProxyAuthCooldown[] | undefined = input.proxyCooldowns;
if (proxyCooldowns === undefined && shouldFetch) {
try {
const proxyStatus = await detectRunningProxy(resolveLifecyclePort());
if (proxyStatus.running && proxyStatus.verified) {
proxyCooldowns = await fetchProxyAuthCooldowns();
}
} catch {
// Degrade silently: keep the CCS-side cooldown view.
proxyCooldowns = undefined;
}
}
return resolvePoolState({ ...input, proxyCooldowns });
}
@@ -10,7 +10,7 @@ import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { getClaudeEnvVars, ensureProviderSettings } from '../env-builder';
import { getClaudeEnvVars, ensureProviderSettings, getRemoteEnvVars } from '../env-builder';
import { clearConfigCache } from '../base-config-loader';
const MODEL_KEYS = [
@@ -380,4 +380,174 @@ describe('claude provider model-neutral passthrough (Gap 1)', () => {
expect(result.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6');
expect(result.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-sonnet-4-6');
});
// ── Upgrader --config ordering: migrate-first, then re-pin survives ───────────
// Models the `ccs claude --config` flow on an UPGRADER: the executor runs
// ensureProviderSettings BEFORE configureProviderModel writes the user's pick.
// Step 1 (ensureProviderSettings) strips the old auto-pins and sets the marker;
// Step 2 (the user's --config write) lands a fresh pin; Step 3 (next plain
// launch) must NOT strip it because the marker is already set.
it('upgrader --config: ensureProviderSettings runs first, so a re-pin equal to a stale default survives the next launch', () => {
process.env.CCS_HOME = tempHome;
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
const settingsPath = path.join(ccsDir, 'claude.settings.json');
const markerPath = path.join(ccsDir, 'cliproxy', '.claude-model-migrated');
// Pre-existing upgrader file with OLD auto-written default pins, no marker.
fs.writeFileSync(
settingsPath,
JSON.stringify({
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_MODEL: 'claude-sonnet-4-5-20250929',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-5-20251101',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-20250929',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
},
}),
'utf-8'
);
expect(fs.existsSync(markerPath)).toBe(false);
// Step 1: executor runs ensureProviderSettings BEFORE the --config write.
// This strips the old auto-pins and sets the migration marker.
ensureProviderSettings('claude');
expect(fs.existsSync(markerPath)).toBe(true);
const afterMigrate = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
env: Record<string, string | undefined>;
};
for (const key of MODEL_KEYS) {
const value = afterMigrate.env[key];
expect(!value || value.trim().length === 0).toBe(true);
}
// Step 2: configureProviderModel writes the user's deliberate pick — and the
// pick happens to equal a current catalog default that is in the stale set.
const userPin = {
ANTHROPIC_MODEL: 'claude-sonnet-4-6',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-sonnet-4-6',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-sonnet-4-6',
};
const current = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
env: Record<string, string>;
};
fs.writeFileSync(
settingsPath,
JSON.stringify({ ...current, env: { ...current.env, ...userPin } }, null, 2) + '\n',
'utf-8'
);
// Step 3: next plain `ccs claude` launch must NOT strip the just-written pin
// because the marker was already set in Step 1.
ensureProviderSettings('claude');
const result = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
env: Record<string, string | undefined>;
};
expect(result.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6');
expect(result.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-sonnet-4-6');
expect(result.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6');
expect(result.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-sonnet-4-6');
});
// ── Remote read path: stale-pin filter without file mutation ──────────────────
it('getRemoteEnvVars (claude): drops stale default model pins from the settings file at read level', () => {
process.env.CCS_HOME = tempHome;
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
const settingsPath = path.join(ccsDir, 'claude.settings.json');
const originalContent =
JSON.stringify({
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_MODEL: 'claude-sonnet-4-6',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-7',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
},
}) + '\n';
fs.writeFileSync(settingsPath, originalContent, 'utf-8');
const env = getRemoteEnvVars('claude', {
host: 'example.com',
port: 8317,
protocol: 'http',
});
// Stale defaults must NOT leak into the remote env.
for (const key of MODEL_KEYS) {
expect(env[key]).toBeUndefined();
}
// Transport keys are always set from the remote config.
expect(env.ANTHROPIC_BASE_URL).toContain('example.com');
expect(env.ANTHROPIC_AUTH_TOKEN).toBeDefined();
// Read-level only: the settings file on disk is untouched.
expect(fs.readFileSync(settingsPath, 'utf-8')).toBe(originalContent);
});
it('getRemoteEnvVars (claude): preserves a user-custom model pin not in any stale set', () => {
process.env.CCS_HOME = tempHome;
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
const settingsPath = path.join(ccsDir, 'claude.settings.json');
fs.writeFileSync(
settingsPath,
JSON.stringify({
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_MODEL: 'claude-opus-4-8', // custom — not a historical default
},
}),
'utf-8'
);
const env = getRemoteEnvVars('claude', {
host: 'example.com',
port: 8317,
protocol: 'http',
});
// Custom pin survives the remote read path.
expect(env.ANTHROPIC_MODEL).toBe('claude-opus-4-8');
});
it('getRemoteEnvVars (claude): Priority 1 explicit custom settings path is NOT stale-filtered', () => {
process.env.CCS_HOME = tempHome;
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
// An explicitly passed settings file is the user's deliberate choice and
// must be honored verbatim — even when it carries a value equal to a
// historical default.
const customPath = path.join(ccsDir, 'custom-claude.settings.json');
fs.writeFileSync(
customPath,
JSON.stringify({
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_MODEL: 'claude-sonnet-4-6', // equals a stale default, but explicit
},
}),
'utf-8'
);
const env = getRemoteEnvVars(
'claude',
{ host: 'example.com', port: 8317, protocol: 'http' },
customPath
);
// Priority 1 untouched: the explicit pin survives.
expect(env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6');
});
});
+31
View File
@@ -590,6 +590,26 @@ function migrateClaudeStaleModelPins(env: Record<string, string>): boolean {
return mutated;
}
/**
* Read-level equivalent of the stale-pin migration for paths that must not
* mutate the settings file (e.g. the remote env builder). Returns a copy of
* the env with any value equal to a historical default dropped per-key, while
* user-custom pins are preserved. Same per-key Sets as the local migration, so
* local / --config / remote read paths agree on which pins are stale.
*
* No marker is consulted or written: this is purely a read-time filter, so it
* is idempotent across launches without a one-shot guard.
*/
function filterClaudeStaleModelPins(env: Record<string, string>): Record<string, string> {
const result = { ...env };
for (const [key, staleValues] of Object.entries(CLAUDE_STALE_MODEL_DEFAULTS)) {
if (staleValues.has(result[key])) {
delete result[key];
}
}
return result;
}
/**
* Copy bundled settings template to user directory if not exists
* Called during installation/first run
@@ -783,6 +803,17 @@ export function getRemoteEnvVars(
migrateDeprecatedModelNames(settingsPath, provider, settings);
migrateIFlowPlaceholderModel(settingsPath, provider, settings);
userEnvVars = settings.env as Record<string, string>;
// claude is model-neutral. The local launch path runs the one-time
// stale-pin migration (ensureProviderSettings), but the remote path
// never rewrites the file, so a remote-only user whose settings still
// carry an old auto-written default would stay pinned. Filter stale
// defaults at read level so values equal to a historical default are
// dropped while user-custom pins survive. Priority 1 (explicit custom
// settings path) is intentionally left untouched: an explicitly passed
// settings file is the user's deliberate choice.
if (provider === 'claude') {
userEnvVars = filterClaudeStaleModelPins(userEnvVars);
}
}
} catch {
// Invalid JSON - fall through to base config
+11
View File
@@ -230,6 +230,17 @@ export async function execClaudeWithCLIProxy(
console.error(` Use "ccs cliproxy edit ${variantName}" to modify composite variants`);
process.exit(1);
} else {
// Run the one-time stale-pin migration on the pre-existing settings file
// BEFORE writing the user's chosen pin. ensureProviderSettingsFile sets the
// migration marker, so the explicit --config pick survives the next launch
// even when it equals a historical default. Without this, the --config flow
// exits before the only ensureProviderSettingsFile call site (later in this
// function), so a later plain launch would strip the just-written pin.
// Skipped for custom-settings variants: those have no claude.settings
// migration and are written verbatim.
if (!cfg.customSettingsPath) {
ensureProviderSettingsFile(provider);
}
await configureProviderModel(provider, true, cfg.customSettingsPath);
process.exit(0);
}
@@ -285,4 +285,84 @@ describe('cliproxy routing strategy service', () => {
expect(state.message).toContain('not reachable');
});
});
// PR #1514 fix index 2: for a remote target, readCliproxyRoutingState must NOT
// present the local pool flag as if it described the remote proxy. It surfaces
// manageable:false + a message, mirroring the session-affinity remote handling.
it('marks remote pool routing as not manageable (local flag does not describe the remote proxy)', async () => {
await withScopedConfig(async () => {
routingTarget = {
host: 'remote.example.com',
port: 8080,
protocol: 'http',
isRemote: true,
};
responseFactory = async () =>
new Response(JSON.stringify({ strategy: 'round-robin' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
// Enable the LOCAL pool flag — the remote proxy must still report not-manageable.
const { mutateUnifiedConfig } = await import('../../../config/unified-config-loader');
mutateUnifiedConfig((config) => {
if (config.cliproxy) {
config.cliproxy.pool_routing = { enabled: true, max_retry_credentials: 3 };
}
});
const mod = await loadRoutingModule();
const state = await mod.readCliproxyRoutingState();
expect(state.target).toBe('remote');
expect(state.poolRouting?.manageable).toBe(false);
expect(state.poolRouting?.message).toContain('remote proxy');
});
});
// PR #1514 fix index 14 (backend): when local pool routing is enabled, the apply
// result message must carry the pool-override note so API/dashboard consumers see
// the same caveat the CLI prints.
it('appends a pool-active override note to local strategy apply when pool routing is on', async () => {
await withScopedConfig(async () => {
const { mutateUnifiedConfig } = await import('../../../config/unified-config-loader');
mutateUnifiedConfig((config) => {
if (config.cliproxy) {
config.cliproxy.pool_routing = { enabled: true, max_retry_credentials: 3 };
}
});
const mod = await loadRoutingModule();
const result = await mod.applyCliproxyRoutingStrategy('round-robin');
expect(result.message).toContain('Pool routing is active');
expect(result.message).toContain('ccs cliproxy pool --disable');
});
});
it('appends a pool-active override note to local affinity apply when pool routing is on', async () => {
await withScopedConfig(async () => {
const { mutateUnifiedConfig } = await import('../../../config/unified-config-loader');
mutateUnifiedConfig((config) => {
if (config.cliproxy) {
config.cliproxy.pool_routing = { enabled: true, max_retry_credentials: 3 };
}
});
const mod = await loadRoutingModule();
const result = await mod.applyCliproxySessionAffinitySettings({ enabled: true, ttl: '1h' });
expect(result.message).toContain('Pool routing is active');
expect(result.message).toContain('ccs cliproxy pool --disable');
});
});
// Without pool routing, the apply message must NOT carry the override note.
it('does not append the pool-override note when pool routing is off', async () => {
await withScopedConfig(async () => {
const mod = await loadRoutingModule();
const result = await mod.applyCliproxyRoutingStrategy('fill-first');
expect(result.message).not.toContain('Pool routing is active');
});
});
});
+1 -1
View File
@@ -166,7 +166,7 @@ export function maybeShowPoolOnboardingHint(profileCount?: number): OnboardingHi
console.log(
info(
`You have ${count} Claude profiles. 'ccs claude' pool auto-continues on limits (accounts share quota). Docs: ${POOL_DOCS_LINK}`
`You have ${count} Claude profiles. Pool routing can auto-continue 'ccs claude' when one account hits its limit. Enable: ccs cliproxy pool --enable Docs: ${POOL_DOCS_LINK}`
)
);
+47 -1
View File
@@ -20,7 +20,11 @@ import {
POOL_ROUTING_VERIFIED_PROVIDERS,
POOL_MAX_RETRY_CREDENTIALS,
} from './routing-strategy';
import { loadOrCreateUnifiedConfig, mutateConfig } from '../../config/config-loader-facade';
import {
loadOrCreateUnifiedConfig,
mutateConfig,
hasUnifiedConfig,
} from '../../config/config-loader-facade';
import { CLIPROXY_DEFAULT_PORT } from '../config/port-manager';
import { getConfigPathForPort } from '../config/path-resolver';
import { getAuthDir } from '../config/path-resolver';
@@ -63,6 +67,10 @@ export function isPoolPromptDismissed(): boolean {
* Mark the pool routing prompt as permanently dismissed (user said no explicitly).
*/
export function dismissPoolPrompt(): void {
// Defense in depth: never write the unified config on a legacy install — that
// would create config.yaml and silently flip isUnifiedMode(). maybeOfferPoolRouting
// already gates the only caller, but this keeps the exported helper safe too.
if (!hasUnifiedConfig()) return;
mutateConfig((cfg) => {
if (!cfg.cliproxy) return;
cfg.cliproxy.pool_routing = {
@@ -134,6 +142,23 @@ export async function maybeOfferPoolRouting(
return { prompted: false, enabled: false, skipped: true, skipReason: 'not-at-transition' };
}
// Legacy install guard: on a profiles.json/config.json-only install there is no
// unified config.yaml. Both accept (enablePoolRouting) and decline
// (dismissPoolPrompt) write the unified config, which would create config.yaml
// and silently flip isUnifiedMode() outside the deliberate `ccs migrate` flow,
// orphaning the user's config.json profiles. Skip entirely before any prompt
// or dismissal persistence. Mirrors the guards in create-command.ts and
// account-flow.ts and the hazard documented in pool-onboarding-hint.ts.
if (!hasUnifiedConfig()) {
console.log(
info(
'[i] Multiple accounts detected, but pool routing needs the unified config. ' +
"Run 'ccs migrate' first, then 'ccs cliproxy pool --enable'."
)
);
return { prompted: false, enabled: false, skipped: true, skipReason: 'legacy-config' };
}
// Only for providers where pool routing is verified
if (!POOL_ROUTING_VERIFIED_PROVIDERS.has(provider)) {
return {
@@ -178,6 +203,19 @@ export async function maybeOfferPoolRouting(
return { prompted: false, enabled: false, skipped: true, skipReason: 'non-tty' };
}
// Automation bypass: InteractivePrompt.confirm auto-returns true when --yes/-y
// is in argv or CCS_YES=1 is set, regardless of the default:false. This is an
// INSTANCE-GLOBAL routing/cooling consent decision; a generic automation flag
// must never grant it. Skip WITHOUT printing the prompt and WITHOUT persisting
// dismissal, so a future interactive run still offers the opt-in.
if (
process.env.CCS_YES === '1' ||
process.argv.includes('--yes') ||
process.argv.includes('-y')
) {
return { prompted: false, enabled: false, skipped: true, skipReason: 'automation-bypass' };
}
// Gather all providers with 2+ accounts for disclosure
const multiAccountProviders = getMultiAccountProviders();
const providerList =
@@ -226,6 +264,14 @@ export async function maybeOfferPoolRouting(
const result = enablePoolRouting(port, { configPath, authDir });
console.log('');
if (result.failed) {
// Config regeneration failed and the flag was rolled back: pool routing is
// NOT active. Surface the recovery copy and report enabled:false so callers
// (and quota/dashboard) do not claim pool ON.
console.log(warn(result.message));
console.log('');
return { prompted: true, enabled: false, skipped: false };
}
if (result.changed) {
console.log(info(result.message));
}
+124 -13
View File
@@ -46,16 +46,43 @@ export const POOL_ROUTING_VERIFIED_PROVIDERS = new Set(['claude', 'agy']);
*/
export const POOL_ROUTING_MIN_VERSION = '6.9.45';
/**
* Pool-active override warning text. When pool routing is enabled the generator
* forces fill-first/affinity/cooling and ignores the stored strategy/affinity, so
* an apply via API or dashboard will not take effect. The CLI prints this same
* text before applying; appending it to the apply result message lets dashboard
* and API consumers surface the same caveat (the CLI warns, the dashboard did not).
*/
function poolActiveOverrideNote(kind: 'strategy' | 'affinity'): string {
const what = kind === 'strategy' ? 'stored strategy' : 'stored affinity setting';
return (
`[!] Pool routing is active. The ${what} will not take effect\n` +
` until pool routing is disabled: ccs cliproxy pool --disable`
);
}
/** Whether pool routing is enabled in the local unified config. */
function isLocalPoolRoutingEnabled(): boolean {
return loadOrCreateUnifiedConfig().cliproxy?.pool_routing?.enabled === true;
}
export interface EnablePoolRoutingResult {
/** Whether the pool routing state actually changed */
changed: boolean;
/** Whether an existing explicit user routing setting was preserved */
preservedExplicitSetting: boolean;
/**
* True when the config could not be regenerated and the pool flag was rolled
* back. Pool routing is NOT active; the message carries recovery guidance.
*/
failed?: boolean;
message: string;
}
export interface DisablePoolRoutingResult {
changed: boolean;
/** True when the config could not be regenerated and the flag was rolled back. */
failed?: boolean;
message: string;
}
@@ -145,7 +172,24 @@ export function enablePoolRouting(
): EnablePoolRoutingResult {
const config = loadOrCreateUnifiedConfig();
const already = config.cliproxy?.pool_routing?.enabled === true;
const configPath = options.configPath ?? getConfigPathForPort(port);
const authDir = options.authDir ?? getAuthDir();
// Already-enabled repair path: the flag may have been persisted by a prior call
// whose regenerateConfig threw (leaving config.yaml non-pool while the flag says
// enabled). Re-run regenerateConfig so `pool --enable` is an idempotent repair
// command rather than a no-op that can never fix a half-applied state.
if (already) {
try {
regenerateConfig(port, { configPath, authDir });
} catch (err) {
return {
changed: false,
preservedExplicitSetting: false,
failed: true,
message: `[X] Could not write CLIProxy config: ${(err as Error).message}.\n Pool routing is flagged enabled but the config was not regenerated.\n Fix the file permission and re-run: ccs cliproxy pool --enable`,
};
}
return {
changed: false,
preservedExplicitSetting: false,
@@ -185,9 +229,26 @@ export function enablePoolRouting(
};
});
const configPath = options.configPath ?? getConfigPathForPort(port);
const authDir = options.authDir ?? getAuthDir();
regenerateConfig(port, { configPath, authDir });
// The flag is persisted before regenerateConfig. If regeneration throws, roll
// the flag back so status surfaces (pool --enable, quota, dashboard) do not
// report pool ON while config.yaml still runs non-pool routing.
try {
regenerateConfig(port, { configPath, authDir });
} catch (err) {
mutateConfig((cfg) => {
if (!cfg.cliproxy) return;
cfg.cliproxy.pool_routing = {
...cfg.cliproxy.pool_routing,
enabled: false,
};
});
return {
changed: false,
preservedExplicitSetting,
failed: true,
message: `[X] Could not write CLIProxy config: ${(err as Error).message}.\n Pool routing was not enabled; fix the file permission and re-run: ccs cliproxy pool --enable`,
};
}
return {
changed: true,
@@ -218,6 +279,8 @@ export function disablePoolRouting(
): DisablePoolRoutingResult {
const config = loadOrCreateUnifiedConfig();
const wasEnabled = config.cliproxy?.pool_routing?.enabled === true;
const configPath = options.configPath ?? getConfigPathForPort(port);
const authDir = options.authDir ?? getAuthDir();
if (!wasEnabled) {
return {
changed: false,
@@ -236,9 +299,19 @@ export function disablePoolRouting(
};
});
const configPath = options.configPath ?? getConfigPathForPort(port);
const authDir = options.authDir ?? getAuthDir();
regenerateConfig(port, { configPath, authDir });
// The flag is already cleared (matching user intent). If regeneration throws we
// keep enabled:false rather than rolling back to true — re-asserting pool ON
// would reintroduce the single-account blackout the disable path exists to
// prevent. Surface the failure so the user can fix permissions and re-run.
try {
regenerateConfig(port, { configPath, authDir });
} catch (err) {
return {
changed: true,
failed: true,
message: `[X] Could not write CLIProxy config: ${(err as Error).message}.\n Pool routing is flagged disabled but the config was not regenerated.\n Fix the file permission and re-run: ccs cliproxy pool --disable`,
};
}
return {
changed: true,
@@ -262,6 +335,16 @@ const GO_DURATION_PATTERN = new RegExp(`^${GO_DURATION_SEGMENT}+$`);
export interface CliproxyPoolRoutingState {
enabled: boolean;
maxRetryCredentials?: number;
/**
* Whether CCS can manage pool routing for this target. enablePoolRouting only
* writes LOCAL config files, so a remote proxy is never affected by the local
* pool flag. For remote targets this is false and `enabled` reflects the local
* config only (not the remote proxy's behaviour). Omitted (treated as true)
* for local targets. Mirrors CliproxySessionAffinityState.manageable.
*/
manageable?: boolean;
/** Explanation surfaced when manageable is false (remote target). */
message?: string;
}
export interface CliproxyRoutingState {
@@ -270,7 +353,11 @@ export interface CliproxyRoutingState {
target: 'local' | 'remote';
reachable: boolean;
message?: string;
/** Pool routing mode (proxy-wide). Present for both local and remote targets. */
/**
* Pool routing mode. For local targets `enabled` reflects the active proxy
* config. For remote targets `manageable` is false and `enabled` reflects only
* the local config (the remote proxy is not affected by it).
*/
poolRouting?: CliproxyPoolRoutingState;
}
@@ -417,12 +504,24 @@ export async function readCliproxyRoutingState(): Promise<CliproxyRoutingState>
const poolRouting = getCliproxyPoolRoutingState();
if (target.isRemote) {
// Do NOT attach the local pool flag as if it described the remote proxy.
// enablePoolRouting only writes local config files; the remote proxy keeps
// its own routing/cooling. Surface manageable:false + a message so the
// dashboard renders "local only / not applied" instead of claiming the
// remote proxy is pool-managed. Mirrors readCliproxySessionAffinityState.
return {
strategy: await fetchLiveCliproxyRoutingStrategy(),
source: 'live',
target: 'remote',
reachable: true,
poolRouting,
poolRouting: {
enabled: poolRouting.enabled,
maxRetryCredentials: poolRouting.maxRetryCredentials,
manageable: false,
message:
'Pool routing is managed from the local config only and does not affect this remote proxy. ' +
'Configure cooling and routing on the host running CLIProxy instead.',
},
};
}
@@ -497,6 +596,11 @@ export async function applyCliproxyRoutingStrategy(
};
}
// Pool routing overrides the stored strategy at config-generation time, so the
// apply will not take effect until pool routing is disabled. Append the same
// note the CLI prints so dashboard/API consumers see the override too.
const poolNote = isLocalPoolRoutingEnabled() ? `\n\n${poolActiveOverrideNote('strategy')}` : '';
mutateConfig((config) => {
if (config.cliproxy) {
config.cliproxy.routing = { ...config.cliproxy.routing, strategy };
@@ -512,7 +616,7 @@ export async function applyCliproxyRoutingStrategy(
target: 'local',
reachable: true,
applied: 'live-and-config',
message: 'Updated the running proxy and saved the local startup default.',
message: 'Updated the running proxy and saved the local startup default.' + poolNote,
};
} catch {
return {
@@ -521,7 +625,8 @@ export async function applyCliproxyRoutingStrategy(
target: 'local',
reachable: false,
applied: 'config-only',
message: 'Saved the local startup default. It will apply the next time CLIProxy starts.',
message:
'Saved the local startup default. It will apply the next time CLIProxy starts.' + poolNote,
};
}
}
@@ -552,6 +657,10 @@ export async function applyCliproxySessionAffinitySettings(
current.ttl ??
DEFAULT_CLIPROXY_SESSION_AFFINITY_TTL;
// Pool routing overrides the stored session-affinity at config-generation time;
// append the same override note the CLI prints so dashboard/API consumers see it.
const poolNote = isLocalPoolRoutingEnabled() ? `\n\n${poolActiveOverrideNote('affinity')}` : '';
mutateConfig((config) => {
if (config.cliproxy) {
config.cliproxy.routing = {
@@ -572,9 +681,11 @@ export async function applyCliproxySessionAffinitySettings(
reachable,
manageable: true,
applied: 'config-only',
message: reachable
? 'Saved the local startup default. Running local CLIProxy may hot-reload the session-affinity setting, but CCS does not verify live selector state yet.'
: 'Saved the local startup default. It will apply the next time local CLIProxy starts.',
message:
(reachable
? 'Saved the local startup default. Running local CLIProxy may hot-reload the session-affinity setting, but CCS does not verify live selector state yet.'
: 'Saved the local startup default. It will apply the next time local CLIProxy starts.') +
poolNote,
};
}
@@ -0,0 +1,159 @@
/**
* Tests for handleOrderSubcommand presentation + reset behavior.
*
* Why these matter:
* - File-mode SHOW must render from the shared resolver (selector pick order +
* drift), not an alphabetical re-sort. Otherwise the displayed order is the
* inverse of what CLIProxy actually drains whenever residual on-disk
* priorities exist (e.g. left by a prior managed order), and the drift
* warning the manual/tier branch shows is silently dropped.
* - `--reset` must actually strip residual priorities from the auth files, not
* just delete the stored config. With the proxy STOPPED the field is removed
* by a direct atomic write; the running-proxy PATCH path is covered at the
* clearDrainOrderPriorities unit level (drain-order.test.ts).
*/
import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { handleOrderSubcommand } from '../order-subcommand';
describe('handleOrderSubcommand', () => {
let tempHome: string;
let originalCcsHome: string | undefined;
let logSpy: ReturnType<typeof spyOn>;
let lines: string[];
function authDir(): string {
return path.join(tempHome, '.ccs', 'cliproxy', 'auth');
}
function writeAuthFile(fileName: string, fields: Record<string, unknown> = {}): void {
fs.mkdirSync(authDir(), { recursive: true, mode: 0o700 });
fs.writeFileSync(
path.join(authDir(), fileName),
JSON.stringify({ type: 'claude', ...fields }, null, 2),
{ mode: 0o600 }
);
}
function readAuthFile(fileName: string): Record<string, unknown> {
return JSON.parse(fs.readFileSync(path.join(authDir(), fileName), 'utf-8')) as Record<
string,
unknown
>;
}
async function registerClaude(): Promise<{
registerAccount: (provider: string, tokenFile: string, email: string) => unknown;
saveDrainOrderConfig: (provider: string, config: unknown) => boolean;
}> {
return import(`../../../cliproxy/accounts/registry?order-subcommand=${Date.now()}`);
}
beforeEach(() => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-order-subcommand-'));
originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tempHome;
process.exitCode = 0;
lines = [];
logSpy = spyOn(console, 'log').mockImplementation((msg?: unknown) => {
if (typeof msg === 'string') lines.push(msg);
});
});
afterEach(() => {
logSpy.mockRestore();
process.exitCode = 0;
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
fs.rmSync(tempHome, { recursive: true, force: true });
});
describe('file-mode show with residual priorities', () => {
it('renders selector pick order (priority desc) and flags drift instead of alphabetical order', async () => {
// Residual on-disk priorities, no stored config -> file mode + drift.
// claude-a sorts first alphabetically, but b has the higher priority, so
// the selector drains b first. The display must follow the selector.
writeAuthFile('claude-a.json', { email: 'a@x.com', priority: 1 });
writeAuthFile('claude-b.json', { email: 'b@x.com', priority: 5 });
const { registerAccount } = await registerClaude();
registerAccount('claude', 'claude-a.json', 'a@x.com');
registerAccount('claude', 'claude-b.json', 'b@x.com');
await handleOrderSubcommand(['claude']);
const output = lines.join('\n');
// b@x.com (priority 5) must appear before a@x.com (priority 1).
const idxB = output.indexOf('b@x.com');
const idxA = output.indexOf('a@x.com');
expect(idxB).toBeGreaterThanOrEqual(0);
expect(idxA).toBeGreaterThan(idxB);
// Drift surfaced (same as the manual/tier branch), and the mode label no
// longer falsely claims "no priority set" under residual priorities.
expect(output).toContain('Drift detected');
expect(output).toContain('residual priorities present');
expect(output).not.toContain('no priority set');
// [priority: N] annotations preserved.
expect(output).toContain('[priority: 5]');
expect(output).toContain('[priority: 1]');
});
it('keeps the plain "no priority set" label and no drift when there are no residuals', async () => {
writeAuthFile('claude-a.json', { email: 'a@x.com' });
writeAuthFile('claude-b.json', { email: 'b@x.com' });
const { registerAccount } = await registerClaude();
registerAccount('claude', 'claude-a.json', 'a@x.com');
registerAccount('claude', 'claude-b.json', 'b@x.com');
await handleOrderSubcommand(['claude']);
const output = lines.join('\n');
expect(output).toContain('no priority set');
expect(output).not.toContain('Drift detected');
});
});
describe('--reset clears residual priorities (proxy stopped -> direct write)', () => {
it('removes the priority field from auth files and reports per-file results', async () => {
writeAuthFile('claude-a.json', { email: 'a@x.com', priority: 4 });
writeAuthFile('claude-b.json', { email: 'b@x.com' }); // already clear
const { registerAccount, saveDrainOrderConfig } = await registerClaude();
registerAccount('claude', 'claude-a.json', 'a@x.com');
registerAccount('claude', 'claude-b.json', 'b@x.com');
saveDrainOrderConfig('claude', { mode: 'manual', orderedIds: ['a@x.com', 'b@x.com'] });
await handleOrderSubcommand(['claude', '--reset']);
// Residual priority is actually gone from disk (not just config deleted).
expect('priority' in readAuthFile('claude-a.json')).toBe(false);
const output = lines.join('\n');
expect(output).toContain('reset to file order');
// Honest per-file reporting, and it no longer claims residuals remain.
expect(output).toContain('Cleared residual priority from 1 auth file');
expect(output).not.toContain('CLIProxy will continue using them');
});
it('reports already-clear files and still resets when no priorities exist', async () => {
writeAuthFile('claude-a.json', { email: 'a@x.com' });
const { registerAccount } = await registerClaude();
registerAccount('claude', 'claude-a.json', 'a@x.com');
await handleOrderSubcommand(['claude', '--reset']);
const output = lines.join('\n');
expect(output).toContain('reset to file order');
expect(output).toContain('no priority set');
});
});
});
@@ -3,7 +3,10 @@
* account states must be NAMED differently because they map to different client
* failure modes), reset formatting, and drain-order mode labels.
*/
import { describe, expect, it } from 'bun:test';
import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { describeAccountState, formatCooldownReset, modeLabel } from '../pool-state-renderer';
import type { PoolAccountState } from '../../../cliproxy/accounts/pool-state';
@@ -30,7 +33,10 @@ describe('describeAccountState', () => {
expect(available.label).toBe('available');
expect(available.tone).toBe('available');
expect(paused.label).toContain('paused');
// The pause label must NOT claim "manual" - a pause can be automatic (ban
// detection, cross-provider isolation, expired-but-unrestored cooldown).
expect(paused.label).toBe('paused (manual or safety)');
expect(paused.label).not.toBe('paused (manual)');
expect(paused.tone).toBe('paused');
expect(cooling.label).toContain('cooling until');
expect(cooling.tone).toBe('cooling');
@@ -78,3 +84,141 @@ describe('modeLabel', () => {
expect(modeLabel('file')).toBe('file order');
});
});
// ---------------------------------------------------------------------------
// renderProviderPoolSection - integration (resume hint, file-mode drift copy,
// pool-on cooling honesty note). Uses an isolated CCS_HOME and captures console.
// ---------------------------------------------------------------------------
describe('renderProviderPoolSection', () => {
let tempHome: string;
let originalCcsHome: string | undefined;
let logSpy: ReturnType<typeof spyOn>;
let lines: string[];
function writeAuthFile(fileName: string, fields: Record<string, unknown> = {}): void {
const authDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth');
fs.mkdirSync(authDir, { recursive: true, mode: 0o700 });
fs.writeFileSync(
path.join(authDir, fileName),
JSON.stringify({ type: 'claude', ...fields }, null, 2),
{ mode: 0o600 }
);
}
const SETTINGS_OFF = {
poolEnabled: false,
strategy: 'round-robin',
sessionAffinityEnabled: false,
sessionAffinityTtl: '1h',
} as const;
const SETTINGS_ON = {
poolEnabled: true,
strategy: 'fill-first',
sessionAffinityEnabled: true,
sessionAffinityTtl: '1h',
} as const;
beforeEach(() => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-pool-renderer-'));
originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tempHome;
process.exitCode = 0;
lines = [];
logSpy = spyOn(console, 'log').mockImplementation((msg?: unknown) => {
if (typeof msg === 'string') lines.push(msg);
});
});
afterEach(() => {
logSpy.mockRestore();
process.exitCode = 0;
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
fs.rmSync(tempHome, { recursive: true, force: true });
});
it('emits a resume hint pointing at the real command when an account is paused', async () => {
writeAuthFile('claude-a.json', { email: 'a@x.com' });
writeAuthFile('claude-b.json', { email: 'b@x.com' });
const { registerAccount, pauseAccount } = await import(
`../../../cliproxy/accounts/registry?renderer-resume=${Date.now()}`
);
registerAccount('claude', 'claude-a.json', 'a@x.com');
registerAccount('claude', 'claude-b.json', 'b@x.com');
pauseAccount('claude', 'a@x.com');
const { renderProviderPoolSection } = await import(
`../pool-state-renderer?renderer-resume=${Date.now()}`
);
await renderProviderPoolSection('claude', SETTINGS_OFF, 1_000_000);
const output = lines.join('\n');
// The hint names the actual resume subcommand (ccs cliproxy resume <account>).
expect(output).toContain('Resume:');
expect(output).toContain('ccs cliproxy resume <account>');
});
it('does NOT emit a resume hint when no account is paused', async () => {
writeAuthFile('claude-a.json', { email: 'a@x.com' });
const { registerAccount } = await import(
`../../../cliproxy/accounts/registry?renderer-no-resume=${Date.now()}`
);
registerAccount('claude', 'claude-a.json', 'a@x.com');
const { renderProviderPoolSection } = await import(
`../pool-state-renderer?renderer-no-resume=${Date.now()}`
);
await renderProviderPoolSection('claude', SETTINGS_OFF, 1_000_000);
expect(lines.join('\n')).not.toContain('Resume:');
});
it('uses honest file-mode drift copy (no "stored order") when residual priorities exist', async () => {
// Residual on-disk priorities, but no stored drain config -> file mode drift.
writeAuthFile('claude-a.json', { email: 'a@x.com', priority: 1 });
writeAuthFile('claude-b.json', { email: 'b@x.com', priority: 5 });
const { registerAccount } = await import(
`../../../cliproxy/accounts/registry?renderer-drift=${Date.now()}`
);
registerAccount('claude', 'claude-a.json', 'a@x.com');
registerAccount('claude', 'claude-b.json', 'b@x.com');
const { renderProviderPoolSection } = await import(
`../pool-state-renderer?renderer-drift=${Date.now()}`
);
await renderProviderPoolSection('claude', SETTINGS_OFF, 1_000_000);
const output = lines.join('\n');
expect(output).toContain('Drift:');
// File mode has no stored order, so the copy must not claim one nor tell the
// user to "re-apply with --set/--by-tier" (which re-adopts managed ordering).
expect(output).toContain('residual priorities');
expect(output).toContain('--reset');
expect(output).not.toContain('stored order does not match');
expect(output).not.toContain('re-apply with --set/--by-tier');
});
it('prints the in-proxy-cooldown honesty note when pool is ON but no proxy data is available', async () => {
writeAuthFile('claude-a.json', { email: 'a@x.com' });
const { registerAccount } = await import(
`../../../cliproxy/accounts/registry?renderer-note=${Date.now()}`
);
registerAccount('claude', 'claude-a.json', 'a@x.com');
const { renderProviderPoolSection } = await import(
`../pool-state-renderer?renderer-note=${Date.now()}`
);
// No proxy is running in the test home, so the fetch degrades to no data and
// the honesty note must be printed instead of implying every account is fine.
await renderProviderPoolSection('claude', SETTINGS_ON, 1_000_000);
expect(lines.join('\n')).toContain('live in-proxy 429 cooldowns are not shown here');
});
});
@@ -0,0 +1,135 @@
/**
* Tests for handlePoolSubcommand: remote-target refusal and local lifecycle-port
* resolution.
*
* Why these matter:
* - Remote refusal: enable/disable only mutate LOCAL config files. On a remote
* target the running proxy is never touched, so flipping the local flag and
* printing a hot-reload success message would lie. The command must refuse
* (exitCode 1) and leave the local pool flag untouched.
* - Port resolution: a user on a custom local.port (e.g. 9000) must have
* config-9000.yaml regenerated (the file the running proxy reads), not the
* default config.yaml.
*/
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
import { handlePoolSubcommand } from '../pool-subcommand';
import {
invalidateConfigCache as invalidateSharedConfigCache,
mutateConfig,
} from '../../../config/config-loader-facade';
function createTestHome(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-pool-subcommand-test-'));
const ccsDir = path.join(dir, '.ccs');
fs.mkdirSync(path.join(ccsDir, 'cliproxy', 'auth'), { recursive: true });
fs.writeFileSync(path.join(ccsDir, 'config.yaml'), 'version: 1\n', 'utf8');
return dir;
}
describe('handlePoolSubcommand', () => {
let tempHome: string;
let originalCcsHome: string | undefined;
let logSpy: ReturnType<typeof spyOn>;
let lines: string[];
beforeEach(() => {
tempHome = createTestHome();
originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tempHome;
invalidateSharedConfigCache();
process.exitCode = 0;
lines = [];
logSpy = spyOn(console, 'log').mockImplementation((msg?: unknown) => {
if (typeof msg === 'string') lines.push(msg);
});
});
afterEach(() => {
logSpy.mockRestore();
process.exitCode = 0;
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
fs.rmSync(tempHome, { recursive: true, force: true });
});
it('refuses --enable on a remote target, sets exitCode 1, and does not flip the local flag', async () => {
mutateConfig((cfg) => {
cfg.cliproxy_server = {
remote: { enabled: true, host: '192.168.1.50', protocol: 'http' },
};
});
invalidateSharedConfigCache();
await handlePoolSubcommand(['--enable']);
const output = lines.join('\n');
expect(output).toContain('Remote proxy target detected');
// Manual-config guidance is shown for the enable path
expect(output).toContain('disable-cooling: false');
expect(output).toContain('strategy: fill-first');
expect(process.exitCode).toBe(1);
// Local pool flag must remain untouched (not enabled).
invalidateSharedConfigCache();
const { loadOrCreateUnifiedConfig } = await import(
`../../../config/config-loader-facade?poolremote=${Date.now()}`
);
const cfg = loadOrCreateUnifiedConfig();
expect(cfg.cliproxy?.pool_routing?.enabled).not.toBe(true);
// No local config.yaml should have been regenerated by the refused command.
const cliproxyConfig = path.join(tempHome, '.ccs', 'cliproxy', 'config.yaml');
expect(fs.existsSync(cliproxyConfig)).toBe(false);
});
it('refuses --disable on a remote target without the enable-only manual snippet', async () => {
mutateConfig((cfg) => {
cfg.cliproxy_server = {
remote: { enabled: true, host: '192.168.1.50', protocol: 'http' },
};
});
invalidateSharedConfigCache();
await handlePoolSubcommand(['--disable']);
const output = lines.join('\n');
expect(output).toContain('Remote proxy target detected');
// The disable refusal must NOT print the enable manual snippet
expect(output).not.toContain('disable-cooling: false');
expect(process.exitCode).toBe(1);
});
it('--enable on a local custom port regenerates config-<port>.yaml, not config.yaml', async () => {
// Configure a custom local lifecycle port. resolveLifecyclePort() must pick
// it up so the regenerated file matches what the running proxy reads.
mutateConfig((cfg) => {
cfg.cliproxy_server = { local: { port: 9000 } };
});
invalidateSharedConfigCache();
await handlePoolSubcommand(['--enable']);
const cliproxyDir = path.join(tempHome, '.ccs', 'cliproxy');
const customConfig = path.join(cliproxyDir, 'config-9000.yaml');
const defaultConfig = path.join(cliproxyDir, 'config.yaml');
// The custom-port config file must be the one regenerated.
expect(fs.existsSync(customConfig)).toBe(true);
// The default-port config.yaml must NOT be written by a custom-port enable.
expect(fs.existsSync(defaultConfig)).toBe(false);
// And the regenerated file must carry the pool-on rails.
const content = fs.readFileSync(customConfig, 'utf-8');
expect(content).toContain('disable-cooling: false');
expect(content).toContain('strategy: fill-first');
// Success path must not flag a failure exit code.
expect(process.exitCode).not.toBe(1);
});
});
+141 -31
View File
@@ -17,7 +17,7 @@ import {
computeTierDrainOrder,
applyDrainOrder,
resolveEffectiveDrainOrder,
readAuthFilePriority,
clearDrainOrderPriorities,
tieBreakKey,
type DrainOrderEntry,
type DrainOrderInput,
@@ -122,9 +122,11 @@ async function handleOrderShow(provider: CLIProxyProvider): Promise<void> {
const effective = resolveEffectiveDrainOrder(provider, accounts);
if (effective.mode === 'file') {
// File order: no priority writes; show stable file order. Reached when no
// config is stored, or when a stored manual order has only stale IDs.
printFileOrderView(provider, accounts);
// File order: no priority writes. Render from the resolver's output so the
// displayed order matches what the selector actually drains, honouring any
// residual on-disk priorities (e.g. left by `order --reset`). Reached when
// no config is stored, or when a stored manual order has only stale IDs.
printFileOrderView(provider, effective.entries, effective.hasDrift);
return;
}
@@ -160,20 +162,25 @@ async function handleOrderShow(provider: CLIProxyProvider): Promise<void> {
console.log('');
}
function readFilePriorityNote(tokenFile: string): string {
try {
const p = readAuthFilePriority(tokenFile);
return p !== undefined ? dim(` [priority: ${p}]`) : '';
} catch {
return '';
}
function filePriorityNote(entry: DrainOrderEntry): string {
return entry.currentPriority !== undefined ? dim(` [priority: ${entry.currentPriority}]`) : '';
}
/**
* Render the stable file-order view (no priority writes). Used when no drain
* order config is stored, or when a stored manual order has only stale IDs.
* Render the file-order view from the shared resolver's output. No priority
* writes; used when no drain order config is stored, or when a stored manual
* order has only stale IDs.
*
* Entries arrive already sorted in selector pick order (on-disk priority desc,
* tokenFile asc on tie), so the displayed order matches what CLIProxy actually
* drains even when residual priorities exist (e.g. left by `order --reset`). The
* mode label and drift warning are kept honest for that residual case.
*/
function printFileOrderView(provider: CLIProxyProvider, accounts: DrainOrderInput[]): void {
function printFileOrderView(
provider: CLIProxyProvider,
entries: DrainOrderEntry[],
hasDrift: boolean
): void {
if (!TIER_AWARE_PROVIDERS.has(provider)) {
console.log(
info(
@@ -182,17 +189,27 @@ function printFileOrderView(provider: CLIProxyProvider, accounts: DrainOrderInpu
)
);
}
console.log(` Mode: ${dim('file order (no priority set)')}`);
// Under drift, "no priority set" would be a lie - residual on-disk priorities
// are present and are what the selector follows. Label accordingly.
const label = hasDrift
? 'file order (residual priorities present)'
: 'file order (no priority set)';
console.log(` Mode: ${dim(label)}`);
if (hasDrift) {
console.log('');
console.log(
warn(
`Drift detected: residual on-disk priorities steer the selector;\n` +
` clear them with --reset (or re-auth the accounts) to return to plain file order.`
)
);
}
console.log('');
console.log(subheader('Active accounts (file order):'));
// Sort by tokenFile ascending to match the Go selector's stable order.
// Upstream sorts by Auth.ID byte order and only lowercases on Windows (see tieBreakKey).
const sortedByFile = accounts
.slice()
.sort((a, b) => (tieBreakKey(a.tokenFile) < tieBreakKey(b.tokenFile) ? -1 : 1));
sortedByFile.forEach((a, idx) => {
const pri = readFilePriorityNote(a.tokenFile);
console.log(` ${String(idx + 1).padStart(3)} ${color(a.accountId, 'command')}${pri}`);
console.log(subheader('Active accounts (selector pick order):'));
// Entries are already in selector pick order from the resolver; render as-is.
entries.forEach((entry, idx) => {
const pri = filePriorityNote(entry);
console.log(` ${String(idx + 1).padStart(3)} ${color(entry.accountId, 'command')}${pri}`);
});
console.log('');
console.log(
@@ -463,21 +480,114 @@ async function handleOrderSet(provider: CLIProxyProvider, setArg: string): Promi
}
/**
* Reset drain order to file order (removes persisted config, no priority writes).
* Reset drain order to file order: remove the persisted config AND clear the
* residual priority field from the provider's auth files so the selector
* actually returns to plain file order.
*
* Clearing the field uses the same dual write path as --set/--by-tier:
* - Proxy running: PATCH priority:0 via the management API (the management layer
* treats 0 as delete). A direct file write here would be clobbered by the
* proxy's whole-file MarkResult persist, so the API path is mandatory.
* - Proxy stopped: delete the field directly with an atomic temp-rename.
*
* Remote targets are refused with guidance (same as --set/--by-tier), and an
* ambiguous proxy state (alive but not yet serving HTTP) is refused to avoid a
* clobbered write.
*/
async function handleOrderReset(provider: CLIProxyProvider): Promise<void> {
const cleared = clearDrainOrderConfig(provider);
if (cleared) {
console.log(ok(`Drain order reset to file order for ${provider}.`));
const configCleared = clearDrainOrderConfig(provider);
// Collect the auth files that may carry residual priorities. Use
// getProviderAccounts() so file-copied fleets (not yet in accounts.json) are
// covered too; clearing is idempotent for files with no priority field.
const tokenFiles = getProviderAccounts(provider).map((a) => a.tokenFile);
if (tokenFiles.length === 0) {
if (configCleared) {
console.log(ok(`Drain order reset to file order for ${provider}.`));
} else {
console.log(info(`No drain order config found for ${provider}. Already in file order.`));
}
console.log('');
return;
}
// Remote targets: refuse with guidance - clearing priorities needs management
// API access that is not wired for remote proxies in v1.
const proxyTarget = getProxyTarget();
if (proxyTarget.isRemote) {
if (configCleared) {
console.log(ok(`Drain order config cleared for ${provider} (mode reset to file order).`));
} else {
console.log(info(`No drain order config found for ${provider}.`));
}
console.log(
dim(
' Note: existing priority values in auth files are not removed.\n' +
' CLIProxy will continue using them until they are overwritten or files are re-created.'
warn(
`Remote proxy target detected. Residual auth-file priorities were NOT cleared;\n` +
` drain order management for remote proxies is not supported in v1. Run this\n` +
` command on the host running CLIProxy to clear them.`
)
);
console.log('');
process.exitCode = 1;
return;
}
const localPort = resolveLifecyclePort();
const proxyStatus = await detectRunningProxy(localPort);
// Ambiguous state: proxy alive but not yet serving HTTP. A direct write could
// be clobbered; refuse rather than risk a half-cleared state.
if (proxyStatus.running && !proxyStatus.verified) {
if (configCleared) {
console.log(ok(`Drain order config cleared for ${provider} (mode reset to file order).`));
}
console.log(
fail(
'Proxy state ambiguous - residual priorities not cleared. Retry in a moment or stop the proxy first.'
)
);
console.log('');
process.exitCode = 1;
return;
}
const proxyRunning = proxyStatus.running && proxyStatus.verified;
if (proxyRunning) {
console.log(
info('Proxy is running - clearing priorities via management API (avoids clobber race).')
);
} else {
console.log(info('Proxy is stopped - clearing priorities directly in auth files.'));
}
const result = await clearDrainOrderPriorities(tokenFiles, proxyRunning);
if (configCleared || result.cleared.length > 0) {
console.log(ok(`Drain order reset to file order for ${provider}.`));
} else {
console.log(info(`No drain order config found for ${provider}. Already in file order.`));
}
if (result.cleared.length > 0) {
console.log(ok(`Cleared residual priority from ${result.cleared.length} auth file(s).`));
}
if (result.alreadyClear.length > 0) {
console.log(
info(`${result.alreadyClear.length} auth file(s) had no priority set (nothing to clear).`)
);
}
if (result.failed.length > 0) {
for (const f of result.failed) {
console.log(fail(`Failed to clear priority for ${f.tokenFile}: ${f.reason}`));
}
console.log(
dim(
' Residual priorities remain on the failed files above and will still steer the selector.'
)
);
process.exitCode = 1;
}
console.log('');
}
+86 -25
View File
@@ -12,11 +12,13 @@
import { subheader, color, dim, warn } from '../../utils/ui';
import {
resolvePoolState,
resolvePoolStateWithProxyCooldowns,
hasProxySourcedCooling,
POOL_TIER_AWARE_PROVIDERS,
type PoolAccountState,
type PoolRoutingSettings,
type PoolDrainOrderMode,
type PoolState,
} from '../../cliproxy/accounts/pool-state';
import {
getConfiguredCliproxyRoutingStrategy,
@@ -95,7 +97,11 @@ export function describeAccountState(
return { label: `cooling until ${reset}${src}`, tone: 'cooling' };
}
case 'paused':
return { label: 'paused (manual)', tone: 'paused' };
// A pause can be manual (the user) OR automatic (ban detection,
// cross-provider isolation, an expired-but-not-restored quota cooldown).
// The classifier cannot tell which without a stored reason, so the label
// must not claim "manual". See pool-state.ts classifyAccount.
return { label: 'paused (manual or safety)', tone: 'paused' };
case 'available':
default:
return { label: 'available', tone: 'available' };
@@ -114,16 +120,83 @@ export function modeLabel(mode: PoolDrainOrderMode): string {
}
}
/**
* Render the drift warning. The honest copy depends on whether a stored order
* exists: in file mode there is no stored order (the user reset or never set
* one), so referencing a "stored order" or telling them to "re-apply" would be
* false. File-mode drift means residual on-disk priorities (e.g. left by a prior
* managed order) still steer the selector, and the fix is to clear them.
*/
function renderDrainOrderDrift(provider: CLIProxyProvider, pool: PoolState): void {
if (!pool.drainOrder.hasDrift) {
return;
}
if (pool.drainOrder.mode === 'file') {
console.log(
` ${warn(
`Drift: residual priorities from a previous managed order still steer the selector;\n` +
` clear them with "ccs cliproxy accounts order ${provider} --reset" (or re-auth the accounts).`
)}`
);
return;
}
console.log(
` ${warn(
`Drift: stored order does not match auth files; run "ccs cliproxy accounts order ${provider}" to inspect, then re-apply with --set/--by-tier.`
)}`
);
}
/**
* Render the honesty note about cooling visibility.
*
* - Pool OFF: cooling is disabled at the proxy (stock stability mode), so no
* account ever shows a quota cooldown here. State that plainly.
* - Pool ON but no live proxy reading available (proxy stopped/older binary, or
* the listing returned no cooling entries): the in-proxy 429 cooldowns that
* actually drive session hops are not visible, so say so rather than implying
* every account is healthy.
*/
function renderCoolingNote(pool: PoolState, settings: PoolRoutingSettings): void {
if (!settings.poolEnabled) {
const anyCooling = pool.states.some((s) => s.state === 'cooling');
if (!anyCooling) {
console.log(
dim(
' Note: cooling is off (stock stability mode); accounts never show a quota cooldown here.'
)
);
}
return;
}
// Pool ON: only omit the caveat once we actually have live proxy-sourced
// cooling data to show. Otherwise the states reflect CCS-side checks only.
if (!hasProxySourcedCooling(pool)) {
console.log(
dim(
' Note: live in-proxy 429 cooldowns are not shown here; states reflect CCS-side checks only.'
)
);
}
}
/**
* Render the pool context section for one provider. Returns silently (renders
* nothing) when the provider has no accounts, so quota output stays clean.
*
* Async because it folds in live in-proxy 429 cooldowns (the real session-hop
* mechanism when pool routing is ON) via a single bounded management API read;
* that read degrades silently when the proxy is stopped, remote, or older.
*/
export function renderProviderPoolSection(
export async function renderProviderPoolSection(
provider: CLIProxyProvider,
settings: PoolRoutingSettings,
now: number = Date.now()
): void {
const pool = resolvePoolState({ provider, settings, now });
): Promise<void> {
const pool = await resolvePoolStateWithProxyCooldowns({ provider, settings, now });
if (pool.states.length === 0) {
return;
@@ -155,16 +228,7 @@ export function renderProviderPoolSection(
console.log(` Order: ${dim(orderMode)} ${dim('(no active accounts)')}`);
}
// Drift: stored config order diverges from what is on disk. Re-auth drops the
// priority attribute and --reset leaves residuals, so warn instead of showing
// a "next account" that silently disagrees with the selector.
if (pool.drainOrder.hasDrift) {
console.log(
` ${warn(
`Drift: stored order does not match auth files; run "ccs cliproxy accounts order ${provider}" to inspect, then re-apply with --set/--by-tier.`
)}`
);
}
renderDrainOrderDrift(provider, pool);
// Per-account state. Render in drain order first, then any accounts not in the
// order (paused accounts are excluded from the order but still listed).
@@ -188,16 +252,13 @@ export function renderProviderPoolSection(
console.log(` - ${account.accountId}${defaultMark}: ${rendered}`);
}
// Honest note when cooling is impossible to observe (cooling flip off).
if (!settings.poolEnabled) {
const anyCooling = pool.states.some((s) => s.state === 'cooling');
if (!anyCooling) {
console.log(
dim(
' Note: cooling is off (stock stability mode); accounts never show a quota cooldown here.'
)
);
}
renderCoolingNote(pool, settings);
// Resume hint: a pause can be manual OR automatic (ban detection,
// cross-provider isolation, expired-but-unrestored quota cooldown). Point the
// user at the real resume command so they can recover whichever caused it.
if (pool.states.some((s) => s.state === 'paused')) {
console.log(` ${dim(`Resume: ccs cliproxy resume <account>`)}`);
}
// Reset/management hints. Dim hint matches the order subcommand's "To update:"
+62 -7
View File
@@ -8,25 +8,77 @@
*/
import { initUI, header, ok, warn, info } from '../../utils/ui';
import { enablePoolRouting, disablePoolRouting } from '../../cliproxy/routing/routing-strategy';
import {
enablePoolRouting,
disablePoolRouting,
POOL_MAX_RETRY_CREDENTIALS,
} from '../../cliproxy/routing/routing-strategy';
import { loadOrCreateUnifiedConfig } from '../../config/config-loader-facade';
import { CLIPROXY_DEFAULT_PORT } from '../../cliproxy/config/port-manager';
import { resolveLifecyclePort } from '../../cliproxy/config/port-manager';
import { getProxyTarget } from '../../cliproxy/proxy/proxy-target-resolver';
import { getConfigPathForPort, getAuthDir } from '../../cliproxy/config/path-resolver';
import { hasAnyFlag } from '../arg-extractor';
/**
* Print the manual-config guidance for remote/Docker targets, then refuse.
* enablePoolRouting/disablePoolRouting only write local config files, so a
* remote proxy is never touched refuse rather than lie about hot-reload.
* Mirrors the remote-refusal copy in order-subcommand and the manual snippet
* in pool-opt-in-prompt's printRemoteHint().
*/
function printRemotePoolRefusal(enable: boolean): void {
console.log(
warn(
`Remote proxy target detected. Pool routing management for remote proxies is not\n` +
` supported in v1. Run this command on the host running CLIProxy instead.`
)
);
console.log('');
if (enable) {
console.log(' To enable pool routing manually, add to your CLIProxy config.yaml:');
console.log(' disable-cooling: false');
console.log(` max-retry-credentials: ${POOL_MAX_RETRY_CREDENTIALS}`);
console.log(' routing:');
console.log(' strategy: fill-first');
console.log(' session-affinity: true');
console.log(' session-affinity-ttl: "1h"');
console.log('');
}
}
export async function handlePoolSubcommand(args: string[]): Promise<void> {
await initUI();
console.log('');
console.log(header('CLIProxy Pool Routing'));
console.log('');
const port = CLIPROXY_DEFAULT_PORT;
const wantsEnable = hasAnyFlag(args, ['--enable']);
const wantsDisable = hasAnyFlag(args, ['--disable']);
// Resolve the real proxy target before any config write. Remote targets are
// refused (enable/disable only mutate local files; the remote proxy is never
// touched, so the "hot-reload" success message would be a lie). Local targets
// use the configured lifecycle port so the regenerated config-<port>.yaml is
// the same file the running proxy reads.
if (wantsEnable || wantsDisable) {
const target = getProxyTarget();
if (target.isRemote) {
printRemotePoolRefusal(wantsEnable);
process.exitCode = 1;
return;
}
}
const port = resolveLifecyclePort();
const configPath = getConfigPathForPort(port);
const authDir = getAuthDir();
if (hasAnyFlag(args, ['--enable'])) {
if (wantsEnable) {
const result = enablePoolRouting(port, { configPath, authDir });
if (result.changed) {
if (result.failed) {
console.log(warn(result.message));
process.exitCode = 1;
} else if (result.changed) {
console.log(ok(result.message));
} else {
console.log(info(result.message));
@@ -35,9 +87,12 @@ export async function handlePoolSubcommand(args: string[]): Promise<void> {
return;
}
if (hasAnyFlag(args, ['--disable'])) {
if (wantsDisable) {
const result = disablePoolRouting(port, { configPath, authDir });
if (result.changed) {
if (result.failed) {
console.log(warn(result.message));
process.exitCode = 1;
} else if (result.changed) {
console.log(ok(result.message));
} else {
console.log(info(result.message));
+2 -1
View File
@@ -907,7 +907,8 @@ export async function handleQuotaStatus(
runtime.render(result);
// Pool context: drain order + per-account state (available/cooling/paused).
// QuotaSupportedProvider ids are all valid CLIProxyProvider values.
renderProviderPoolSection(provider as CLIProxyProvider, poolSettings);
// Async: folds in live in-proxy 429 cooldowns when pool routing is on.
await renderProviderPoolSection(provider as CLIProxyProvider, poolSettings);
continue;
}
@@ -48,6 +48,12 @@ export function RoutingGuidanceCard({
const currentStrategy = state?.strategy ?? 'round-robin';
const poolEnabled = state?.poolRouting?.enabled ?? false;
const poolMaxRetry = state?.poolRouting?.maxRetryCredentials;
// Undefined manageable means local (managed); only false signals a remote
// proxy where the local pool flag may not reflect the running proxy.
const poolManageable = state?.poolRouting?.manageable ?? true;
const poolLocalOnly = !poolManageable;
const poolLocalOnlyMessage =
state?.poolRouting?.message ?? t('routingGuidance.poolRoutingLocalOnly');
const currentAffinityEnabled = sessionAffinityState?.enabled ?? false;
const currentAffinityTtl = sessionAffinityState?.ttl ?? '1h';
const sessionAffinityManageable = sessionAffinityState?.manageable ?? true;
@@ -174,26 +180,45 @@ export function RoutingGuidanceCard({
</div>
</div>
{poolEnabled && poolManageable ? (
<div
role="status"
className="rounded-lg border border-amber-500/40 bg-amber-500/10 px-2 py-1.5 text-[10px] leading-4 text-amber-700 dark:text-amber-300"
>
{t('routingGuidance.poolRoutingApplyWarning')}
</div>
) : null}
<div className="flex items-center justify-between gap-2 rounded-lg border border-border/60 bg-muted/20 px-2 py-1.5">
<div className="min-w-0">
<div className="text-[10px] font-medium text-foreground">
{t('routingGuidance.poolRouting')}
</div>
<div className="text-[10px] text-muted-foreground">
{poolEnabled && poolMaxRetry !== undefined
? t('routingGuidance.poolMaxRetry', { count: poolMaxRetry })
: t('routingGuidance.drainOrderHint')}
{poolLocalOnly
? poolLocalOnlyMessage
: poolEnabled
? t('routingGuidance.drainOrderHint')
: t('routingGuidance.poolRoutingOffHint')}
</div>
</div>
<Badge
variant={poolEnabled ? 'secondary' : 'outline'}
variant={poolLocalOnly ? 'outline' : poolEnabled ? 'secondary' : 'outline'}
title={
poolEnabled
? t('routingGuidance.poolRoutingManaged')
: t('routingGuidance.poolRoutingOffHint')
poolLocalOnly
? poolLocalOnlyMessage
: poolEnabled
? t('routingGuidance.poolRoutingManaged')
: t('routingGuidance.poolRoutingOffHint')
}
>
{poolEnabled ? t('routingGuidance.poolRoutingOn') : t('routingGuidance.poolRoutingOff')}
{poolLocalOnly
? t('routingGuidance.localOnly')
: poolEnabled
? `${t('routingGuidance.poolRoutingOn')} · ${t('routingGuidance.poolMaxRetry', {
count: poolMaxRetry ?? 0,
})}`
: t('routingGuidance.poolRoutingOff')}
</Badge>
</div>
@@ -267,18 +292,22 @@ export function RoutingGuidanceCard({
{state ? <Badge variant="outline">{state.target}</Badge> : null}
{state ? (
<Badge
variant={poolEnabled ? 'secondary' : 'outline'}
variant={poolLocalOnly ? 'outline' : poolEnabled ? 'secondary' : 'outline'}
title={
poolEnabled
? t('routingGuidance.poolRoutingManaged')
: t('routingGuidance.poolRoutingOffHint')
poolLocalOnly
? poolLocalOnlyMessage
: poolEnabled
? t('routingGuidance.poolRoutingManaged')
: t('routingGuidance.poolRoutingOffHint')
}
>
{t('routingGuidance.poolRouting')}:{' '}
{poolEnabled
? t('routingGuidance.poolRoutingOn')
: t('routingGuidance.poolRoutingOff')}
{poolEnabled && poolMaxRetry !== undefined
{poolLocalOnly
? t('routingGuidance.localOnly')
: poolEnabled
? t('routingGuidance.poolRoutingOn')
: t('routingGuidance.poolRoutingOff')}
{!poolLocalOnly && poolEnabled && poolMaxRetry !== undefined
? ` · ${t('routingGuidance.poolMaxRetry', { count: poolMaxRetry })}`
: ''}
</Badge>
@@ -339,6 +368,14 @@ export function RoutingGuidanceCard({
{isSaving ? 'Saving...' : `Use ${selected}`}
</Button>
</div>
{poolEnabled && poolManageable ? (
<p
role="status"
className="max-w-xs text-[11px] leading-4 text-amber-700 dark:text-amber-400 xl:text-right"
>
{t('routingGuidance.poolRoutingApplyWarning')}
</p>
) : null}
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-muted-foreground xl:col-span-2">
+8
View File
@@ -512,6 +512,14 @@ export type RoutingStrategy = 'round-robin' | 'fill-first';
export interface CliproxyPoolRoutingState {
enabled: boolean;
maxRetryCredentials?: number;
/**
* Whether the pool flag reflects the actual proxy. For a remote proxy target
* the pool flag is local-only and may not be applied to the remote, so the
* backend sets this to false. Undefined is treated as manageable (local).
*/
manageable?: boolean;
/** Explanation surfaced when manageable is false (remote target). */
message?: string;
}
export interface CliproxyRoutingState {
+19
View File
@@ -1924,6 +1924,10 @@ const resources = {
'Pool routing is on: CCS manages strategy (fill-first), session affinity, and cooling for the whole proxy.',
poolRoutingOffHint:
'Pool routing is off. Enable it from the CLI (ccs cliproxy pool --enable) for fill-first drain with cooldown.',
poolRoutingApplyWarning:
'Pool routing is on. Strategy and session affinity changes will not take effect until you disable it: ccs cliproxy pool --disable',
poolRoutingLocalOnly:
'Pool routing is managed locally; this remote proxy may not reflect it.',
poolMaxRetry: 'Max retry {{count}}',
drainOrderHint:
'Per-account drain order and cooldown state: run ccs cliproxy quota or ccs cliproxy accounts order <provider>.',
@@ -4559,6 +4563,9 @@ const resources = {
'账号池路由已开启:CCS 为整个代理统一管理策略(优先填满)、会话粘性和冷却。',
poolRoutingOffHint:
'账号池路由已关闭。可在命令行启用(ccs cliproxy pool --enable)以使用带冷却的优先填满。',
poolRoutingApplyWarning:
'账号池路由已开启。在禁用之前,策略和会话粘性的更改不会生效:ccs cliproxy pool --disable',
poolRoutingLocalOnly: '账号池路由仅在本地管理;此远程代理可能不会反映该设置。',
poolMaxRetry: '最大重试 {{count}}',
drainOrderHint:
'查看每个账号的排空顺序和冷却状态:运行 ccs cliproxy quota 或 ccs cliproxy accounts order <provider>。',
@@ -7257,6 +7264,10 @@ const resources = {
'Định tuyến nhóm đang bật: CCS quản lý chiến lược (fill-first), ghim phiên và làm nguội cho toàn bộ proxy.',
poolRoutingOffHint:
'Định tuyến nhóm đang tắt. Bật từ CLI (ccs cliproxy pool --enable) để dùng fill-first kèm làm nguội.',
poolRoutingApplyWarning:
'Định tuyến nhóm đang bật. Thay đổi chiến lược và ghim phiên sẽ không có hiệu lực cho đến khi bạn tắt nó: ccs cliproxy pool --disable',
poolRoutingLocalOnly:
'Định tuyến nhóm chỉ được quản lý cục bộ; proxy từ xa này có thể không phản ánh điều đó.',
poolMaxRetry: 'Thử lại tối đa {{count}}',
drainOrderHint:
'Thứ tự rút và trạng thái làm nguội từng tài khoản: chạy ccs cliproxy quota hoặc ccs cliproxy accounts order <provider>.',
@@ -10432,6 +10443,10 @@ const resources = {
'プールルーティングが有効です。CCS がプロキシ全体の戦略(fill-first)、セッション固定、クールダウンを管理します。',
poolRoutingOffHint:
'プールルーティングは無効です。CLIccs cliproxy pool --enable)で有効にすると、クールダウン付きの fill-first を利用できます。',
poolRoutingApplyWarning:
'プールルーティングが有効です。無効化するまで、戦略とセッション固定の変更は反映されません:ccs cliproxy pool --disable',
poolRoutingLocalOnly:
'プールルーティングはローカルで管理されます。このリモートプロキシには反映されない場合があります。',
poolMaxRetry: '最大リトライ {{count}}',
drainOrderHint:
'アカウントごとのドレイン順序とクールダウン状態は、ccs cliproxy quota または ccs cliproxy accounts order <provider> を実行してください。',
@@ -12704,6 +12719,10 @@ const resources = {
'풀 라우팅이 켜져 있습니다. CCS가 프록시 전체의 전략(fill-first), 세션 어피니티, 쿨다운을 관리합니다.',
poolRoutingOffHint:
'풀 라우팅이 꺼져 있습니다. CLI(ccs cliproxy pool --enable)에서 켜면 쿨다운이 있는 fill-first를 사용할 수 있습니다.',
poolRoutingApplyWarning:
'풀 라우팅이 켜져 있습니다. 비활성화하기 전에는 전략과 세션 어피니티 변경이 적용되지 않습니다: ccs cliproxy pool --disable',
poolRoutingLocalOnly:
'풀 라우팅은 로컬에서 관리됩니다. 이 원격 프록시에는 반영되지 않을 수 있습니다.',
poolMaxRetry: '최대 재시도 {{count}}',
drainOrderHint:
'계정별 드레인 순서와 쿨다운 상태는 ccs cliproxy quota 또는 ccs cliproxy accounts order <provider>를 실행하세요.',
@@ -87,4 +87,109 @@ describe('RoutingGuidanceCard', () => {
).toBeInTheDocument();
expect(screen.getByRole('button', { name: /session affinity unavailable/i })).toBeDisabled();
});
it('compact: pool ON shows the drain-order pointer and an inline override warning', () => {
render(
<RoutingGuidanceCard
compact
state={{
strategy: 'fill-first',
source: 'live',
target: 'local',
reachable: true,
poolRouting: { enabled: true, maxRetryCredentials: 3 },
}}
sessionAffinityState={{
enabled: true,
ttl: '1h',
source: 'config',
target: 'local',
reachable: true,
manageable: true,
}}
isLoading={false}
isSaving={false}
onApply={() => undefined}
onApplyAffinity={() => undefined}
/>
);
// Drain-order CLI pointer is visible (not hidden behind a hover tooltip).
expect(
screen.getByText(/ccs cliproxy quota or ccs cliproxy accounts order/i)
).toBeInTheDocument();
// Max retry stays visible in the badge.
expect(screen.getByText(/On · Max retry 3/i)).toBeInTheDocument();
// Static override warning is rendered before the user clicks anything.
expect(
screen.getByText(/will not take effect until you disable it: ccs cliproxy pool --disable/i)
).toBeInTheDocument();
});
it('compact: pool OFF surfaces the how-to-enable command as visible text', () => {
render(
<RoutingGuidanceCard
compact
state={{
strategy: 'round-robin',
source: 'live',
target: 'local',
reachable: true,
poolRouting: { enabled: false },
}}
sessionAffinityState={{
enabled: false,
ttl: '1h',
source: 'config',
target: 'local',
reachable: true,
manageable: true,
}}
isLoading={false}
isSaving={false}
onApply={() => undefined}
onApplyAffinity={() => undefined}
/>
);
// The enable command is visible, not buried in a hover-only tooltip.
expect(screen.getByText(/ccs cliproxy pool --enable/i)).toBeInTheDocument();
// No override warning when pool is off.
expect(screen.queryByText(/ccs cliproxy pool --disable/i)).not.toBeInTheDocument();
});
it('compact: remote pool (manageable false) shows a local-only note, not a confident On/Off', () => {
render(
<RoutingGuidanceCard
compact
state={{
strategy: 'round-robin',
source: 'live',
target: 'remote',
reachable: true,
poolRouting: {
enabled: true,
manageable: false,
message: 'Pool routing is managed locally; this remote proxy may not reflect it.',
},
}}
sessionAffinityState={{
source: 'unsupported',
target: 'remote',
reachable: true,
manageable: false,
}}
isLoading={false}
isSaving={false}
onApply={() => undefined}
onApplyAffinity={() => undefined}
/>
);
expect(
screen.getByText('Pool routing is managed locally; this remote proxy may not reflect it.')
).toBeInTheDocument();
// No override warning for a remote proxy the local flag does not manage.
expect(screen.queryByText(/ccs cliproxy pool --disable/i)).not.toBeInTheDocument();
});
});