diff --git a/config/base-claude.settings.json b/config/base-claude.settings.json index e9986c70..284681cc 100644 --- a/config/base-claude.settings.json +++ b/config/base-claude.settings.json @@ -1,10 +1,6 @@ { "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" + "ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed" } } diff --git a/src/api/services/cliproxy-profile-bridge.ts b/src/api/services/cliproxy-profile-bridge.ts index 25ab7d21..6c8a5ca7 100644 --- a/src/api/services/cliproxy-profile-bridge.ts +++ b/src/api/services/cliproxy-profile-bridge.ts @@ -91,6 +91,12 @@ export function suggestCliproxyBridgeName(provider: CLIProxyProvider): string { } function resolveBridgeModelMapping(provider: CLIProxyProvider): ModelMapping { + // claude is model-neutral: model keys are absent from its base config so that + // Claude Code's own /model selection is respected end-to-end. Return empty + // strings here; createSettingsFile skips writing empty model entries. + if (provider === 'claude') { + return { default: '', opus: '', sonnet: '', haiku: '' }; + } const mapping = getModelMappingFromConfig(provider); return { default: mapping.defaultModel, diff --git a/src/api/services/profile-writer.ts b/src/api/services/profile-writer.ts index 2ef17caa..0c0df0e0 100644 --- a/src/api/services/profile-writer.ts +++ b/src/api/services/profile-writer.ts @@ -109,6 +109,15 @@ function createSettingsFile( }); const isNative = isAnthropicDirect(baseUrl, apiKey); + // Model-neutral providers (e.g. claude built-in) pass empty strings to signal + // "omit this key". Filter them out so the written settings file does not + // contain ANTHROPIC_MODEL:'' which could be treated as an unintended override. + const modelEnv = { + ...(models.default.trim() ? { ANTHROPIC_MODEL: models.default } : {}), + ...(models.opus.trim() ? { ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus } : {}), + ...(models.sonnet.trim() ? { ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet } : {}), + ...(models.haiku.trim() ? { ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku } : {}), + }; const settings = { env: { // Native mode: ANTHROPIC_API_KEY only, no BASE_URL/AUTH_TOKEN @@ -120,10 +129,7 @@ function createSettingsFile( ANTHROPIC_AUTH_TOKEN: apiKey, ...(isOpenRouterUrl(baseUrl) && { ANTHROPIC_API_KEY: '' }), }), - ANTHROPIC_MODEL: models.default, - ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus, - ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet, - ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku, + ...modelEnv, ...(extraModels && extraModels.length > 0 ? { ANTHROPIC_EXTRA_MODELS: extraModels.join(',') } : {}), @@ -202,6 +208,13 @@ function createApiProfileUnified( }); const isNative = isAnthropicDirect(baseUrl, apiKey); + // Model-neutral providers pass empty strings; omit those keys. + const modelEnvUnified = { + ...(models.default.trim() ? { ANTHROPIC_MODEL: models.default } : {}), + ...(models.opus.trim() ? { ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus } : {}), + ...(models.sonnet.trim() ? { ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet } : {}), + ...(models.haiku.trim() ? { ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku } : {}), + }; const settings = { env: { ...(isNative @@ -211,10 +224,7 @@ function createApiProfileUnified( ANTHROPIC_AUTH_TOKEN: apiKey, ...(isOpenRouterUrl(baseUrl) && { ANTHROPIC_API_KEY: '' }), }), - ANTHROPIC_MODEL: models.default, - ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus, - ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet, - ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku, + ...modelEnvUnified, ...(extraModels && extraModels.length > 0 ? { ANTHROPIC_EXTRA_MODELS: extraModels.join(',') } : {}), diff --git a/src/auth/commands/create-command.ts b/src/auth/commands/create-command.ts index b0cd4aad..05eacc4a 100644 --- a/src/auth/commands/create-command.ts +++ b/src/auth/commands/create-command.ts @@ -29,7 +29,11 @@ import { exitWithError } from '../../errors'; import { ExitCode } from '../../errors/exit-codes'; import { CommandContext, parseArgs, rejectUnsupportedAuthOptions } from './types'; import { stripAmbientProviderCredentials } from './create-command-env'; -import { isUnifiedMode } from '../../config/config-loader-facade'; +import { isUnifiedMode, hasUnifiedConfig } from '../../config/config-loader-facade'; +import { + maybeShowPoolOnboardingHint, + countNativeClaudeProfiles, +} from '../../cliproxy/routing/pool-onboarding-hint'; function sanitizeProfileNameForInstance(name: string): string { return name.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase(); @@ -310,6 +314,16 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise ) ); console.log(''); + // Pool suggestion: shown only AFTER the profile creation fully + // succeeded (a pre-create hint would burn the once-per-install + // dismissal even when creation fails or rolls back). Print-only, + // TTY-gated, never blocks. Gated on hasUnifiedConfig() so legacy + // profiles.json-only installs receive the hint from ccs doctor only + // (where dismissal semantics are preserved). The profile now exists, + // so the registry count is already post-create (no +1 needed). + if (!profileExistedBeforeCreate && hasUnifiedConfig()) { + maybeShowPoolOnboardingHint(countNativeClaudeProfiles()); + } process.exit(0); } else { await rollbackFailedCreate(); diff --git a/src/cliproxy/__tests__/account-safety-ban-copy.test.ts b/src/cliproxy/__tests__/account-safety-ban-copy.test.ts new file mode 100644 index 00000000..1814d413 --- /dev/null +++ b/src/cliproxy/__tests__/account-safety-ban-copy.test.ts @@ -0,0 +1,133 @@ +/** + * Tests for account-safety ban copy parameterization (Gap 3). + * + * Verifies that handleBanDetection uses provider-appropriate copy: + * - "Anthropic" for the claude provider + * - "Google" for gemini / agy / codex + * and that isBanResponse matches both shared and Anthropic-specific patterns. + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { describe, expect, it, spyOn, afterEach, beforeEach } from 'bun:test'; +import { isBanResponse, handleBanDetection } from '../accounts/account-safety'; + +// ── isBanResponse ───────────────────────────────────────────────────────────── + +describe('isBanResponse', () => { + it.each([ + 'disabled in this account', + 'violation of terms of service', + 'account has been disabled', + 'account is disabled', + 'account has been suspended', + 'account has been banned', + ])('matches shared ban pattern for all providers: %s', (pattern) => { + expect(isBanResponse(pattern)).toBe(true); + expect(isBanResponse(pattern.toUpperCase())).toBe(true); + // Also matches when provider is specified + expect(isBanResponse(pattern, 'gemini')).toBe(true); + expect(isBanResponse(pattern, 'claude')).toBe(true); + }); + + it.each(['your account has been blocked', 'account is blocked'])( + 'matches Anthropic-specific ban pattern for claude provider: %s', + (pattern) => { + expect(isBanResponse(pattern, 'claude')).toBe(true); + } + ); + + it.each(['your account has been blocked', 'account is blocked'])( + 'does NOT match Anthropic-specific ban pattern for non-claude providers: %s', + (pattern) => { + expect(isBanResponse(pattern, 'gemini')).toBe(false); + expect(isBanResponse(pattern, 'agy')).toBe(false); + expect(isBanResponse(pattern, 'codex')).toBe(false); + // Without provider argument also should not match + expect(isBanResponse(pattern)).toBe(false); + } + ); + + it('does not match bare "usage policy" substring for any provider (avoids false positives)', () => { + // A rate-limit message mentioning "usage policy" should not trigger ban + const msg = 'Request exceeds usage policy limits for your plan'; + expect(isBanResponse(msg)).toBe(false); + expect(isBanResponse(msg, 'gemini')).toBe(false); + expect(isBanResponse(msg, 'claude')).toBe(false); + }); + + it('returns false for a benign error message', () => { + expect(isBanResponse('rate limit exceeded')).toBe(false); + expect(isBanResponse('network timeout')).toBe(false); + expect(isBanResponse('')).toBe(false); + }); +}); + +// ── handleBanDetection ban copy ──────────────────────────────────────────────── + +describe('handleBanDetection ban copy', () => { + const stderrLines: string[] = []; + let writeSpy: ReturnType; + let consoleSpy: ReturnType; + let tempHome: string; + let originalCcsHome: string | undefined; + + beforeEach(() => { + originalCcsHome = process.env.CCS_HOME; + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-ban-copy-')); + process.env.CCS_HOME = tempHome; + }); + + const setup = () => { + stderrLines.length = 0; + writeSpy = spyOn(process.stderr, 'write').mockImplementation( + (chunk: string | Uint8Array): boolean => { + stderrLines.push(typeof chunk === 'string' ? chunk : ''); + return true; + } + ); + // Also capture console.error calls + consoleSpy = spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + stderrLines.push(String(args[0] ?? '')); + }); + }; + + afterEach(() => { + writeSpy?.mockRestore(); + consoleSpy?.mockRestore(); + process.env.CCS_HOME = originalCcsHome; + fs.rmSync(tempHome, { recursive: true, force: true }); + }); + + it('uses "Anthropic" actor copy for the claude provider', () => { + setup(); + handleBanDetection('claude', 'test@example.com', 'account has been disabled'); + const output = stderrLines.join(' '); + expect(output).toContain('Anthropic'); + expect(output).not.toContain('Google'); + }); + + it('uses "Google" actor copy for the gemini provider', () => { + setup(); + handleBanDetection('gemini', 'test@example.com', 'account has been disabled'); + const output = stderrLines.join(' '); + expect(output).toContain('Google'); + expect(output).not.toContain('Anthropic'); + }); + + it('uses "Google" actor copy for the agy provider', () => { + setup(); + handleBanDetection('agy', 'test@example.com', 'account has been banned'); + const output = stderrLines.join(' '); + expect(output).toContain('Google'); + }); + + it('returns false for a non-ban error (no actor copy emitted)', () => { + setup(); + const result = handleBanDetection('claude', 'test@example.com', 'rate limit exceeded'); + expect(result).toBe(false); + // No ban message written + expect(stderrLines.join(' ')).not.toContain('Anthropic'); + }); +}); diff --git a/src/cliproxy/__tests__/claude-shadow-warning.test.ts b/src/cliproxy/__tests__/claude-shadow-warning.test.ts new file mode 100644 index 00000000..c8087fa2 --- /dev/null +++ b/src/cliproxy/__tests__/claude-shadow-warning.test.ts @@ -0,0 +1,214 @@ +/** + * Tests for claude-shadow-warning.ts + * + * Gap 2: shadow warning — emitted once when a user profile named 'claude' or + * 'anthropic' is present; not repeated; not emitted without a collision. + * Gap 4: routing notice — emitted once on first claude provider launch. + * + * These tests use a temp CCS_HOME and a non-TTY stderr mock to confirm + * write behaviour without relying on an interactive terminal. + */ + +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'; + +// Module under test — imported after CCS_HOME is set up in beforeEach via dynamic re-import. +// Because Bun caches modules, we test the exported functions in isolation by +// controlling the file-system state they depend on. +import { maybeWarnClaudeShadow, maybeShowClaudeRoutingNotice } from '../claude-shadow-warning'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function writeLegacyConfig(ccsDir: string, profiles: Record): void { + const configPath = path.join(ccsDir, 'config.json'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync(configPath, JSON.stringify({ profiles }), 'utf-8'); +} + +function markerDir(ccsDir: string): string { + return path.join(ccsDir, 'cliproxy'); +} + +function shadowMarker(ccsDir: string): string { + return path.join(markerDir(ccsDir), '.claude-shadow-warned'); +} + +function routingMarker(ccsDir: string): string { + return path.join(markerDir(ccsDir), '.claude-routing-noticed'); +} + +// ── Test setup ──────────────────────────────────────────────────────────────── + +let tempHome: string; +let originalCcsHome: string | undefined; +let stderrLines: string[]; +let stderrSpy: ReturnType; +let originalIsTTYDescriptor: PropertyDescriptor | undefined; + +beforeEach(() => { + originalCcsHome = process.env.CCS_HOME; + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-shadow-warn-')); + process.env.CCS_HOME = tempHome; + + // Collect stderr writes without printing them + stderrLines = []; + stderrSpy = spyOn(process.stderr, 'write').mockImplementation( + (chunk: string | Uint8Array): boolean => { + stderrLines.push(typeof chunk === 'string' ? chunk : ''); + return true; + } + ); + + // Save exact isTTY property descriptor so afterEach can restore it precisely + originalIsTTYDescriptor = Object.getOwnPropertyDescriptor(process.stderr, 'isTTY'); + + // Simulate a TTY so the TTY guard does not short-circuit + Object.defineProperty(process.stderr, 'isTTY', { value: true, configurable: true }); +}); + +afterEach(() => { + process.env.CCS_HOME = originalCcsHome; + fs.rmSync(tempHome, { recursive: true, force: true }); + stderrSpy.mockRestore(); + // Restore isTTY to exactly what it was before the test + if (originalIsTTYDescriptor !== undefined) { + Object.defineProperty(process.stderr, 'isTTY', originalIsTTYDescriptor); + } else { + delete (process.stderr as { isTTY?: boolean }).isTTY; + } +}); + +// ── Shadow warning (Gap 2) ──────────────────────────────────────────────────── + +describe('maybeWarnClaudeShadow', () => { + it('does not emit a warning when no shadowed profiles exist', () => { + const ccsDir = path.join(tempHome, '.ccs'); + writeLegacyConfig(ccsDir, { myprofile: '/some/path' }); + + maybeWarnClaudeShadow(); + + expect(stderrLines.join('')).not.toContain('shadowed'); + expect(stderrLines.join('')).not.toContain('claude'); + }); + + it('emits a warning when a profile named "claude" exists', () => { + const ccsDir = path.join(tempHome, '.ccs'); + writeLegacyConfig(ccsDir, { claude: '/path/to/claude.settings.json' }); + + maybeWarnClaudeShadow(); + + const output = stderrLines.join(''); + expect(output).toContain('claude'); + expect(output).toContain('shadowed'); + // Ensure warn() prefix is not doubled ([!] [!] ...) + expect(output).not.toMatch(/\[!\]\s*\[!\]/); + }); + + it('emits a warning when a profile named "anthropic" exists', () => { + const ccsDir = path.join(tempHome, '.ccs'); + writeLegacyConfig(ccsDir, { anthropic: '/path/to/anthropic.settings.json' }); + + maybeWarnClaudeShadow(); + + const output = stderrLines.join(''); + expect(output).toContain('anthropic'); + expect(output).toContain('shadowed'); + // Ensure warn() prefix is not doubled + expect(output).not.toMatch(/\[!\]\s*\[!\]/); + }); + + it('creates the dismissal marker after warning', () => { + const ccsDir = path.join(tempHome, '.ccs'); + writeLegacyConfig(ccsDir, { claude: '/path/to/settings.json' }); + + maybeWarnClaudeShadow(); + + expect(fs.existsSync(shadowMarker(ccsDir))).toBe(true); + }); + + it('does not repeat the warning when the marker already exists', () => { + const ccsDir = path.join(tempHome, '.ccs'); + writeLegacyConfig(ccsDir, { claude: '/path/to/settings.json' }); + // Pre-create marker + const mDir = markerDir(ccsDir); + fs.mkdirSync(mDir, { recursive: true }); + fs.writeFileSync(shadowMarker(ccsDir), 'already-shown'); + + maybeWarnClaudeShadow(); + + const output = stderrLines.join(''); + expect(output).not.toContain('shadowed'); + }); + + it('does not warn when stderr is not a TTY', () => { + Object.defineProperty(process.stderr, 'isTTY', { value: false, configurable: true }); + const ccsDir = path.join(tempHome, '.ccs'); + writeLegacyConfig(ccsDir, { claude: '/path/to/settings.json' }); + + maybeWarnClaudeShadow(); + + expect(stderrLines.join('')).not.toContain('shadowed'); + }); + + it('emits a warning when an account profile named "claude" exists in profiles.json (legacy mode)', () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + // Write an empty settings config (no settings profiles named claude) + writeLegacyConfig(ccsDir, { myprofile: '/some/path' }); + // Write profiles.json with an account profile named 'claude' + const profilesData = { + version: '2.0.0', + profiles: { claude: { type: 'account', created: new Date().toISOString(), last_used: null } }, + default: null, + }; + fs.writeFileSync(path.join(ccsDir, 'profiles.json'), JSON.stringify(profilesData), 'utf-8'); + + maybeWarnClaudeShadow(); + + const output = stderrLines.join(''); + expect(output).toContain('claude'); + expect(output).toContain('shadowed'); + }); +}); + +// ── Routing notice (Gap 4) ──────────────────────────────────────────────────── + +describe('maybeShowClaudeRoutingNotice', () => { + it('emits routing notice on first call', () => { + maybeShowClaudeRoutingNotice(); + + const output = stderrLines.join(''); + expect(output).toContain('CLIProxy'); + // Ensure info() prefix is not doubled ([i] [i] ...) + expect(output).not.toMatch(/\[i\]\s*\[i\]/); + }); + + it('creates the routing notice marker after first call', () => { + const ccsDir = path.join(tempHome, '.ccs'); + maybeShowClaudeRoutingNotice(); + + expect(fs.existsSync(routingMarker(ccsDir))).toBe(true); + }); + + it('does not repeat the routing notice when the marker already exists', () => { + const ccsDir = path.join(tempHome, '.ccs'); + const mDir = markerDir(ccsDir); + fs.mkdirSync(mDir, { recursive: true }); + fs.writeFileSync(routingMarker(ccsDir), 'already-shown'); + + maybeShowClaudeRoutingNotice(); + + // No output at all + expect(stderrLines.join('')).not.toContain('CLIProxy'); + }); + + it('does not emit when stderr is not a TTY', () => { + Object.defineProperty(process.stderr, 'isTTY', { value: false, configurable: true }); + + maybeShowClaudeRoutingNotice(); + + expect(stderrLines.join('')).not.toContain('CLIProxy'); + }); +}); diff --git a/src/cliproxy/__tests__/pool-onboarding-phase5.test.ts b/src/cliproxy/__tests__/pool-onboarding-phase5.test.ts new file mode 100644 index 00000000..b0001ee9 --- /dev/null +++ b/src/cliproxy/__tests__/pool-onboarding-phase5.test.ts @@ -0,0 +1,505 @@ +/** + * Phase 5: Pool Onboarding Hint - Test Suite + * + * Covers: + * 1. Non-TTY: hint is silent + * 2. Pool already enabled: hint is silent + * 3. Dismissed flag: hint is silent + * 4. Fewer than 2 profiles: hint is silent + * 5. Exactly 2 profiles, TTY, not dismissed, pool off: hint prints (single [i] marker) + * 6. After dismiss, second call is silent + * 7. Pre-computed count avoids double registry read + * 8. isOnboardingHintDismissed / dismissOnboardingHint roundtrip + * 9. countNativeClaudeProfiles returns only account-type profiles + * 10. Doctor site (simulation): maybeShowPoolOnboardingHint callable after report phase + * 10b. Doctor wiring (static): doctor.ts imports and calls the hint symbol + * 11. Create-command suggestion: hint fires with count=2 when 1 existing profile present + * 12. Legacy-only install: dismissOnboardingHint does not create config.yaml + * 13. Hint copy includes 'ccs claude' entry point and quota trade-off language + * 14. Legacy-only install: hasUnifiedConfig() returns false, enforcing call-site gate + * 15. Malformed config (with count): hint swallows the error, never throws, prints nothing + * 16. Malformed config (no count, doctor path): hint never throws + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'; + +// Non-cache-busted facade import so mutateConfig targets the SHARED singleton +import { invalidateConfigCache as invalidateSharedConfigCache } from '../../config/config-loader-facade'; + +// ── Helpers ───────────────────────────────────────────────────────────────── + +function createTestHome(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-pool-onboarding-test-')); + const ccsDir = path.join(dir, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync(path.join(ccsDir, 'config.yaml'), 'version: 1\n', 'utf8'); + return dir; +} + +/** Create a legacy-only home: has profiles.json but NO config.yaml */ +function createLegacyTestHome(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-pool-onboarding-legacy-')); + const ccsDir = path.join(dir, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + // Intentionally no config.yaml - legacy install + return dir; +} + +/** Write a minimal profiles.json with N account-type entries */ +function writeProfiles(ccsDir: string, names: string[]): void { + const profiles: Record = {}; + for (const n of names) { + profiles[n] = { type: 'account', created: new Date().toISOString(), last_used: null }; + } + fs.writeFileSync( + path.join(ccsDir, 'profiles.json'), + JSON.stringify({ version: '2.0.0', profiles, default: null }, null, 2), + 'utf8' + ); +} + +/** Write a minimal profiles.json with mixed types */ +function writeProfilesMixed(ccsDir: string): void { + const profiles: Record = { + acct1: { type: 'account', created: new Date().toISOString(), last_used: null }, + settings1: { type: 'settings', created: new Date().toISOString(), last_used: null }, + }; + fs.writeFileSync( + path.join(ccsDir, 'profiles.json'), + JSON.stringify({ version: '2.0.0', profiles, default: null }, null, 2), + 'utf8' + ); +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +describe('Phase 5: Pool Onboarding Hint', () => { + let tempHome: string; + let originalCcsHome: string | undefined; + let originalIsTTY: boolean | undefined; + + beforeEach(() => { + tempHome = createTestHome(); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + // Default: treat stdout as TTY for hint to fire + originalIsTTY = process.stdout.isTTY; + Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true }); + }); + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + Object.defineProperty(process.stdout, 'isTTY', { + value: originalIsTTY, + configurable: true, + }); + fs.rmSync(tempHome, { recursive: true, force: true }); + invalidateSharedConfigCache(); + }); + + // ── 1. Non-TTY ────────────────────────────────────────────────────────── + it('returns skipReason non-tty when stdout.isTTY is false', async () => { + Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }); + const ccsDir = path.join(tempHome, '.ccs'); + writeProfiles(ccsDir, ['work', 'personal']); + const { maybeShowPoolOnboardingHint } = await import( + `../routing/pool-onboarding-hint?p5nontty=${Date.now()}` + ); + const result = maybeShowPoolOnboardingHint(); + expect(result.printed).toBe(false); + expect(result.skipReason).toBe('non-tty'); + }); + + // ── 2. Pool already enabled ───────────────────────────────────────────── + it('returns skipReason pool-already-enabled when pool routing is on', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + writeProfiles(ccsDir, ['work', 'personal']); + // Enable pool routing first + const { mutateConfig } = await import(`../../config/config-loader-facade?p5pool=${Date.now()}`); + mutateConfig((cfg: { cliproxy?: { pool_routing?: Record } }) => { + if (!cfg.cliproxy) return; + cfg.cliproxy.pool_routing = { ...(cfg.cliproxy.pool_routing ?? {}), enabled: true }; + }); + invalidateSharedConfigCache(); + + const { maybeShowPoolOnboardingHint } = await import( + `../routing/pool-onboarding-hint?p5pool2=${Date.now()}` + ); + const result = maybeShowPoolOnboardingHint(); + expect(result.printed).toBe(false); + expect(result.skipReason).toBe('pool-already-enabled'); + }); + + // ── 3. Dismissed ──────────────────────────────────────────────────────── + it('returns skipReason dismissed when onboarding_hint_dismissed is true', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + writeProfiles(ccsDir, ['work', 'personal']); + + const { mutateConfig } = await import(`../../config/config-loader-facade?p5dis=${Date.now()}`); + mutateConfig((cfg: { cliproxy?: { pool_routing?: Record } }) => { + if (!cfg.cliproxy) return; + cfg.cliproxy.pool_routing = { + ...(cfg.cliproxy.pool_routing ?? {}), + onboarding_hint_dismissed: true, + }; + }); + invalidateSharedConfigCache(); + + const { maybeShowPoolOnboardingHint } = await import( + `../routing/pool-onboarding-hint?p5dis2=${Date.now()}` + ); + const result = maybeShowPoolOnboardingHint(); + expect(result.printed).toBe(false); + expect(result.skipReason).toBe('dismissed'); + }); + + // ── 4. Fewer than 2 profiles ──────────────────────────────────────────── + it('returns skipReason fewer-than-2-profiles when only 1 profile exists', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + writeProfiles(ccsDir, ['work']); + const { maybeShowPoolOnboardingHint } = await import( + `../routing/pool-onboarding-hint?p5few=${Date.now()}` + ); + const result = maybeShowPoolOnboardingHint(); + expect(result.printed).toBe(false); + expect(result.skipReason).toBe('fewer-than-2-profiles'); + }); + + it('returns skipReason fewer-than-2-profiles when no profiles exist', async () => { + const { maybeShowPoolOnboardingHint } = await import( + `../routing/pool-onboarding-hint?p5zero=${Date.now()}` + ); + const result = maybeShowPoolOnboardingHint(); + expect(result.printed).toBe(false); + expect(result.skipReason).toBe('fewer-than-2-profiles'); + }); + + // ── 5. Hint prints when 2 profiles, TTY, not dismissed, pool off ──────── + it('prints hint and returns printed=true when conditions are met', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + writeProfiles(ccsDir, ['work', 'personal']); + + const consoleSpy = spyOn(console, 'log').mockImplementation(() => {}); + try { + const { maybeShowPoolOnboardingHint } = await import( + `../routing/pool-onboarding-hint?p5print=${Date.now()}` + ); + const result = maybeShowPoolOnboardingHint(); + expect(result.printed).toBe(true); + expect(result.skipReason).toBeUndefined(); + // Verify output: exactly one [i] marker (no double-prefix from info() + literal) + expect(consoleSpy.mock.calls.length).toBeGreaterThan(0); + const allOutput = consoleSpy.mock.calls.map((c) => String(c[0])).join('\n'); + expect(allOutput).toContain('2 Claude profiles'); + const iMarkerCount = (allOutput.match(/\[i\]/g) ?? []).length; + expect(iMarkerCount).toBe(1); + } finally { + consoleSpy.mockRestore(); + } + }); + + // ── 6. After dismiss, second call is silent ───────────────────────────── + it('is silent on the second call because hint auto-dismisses after first print', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + writeProfiles(ccsDir, ['work', 'personal']); + + // Use the same module instance for both calls so the dismissed flag persists + const { maybeShowPoolOnboardingHint } = await import( + `../routing/pool-onboarding-hint?p5once=${Date.now()}` + ); + + const consoleSpy = spyOn(console, 'log').mockImplementation(() => {}); + try { + const first = maybeShowPoolOnboardingHint(); + expect(first.printed).toBe(true); + + invalidateSharedConfigCache(); + + const second = maybeShowPoolOnboardingHint(); + expect(second.printed).toBe(false); + expect(second.skipReason).toBe('dismissed'); + } finally { + consoleSpy.mockRestore(); + } + }); + + // ── 7. Pre-computed count skips internal registry read ───────────────── + it('uses the pre-computed count when provided', async () => { + // No profiles.json — but we pass count=3 directly so it should still print + const consoleSpy = spyOn(console, 'log').mockImplementation(() => {}); + try { + const { maybeShowPoolOnboardingHint } = await import( + `../routing/pool-onboarding-hint?p5precount=${Date.now()}` + ); + const result = maybeShowPoolOnboardingHint(3); + expect(result.printed).toBe(true); + } finally { + consoleSpy.mockRestore(); + } + }); + + // ── 8. isOnboardingHintDismissed / dismissOnboardingHint roundtrip ───── + it('dismissOnboardingHint persists and isOnboardingHintDismissed reads it', async () => { + const { isOnboardingHintDismissed, dismissOnboardingHint } = await import( + `../routing/pool-onboarding-hint?p5dismiss=${Date.now()}` + ); + expect(isOnboardingHintDismissed()).toBe(false); + dismissOnboardingHint(); + invalidateSharedConfigCache(); + expect(isOnboardingHintDismissed()).toBe(true); + }); + + // ── 9. countNativeClaudeProfiles counts only account-type profiles ─────── + it('countNativeClaudeProfiles ignores non-account type profiles', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + writeProfilesMixed(ccsDir); // 1 account + 1 settings + const { countNativeClaudeProfiles } = await import( + `../routing/pool-onboarding-hint?p5count=${Date.now()}` + ); + expect(countNativeClaudeProfiles()).toBe(1); + }); + + it('countNativeClaudeProfiles returns 0 when no profiles.json exists', async () => { + const { countNativeClaudeProfiles } = await import( + `../routing/pool-onboarding-hint?p5count2=${Date.now()}` + ); + expect(countNativeClaudeProfiles()).toBe(0); + }); + + // ── 10. Doctor site: maybeShowPoolOnboardingHint is exported and callable ─ + it('maybeShowPoolOnboardingHint is callable after the doctor report phase', async () => { + // NOTE: This is a SIMULATION of the doctor post-checks call site, not the + // real Doctor pipeline. The hint fires from Doctor's private displayResults() + // method, which has no cheap standalone entry point - exercising it directly + // would require constructing Doctor and running the full async check pipeline + // (system / OAuth / CLIProxy / Docker), which is heavy and environment-fragile. + // Instead we call the exact exported symbol the doctor imports + // (maybeShowPoolOnboardingHint, see doctor.ts import) under the same + // preconditions: 2 profiles, TTY, not dismissed. The wiring itself - that + // doctor.ts imports this symbol - is asserted in the companion test below. + const ccsDir = path.join(tempHome, '.ccs'); + writeProfiles(ccsDir, ['work', 'personal']); + + const consoleSpy = spyOn(console, 'log').mockImplementation(() => {}); + try { + const { maybeShowPoolOnboardingHint } = await import( + `../routing/pool-onboarding-hint?p5doc=${Date.now()}` + ); + // doctor calls maybeShowPoolOnboardingHint() with no pre-computed count + const result = maybeShowPoolOnboardingHint(); + expect(result.printed).toBe(true); + const allOutput = consoleSpy.mock.calls.map((c) => String(c[0])).join('\n'); + expect(allOutput).toContain('2 Claude profiles'); + } finally { + consoleSpy.mockRestore(); + } + }); + + // ── 10b. Doctor wiring: doctor.ts imports and calls the hint symbol ─────── + it('doctor.ts wires maybeShowPoolOnboardingHint as the doctor hint site', () => { + // Cheap static wiring assertion: confirms the real doctor call site imports + // and invokes the same symbol the simulation above exercises, without + // executing the heavy Doctor pipeline. If the doctor stops importing or + // calling the hint, this fails - catching a broken hint site that a pure + // simulation test could not. + const doctorSrc = fs.readFileSync( + path.join(__dirname, '..', '..', 'management', 'doctor.ts'), + 'utf8' + ); + expect(doctorSrc).toContain( + "import { maybeShowPoolOnboardingHint } from '../cliproxy/routing/pool-onboarding-hint'" + ); + expect(doctorSrc).toContain('maybeShowPoolOnboardingHint();'); + }); + + // ── 11. Create-command suggestion: hint fires with post-create count ────── + it('hint fires with count 2 after a successful 2nd-profile create (post-create call site)', async () => { + // NOTE: SIMULATION of the create-command call site, not the real command. + // create-command shows the hint only AFTER profile creation succeeds (a + // pre-create hint would burn the once-per-install dismissal on a failed + // create). At that point the registry already contains the new profile, + // so the call is maybeShowPoolOnboardingHint(countNativeClaudeProfiles()). + const ccsDir = path.join(tempHome, '.ccs'); + writeProfiles(ccsDir, ['work', 'personal']); // post-create state: 2 profiles + + const consoleSpy = spyOn(console, 'log').mockImplementation(() => {}); + try { + const { maybeShowPoolOnboardingHint, countNativeClaudeProfiles } = await import( + `../routing/pool-onboarding-hint?p5create=${Date.now()}` + ); + const count = countNativeClaudeProfiles(); + expect(count).toBe(2); + const result = maybeShowPoolOnboardingHint(count); + expect(result.printed).toBe(true); + const allOutput = consoleSpy.mock.calls.map((c) => String(c[0])).join('\n'); + expect(allOutput).toContain('2 Claude profiles'); + } finally { + consoleSpy.mockRestore(); + } + }); + + // ── 12. Legacy-only install: dismissal does not create config.yaml ──────── + it('dismissOnboardingHint does not create config.yaml for legacy profiles.json-only installs', async () => { + // Override tempHome with a legacy-only home (no config.yaml) + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + const legacyHome = createLegacyTestHome(); + process.env.CCS_HOME = legacyHome; + invalidateSharedConfigCache(); + + try { + const ccsDir = path.join(legacyHome, '.ccs'); + writeProfiles(ccsDir, ['work', 'personal']); + const configYamlPath = path.join(ccsDir, 'config.yaml'); + + const { dismissOnboardingHint, isOnboardingHintDismissed } = await import( + `../routing/pool-onboarding-hint?p5legacy=${Date.now()}` + ); + + // Sanity: config.yaml does not exist yet + expect(fs.existsSync(configYamlPath)).toBe(false); + + // isOnboardingHintDismissed returns false (reads empty config, no persist) + expect(isOnboardingHintDismissed()).toBe(false); + + // dismissOnboardingHint must NOT create config.yaml + dismissOnboardingHint(); + expect(fs.existsSync(configYamlPath)).toBe(false); + } finally { + fs.rmSync(legacyHome, { recursive: true, force: true }); + invalidateSharedConfigCache(); + } + }); + + // ── 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']); + + const consoleSpy = spyOn(console, 'log').mockImplementation(() => {}); + try { + const { maybeShowPoolOnboardingHint } = await import( + `../routing/pool-onboarding-hint?p5copy=${Date.now()}` + ); + const result = maybeShowPoolOnboardingHint(); + expect(result.printed).toBe(true); + const allOutput = consoleSpy.mock.calls.map((c) => String(c[0])).join('\n'); + expect(allOutput).toContain('ccs claude'); + // 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(); + } + }); + + // ── 14. Legacy-only install: hasUnifiedConfig() is false at account-flow / create-command sites ── + it('hasUnifiedConfig returns false for legacy profiles.json-only installs (call-site gate)', async () => { + // Verifies that the guard used in account-flow.ts and create-command.ts + // (hasUnifiedConfig()) correctly reports false for a profiles.json-only home, + // meaning those call sites silently skip the hint and legacy users receive + // the hint exclusively from ccs doctor. + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + const legacyHome = createLegacyTestHome(); + process.env.CCS_HOME = legacyHome; + invalidateSharedConfigCache(); + + try { + const ccsDir = path.join(legacyHome, '.ccs'); + writeProfiles(ccsDir, ['work', 'personal']); + + const { hasUnifiedConfig } = await import( + `../../config/config-loader-facade?p5gate=${Date.now()}` + ); + + // The call-site guard must return false - legacy install has no config.yaml + expect(hasUnifiedConfig()).toBe(false); + } finally { + fs.rmSync(legacyHome, { recursive: true, force: true }); + invalidateSharedConfigCache(); + } + }); + + // ── 15. Malformed config: hint never throws and prints nothing ──────────── + it('does not throw or print when config.yaml is malformed (hint must not break launch)', async () => { + // A pre-computed profileCount (passed by the launch / create call sites) + // clears the cheap gates, so the decision reaches the single config load. + // A malformed config.yaml makes loadOrCreateUnifiedConfig throw; the + // try/catch in maybeShowPoolOnboardingHint must swallow it - returning + // printed=false with skipReason 'error' and emitting no hint - so an account + // launch is never broken by a bad config. + const ccsDir = path.join(tempHome, '.ccs'); + // Overwrite the valid config.yaml from createTestHome() with invalid YAML. + fs.writeFileSync(path.join(ccsDir, 'config.yaml'), 'cliproxy: [unclosed\n', 'utf8'); + invalidateSharedConfigCache(); + + const consoleSpy = spyOn(console, 'log').mockImplementation(() => {}); + try { + const { maybeShowPoolOnboardingHint } = await import( + `../routing/pool-onboarding-hint?p5malformed=${Date.now()}` + ); + let result: { printed: boolean; skipReason?: string } | undefined; + // Must not throw. Pass count=2 to bypass the registry (which itself loads + // config) and drive the decision into the explicit config load + try/catch. + expect(() => { + result = maybeShowPoolOnboardingHint(2); + }).not.toThrow(); + expect(result?.printed).toBe(false); + expect(result?.skipReason).toBe('error'); + // No hint line printed. + const hintLines = consoleSpy.mock.calls + .map((c) => String(c[0])) + .filter((s) => s.includes('Claude profiles')); + expect(hintLines.length).toBe(0); + } finally { + consoleSpy.mockRestore(); + } + }); + + // ── 16. Malformed config via doctor path (no count): still no throw ─────── + it('does not throw when config is malformed and no profileCount is passed (doctor path)', async () => { + // The doctor site calls maybeShowPoolOnboardingHint() with no count. With a + // malformed config, countNativeClaudeProfiles() swallows the registry error + // and returns 0, short-circuiting at fewer-than-2-profiles. Either way the + // call must never throw - this guards the doctor call site specifically. + const ccsDir = path.join(tempHome, '.ccs'); + writeProfiles(ccsDir, ['work', 'personal']); + fs.writeFileSync(path.join(ccsDir, 'config.yaml'), 'cliproxy: [unclosed\n', 'utf8'); + invalidateSharedConfigCache(); + + const consoleSpy = spyOn(console, 'log').mockImplementation(() => {}); + try { + const { maybeShowPoolOnboardingHint } = await import( + `../routing/pool-onboarding-hint?p5malformed2=${Date.now()}` + ); + let result: { printed: boolean } | undefined; + expect(() => { + result = maybeShowPoolOnboardingHint(); + }).not.toThrow(); + expect(result?.printed).toBe(false); + const hintLines = consoleSpy.mock.calls + .map((c) => String(c[0])) + .filter((s) => s.includes('Claude profiles')); + expect(hintLines.length).toBe(0); + } finally { + consoleSpy.mockRestore(); + } + }); +}); diff --git a/src/cliproxy/__tests__/pool-routing-phase3.test.ts b/src/cliproxy/__tests__/pool-routing-phase3.test.ts new file mode 100644 index 00000000..da79ad16 --- /dev/null +++ b/src/cliproxy/__tests__/pool-routing-phase3.test.ts @@ -0,0 +1,1585 @@ +/** + * Phase 3: Pool Routing Defaults and Safety Rails — Test Suite + * + * Covers: + * 1. Schema keys: pool_routing.enabled, max_retry_credentials, prompt_dismissed + * 2. Generator snapshot: non-pool config is content-identical (cooling=true, RR, no affinity) + * 3. Generator snapshot: pool config block (cooling=false, fill-first, affinity, max-retry) + * 4. enablePoolRouting / disablePoolRouting lifecycle + * 5. Explicit-setting detection (preserve user routing values) + * 6. disablePoolRouting rollback restores cooling-true (prevent single-account blackout) + * 7. Opt-in prompt gating: non-verified providers skip + * 8. Opt-in prompt gating: dismissed flag prevents re-prompt + * 9. Opt-in prompt gating: remote target gets hint-not-prompt (skipReason=remote-target) + * 10. Opt-in prompt gating: not-at-transition skip (accountCountBefore != 1) + * 11. Mixed-state: claude pool ON + multi-account agy (both providers in disclosure) + * 12. Cross-lane overlap guard: same email in CLIProxy + native Claude profile + * 13. Cross-lane overlap guard: different email = no warning + * 14. Cross-lane overlap guard: native Claude not logged in = silent + * 15. Safety invariants regression: disablePoolRouting always writes disable-cooling: true + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'; + +// Static imports for modules that need spyOn to work across the same module instance. +// Dynamic cache-bust imports create new module instances, making spyOn ineffective +// for testing behaviour in modules that import from the same source. +import * as claudeDetector from '../../utils/claude-detector'; +import { checkCrossLaneEmailOverlap } from '../accounts/account-safety-cross-lane'; +import * as promptModule from '../../utils/prompt'; +import * as poolOptInModule from '../routing/pool-opt-in-prompt'; +// Non-cache-busted facade import: invalidateConfigCache targets the SHARED singleton +// that routing-strategy (also non-cache-busted at runtime) reads from. +import { invalidateConfigCache as invalidateSharedConfigCache } from '../../config/config-loader-facade'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +function createTestHome(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-pool-routing-test-')); + const ccsDir = path.join(dir, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + // Minimal config.yaml — loadOrCreateUnifiedConfig will expand it on first read + fs.writeFileSync(path.join(ccsDir, 'config.yaml'), 'version: 1\n', 'utf8'); + return dir; +} + +// ── Tests ────────────────────────────────────────────────────────────────────── + +describe('Phase 3: Pool Routing — schema keys and generator snapshots', () => { + let tempHome: string; + let originalCcsHome: string | undefined; + + beforeEach(() => { + tempHome = createTestHome(); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + }); + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + fs.rmSync(tempHome, { recursive: true, force: true }); + }); + + // ── 1. Schema keys ──────────────────────────────────────────────────────── + describe('CLIProxyPoolRoutingConfig schema', () => { + it('pool_routing.enabled defaults to absent (falsy) for a new user config', async () => { + const { loadOrCreateUnifiedConfig } = await import( + `../../config/config-loader-facade?p3schema=${Date.now()}` + ); + const cfg = loadOrCreateUnifiedConfig(); + expect(cfg.cliproxy?.pool_routing?.enabled).toBeUndefined(); + }); + + it('pool_routing.prompt_dismissed defaults to absent', async () => { + const { loadOrCreateUnifiedConfig } = await import( + `../../config/config-loader-facade?p3dismissed=${Date.now()}` + ); + const cfg = loadOrCreateUnifiedConfig(); + expect(cfg.cliproxy?.pool_routing?.prompt_dismissed).toBeUndefined(); + }); + + it('pool_routing.max_retry_credentials can be set and read back', async () => { + const { loadOrCreateUnifiedConfig, mutateConfig, invalidateConfigCache } = await import( + `../../config/config-loader-facade?p3retry=${Date.now()}` + ); + mutateConfig((cfg: { cliproxy?: { pool_routing?: Record } }) => { + cfg.cliproxy = cfg.cliproxy ?? {}; + cfg.cliproxy.pool_routing = { enabled: true, max_retry_credentials: 5 }; + }); + invalidateConfigCache(); + const cfg = loadOrCreateUnifiedConfig(); + expect(cfg.cliproxy?.pool_routing?.max_retry_credentials).toBe(5); + }); + }); + + // ── 2. Generator snapshot — non-pool ───────────────────────────────────── + describe('generateUnifiedConfigContent — non-pool snapshot', () => { + it('emits disable-cooling: true when pool routing is disabled', async () => { + // Ensure pool_routing is absent + const { invalidateConfigCache } = await import( + `../../config/config-loader-facade?p3gennp1=${Date.now()}` + ); + invalidateConfigCache(); + + const { regenerateConfig, CLIPROXY_CONFIG_VERSION } = await import( + `../config/generator?p3gennp1=${Date.now()}` + ); + 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 }); + + regenerateConfig(8317, { configPath, authDir }); + + const content = fs.readFileSync(configPath, 'utf-8'); + expect(content).toContain('disable-cooling: true'); + expect(content).toContain(`CCS v${CLIPROXY_CONFIG_VERSION}`); + }); + + it('emits round-robin routing strategy when pool routing is disabled', async () => { + const { regenerateConfig } = await import(`../config/generator?p3gennp2=${Date.now()}`); + 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 }); + + regenerateConfig(8317, { configPath, authDir }); + + const content = fs.readFileSync(configPath, 'utf-8'); + expect(content).toContain('strategy: round-robin'); + expect(content).toContain('session-affinity: false'); + // max-retry-credentials must NOT be present in non-pool config + expect(content).not.toContain('max-retry-credentials:'); + }); + }); + + // ── 3. Generator snapshot — pool ───────────────────────────────────────── + describe('generateUnifiedConfigContent — pool snapshot', () => { + it('emits disable-cooling: false when pool routing is enabled', async () => { + const { mutateConfig, invalidateConfigCache } = await import( + `../../config/config-loader-facade?p3genp1=${Date.now()}` + ); + mutateConfig((cfg: { cliproxy?: { pool_routing?: Record } }) => { + cfg.cliproxy = cfg.cliproxy ?? {}; + cfg.cliproxy.pool_routing = { enabled: true }; + }); + invalidateConfigCache(); + + const { regenerateConfig } = await import(`../config/generator?p3genp1=${Date.now()}`); + 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 }); + + regenerateConfig(8317, { configPath, authDir }); + + const content = fs.readFileSync(configPath, 'utf-8'); + expect(content).toContain('disable-cooling: false'); + }); + + it('emits fill-first strategy and session-affinity: true for pool users', async () => { + const { mutateConfig, invalidateConfigCache } = await import( + `../../config/config-loader-facade?p3genp2=${Date.now()}` + ); + mutateConfig((cfg: { cliproxy?: { pool_routing?: Record } }) => { + cfg.cliproxy = cfg.cliproxy ?? {}; + cfg.cliproxy.pool_routing = { enabled: true }; + }); + invalidateConfigCache(); + + const { regenerateConfig } = await import(`../config/generator?p3genp2=${Date.now()}`); + 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 }); + + regenerateConfig(8317, { configPath, authDir }); + + const content = fs.readFileSync(configPath, 'utf-8'); + expect(content).toContain('strategy: fill-first'); + expect(content).toContain('session-affinity: true'); + expect(content).toContain('session-affinity-ttl: "1h"'); + expect(content).toContain('max-retry-credentials: 3'); + }); + + it('pool config does NOT emit round-robin or disable-cooling: true', async () => { + const { mutateConfig, invalidateConfigCache } = await import( + `../../config/config-loader-facade?p3genp3=${Date.now()}` + ); + mutateConfig((cfg: { cliproxy?: { pool_routing?: Record } }) => { + cfg.cliproxy = cfg.cliproxy ?? {}; + cfg.cliproxy.pool_routing = { enabled: true }; + }); + invalidateConfigCache(); + + const { regenerateConfig } = await import(`../config/generator?p3genp3=${Date.now()}`); + 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 }); + + regenerateConfig(8317, { configPath, authDir }); + + const content = fs.readFileSync(configPath, 'utf-8'); + expect(content).not.toContain('disable-cooling: true'); + expect(content).not.toContain('strategy: round-robin'); + }); + }); +}); + +// ── enablePoolRouting / disablePoolRouting ───────────────────────────────────── + +describe('Phase 3: enablePoolRouting and disablePoolRouting', () => { + let tempHome: string; + let originalCcsHome: string | undefined; + + beforeEach(() => { + tempHome = createTestHome(); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + // Invalidate the shared config singleton so no stale data bleeds across tests. + invalidateSharedConfigCache(); + }); + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + fs.rmSync(tempHome, { recursive: true, force: true }); + }); + + // ── 4. Lifecycle ───────────────────────────────────────────────────────── + it('enablePoolRouting sets pool_routing.enabled=true and regenerates config', async () => { + const { enablePoolRouting } = await import( + `../routing/routing-strategy?p3enable1=${Date.now()}` + ); + 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 }); + + const result = enablePoolRouting(8317, { configPath, authDir }); + + expect(result.changed).toBe(true); + const content = fs.readFileSync(configPath, 'utf-8'); + expect(content).toContain('disable-cooling: false'); + expect(content).toContain('strategy: fill-first'); + expect(content).toContain('max-retry-credentials: 3'); + }); + + it('enablePoolRouting is idempotent (second call returns changed=false)', async () => { + const { enablePoolRouting } = await import( + `../routing/routing-strategy?p3enable2=${Date.now()}` + ); + 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 }); + + enablePoolRouting(8317, { configPath, authDir }); + const second = enablePoolRouting(8317, { configPath, authDir }); + + expect(second.changed).toBe(false); + }); + + it('disablePoolRouting restores disable-cooling: true — invariant for single-account safety', async () => { + const { enablePoolRouting, disablePoolRouting } = await import( + `../routing/routing-strategy?p3disable1=${Date.now()}` + ); + 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 }); + + enablePoolRouting(8317, { configPath, authDir }); + const disableResult = disablePoolRouting(8317, { configPath, authDir }); + + expect(disableResult.changed).toBe(true); + const content = fs.readFileSync(configPath, 'utf-8'); + // CRITICAL: disable-cooling must be true after rollback — single-account blackout prevention + expect(content).toContain('disable-cooling: true'); + expect(content).not.toContain('disable-cooling: false'); + }); + + it('disablePoolRouting restores round-robin strategy and disables session affinity', async () => { + const { enablePoolRouting, disablePoolRouting } = await import( + `../routing/routing-strategy?p3disable2=${Date.now()}` + ); + 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 }); + + enablePoolRouting(8317, { configPath, authDir }); + disablePoolRouting(8317, { configPath, authDir }); + + const content = fs.readFileSync(configPath, 'utf-8'); + expect(content).toContain('strategy: round-robin'); + expect(content).toContain('session-affinity: false'); + expect(content).not.toContain('max-retry-credentials:'); + }); + + it('disablePoolRouting is idempotent (second call returns changed=false)', async () => { + const { disablePoolRouting } = await import( + `../routing/routing-strategy?p3disable3=${Date.now()}` + ); + 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 }); + + const result = disablePoolRouting(8317, { configPath, authDir }); + expect(result.changed).toBe(false); + }); + + // ── 5. Explicit-setting detection ───────────────────────────────────────── + it('hasExplicitRoutingStrategy returns false when raw YAML has no routing.strategy key', async () => { + // Write a raw YAML with a cliproxy section that has no routing key. + // readRawRoutingConfig reads directly from disk (before defaults-merger) so + // an absent routing.strategy key returns false — even though + // loadOrCreateUnifiedConfig would inject strategy:round-robin as a default. + const ccsDir = path.join(tempHome, '.ccs'); + const rawYamlPath = path.join(ccsDir, 'config.yaml'); + // version: 19 so loadOrCreateUnifiedConfig does not auto-upgrade and rewrite the file + const rawYaml = + ['version: 19', 'cliproxy:', ' logging:', ' enabled: false'].join('\n') + '\n'; + fs.writeFileSync(rawYamlPath, rawYaml, 'utf8'); + invalidateSharedConfigCache(); + + const { hasExplicitRoutingStrategy } = await import( + `../routing/routing-strategy?p3explicit1=${Date.now()}` + ); + expect(hasExplicitRoutingStrategy()).toBe(false); + }); + + it('hasExplicitRoutingStrategy returns true for fill-first (user-set)', async () => { + const { loadOrCreateUnifiedConfig, mutateConfig, invalidateConfigCache } = await import( + `../../config/config-loader-facade?p3explicit2=${Date.now()}` + ); + mutateConfig((cfg: { cliproxy?: { routing?: Record } }) => { + cfg.cliproxy = cfg.cliproxy ?? {}; + cfg.cliproxy.routing = { strategy: 'fill-first' }; + }); + invalidateConfigCache(); + + const { hasExplicitRoutingStrategy } = await import( + `../routing/routing-strategy?p3explicit2=${Date.now()}` + ); + expect(hasExplicitRoutingStrategy()).toBe(true); + }); + + it('hasExplicitRoutingStrategy returns false when persisted value equals the injected default (round-robin)', async () => { + // loadOrCreateUnifiedConfig may write strategy:round-robin to disk as a default. + // A stored value equal to the default must NOT be treated as user-customised so + // enablePoolRouting does not falsely report "preserving a custom strategy". + const { mutateConfig, invalidateConfigCache } = await import( + `../../config/config-loader-facade?p3explicit2b=${Date.now()}` + ); + mutateConfig((cfg: { cliproxy?: { routing?: Record } }) => { + cfg.cliproxy = cfg.cliproxy ?? {}; + cfg.cliproxy.routing = { strategy: 'round-robin' }; + }); + invalidateConfigCache(); + + const { hasExplicitRoutingStrategy } = await import( + `../routing/routing-strategy?p3explicit2b=${Date.now()}` + ); + expect(hasExplicitRoutingStrategy()).toBe(false); + }); + + it('hasExplicitSessionAffinity returns false when persisted value equals the injected default (false)', async () => { + // Same rationale as the round-robin test above. + const { mutateConfig, invalidateConfigCache } = await import( + `../../config/config-loader-facade?p3explicit2c=${Date.now()}` + ); + mutateConfig((cfg: { cliproxy?: { routing?: Record } }) => { + cfg.cliproxy = cfg.cliproxy ?? {}; + cfg.cliproxy.routing = { session_affinity: false }; + }); + invalidateConfigCache(); + + const { hasExplicitSessionAffinity } = await import( + `../routing/routing-strategy?p3explicit2c=${Date.now()}` + ); + expect(hasExplicitSessionAffinity()).toBe(false); + }); + + it('hasExplicitSessionAffinity returns true when session_affinity differs from default (true)', async () => { + const { mutateConfig, invalidateConfigCache } = await import( + `../../config/config-loader-facade?p3explicit2d=${Date.now()}` + ); + mutateConfig((cfg: { cliproxy?: { routing?: Record } }) => { + cfg.cliproxy = cfg.cliproxy ?? {}; + cfg.cliproxy.routing = { session_affinity: true }; + }); + invalidateConfigCache(); + + const { hasExplicitSessionAffinity } = await import( + `../routing/routing-strategy?p3explicit2d=${Date.now()}` + ); + expect(hasExplicitSessionAffinity()).toBe(true); + }); + + it('enablePoolRouting: pristine config (defaults written to disk) -> clean [OK] branch, not preserve branch', async () => { + // Simulates a first-load where loadOrCreateUnifiedConfig wrote strategy:round-robin + // and session_affinity:false as injected defaults to disk. enablePoolRouting must + // take the clean "[OK] Pool routing enabled" branch, not the preserve branch. + const { mutateConfig, invalidateConfigCache } = await import( + `../../config/config-loader-facade?p3pristine1=${Date.now()}` + ); + mutateConfig((cfg: { cliproxy?: { routing?: Record } }) => { + cfg.cliproxy = cfg.cliproxy ?? {}; + cfg.cliproxy.routing = { strategy: 'round-robin', session_affinity: false }; + }); + invalidateConfigCache(); + + const { enablePoolRouting } = await import( + `../routing/routing-strategy?p3pristine1=${Date.now()}` + ); + 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 }); + + const result = enablePoolRouting(8317, { configPath, authDir }); + expect(result.preservedExplicitSetting).toBe(false); + expect(result.message).toContain('[OK] Pool routing enabled'); + }); + + it('enablePoolRouting: genuinely customized fill-first -> preserve branch, message omits "custom strategy"', async () => { + // fill-first differs from the round-robin default so it is treated as user-managed. + // The preserve branch message must reference restoring the setting but must NOT say + // "custom strategy" (the review fix requires neutral wording). + const { mutateConfig, invalidateConfigCache } = await import( + `../../config/config-loader-facade?p3genuine1=${Date.now()}` + ); + mutateConfig((cfg: { cliproxy?: { routing?: Record } }) => { + cfg.cliproxy = cfg.cliproxy ?? {}; + cfg.cliproxy.routing = { strategy: 'fill-first' }; + }); + invalidateConfigCache(); + + const { enablePoolRouting } = await import( + `../routing/routing-strategy?p3genuine1=${Date.now()}` + ); + 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 }); + + const result = enablePoolRouting(8317, { configPath, authDir }); + expect(result.preservedExplicitSetting).toBe(true); + // Must NOT claim "custom strategy" — the user's value is preserved but the + // message should not make assumptions about intent + expect(result.message).not.toContain('custom strategy'); + expect(result.message).toContain('[!]'); + }); + + it('enablePoolRouting sets preservedExplicitSetting=true when user had fill-first', async () => { + const { mutateConfig, invalidateConfigCache } = await import( + `../../config/config-loader-facade?p3explicit3=${Date.now()}` + ); + mutateConfig((cfg: { cliproxy?: { routing?: Record } }) => { + cfg.cliproxy = cfg.cliproxy ?? {}; + cfg.cliproxy.routing = { strategy: 'fill-first' }; + }); + invalidateConfigCache(); + + const { enablePoolRouting } = await import( + `../routing/routing-strategy?p3explicit3=${Date.now()}` + ); + 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 }); + + const result = enablePoolRouting(8317, { configPath, authDir }); + expect(result.preservedExplicitSetting).toBe(true); + }); + + // ── 5b. Explicit-setting round-trip survives enable->disable ───────────── + it('explicit strategy and TTL survive enable->disable round-trip (not overwritten)', async () => { + // User sets explicit strategy and TTL before enabling pool routing. + // enablePoolRouting must NOT overwrite these. + // disablePoolRouting must restore the original values (they were never touched). + const ts = Date.now(); + const { mutateConfig, invalidateConfigCache, loadOrCreateUnifiedConfig } = await import( + `../../config/config-loader-facade?p3roundtrip=${ts}` + ); + mutateConfig((cfg: { cliproxy?: { routing?: Record } }) => { + cfg.cliproxy = cfg.cliproxy ?? {}; + cfg.cliproxy.routing = { strategy: 'round-robin', session_affinity_ttl: '2h' }; + }); + invalidateConfigCache(); + + const { enablePoolRouting, disablePoolRouting } = await import( + `../routing/routing-strategy?p3roundtrip=${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 }); + + // Enable: user routing must not be touched + enablePoolRouting(8317, { configPath, authDir }); + invalidateConfigCache(); + const afterEnable = loadOrCreateUnifiedConfig(); + expect(afterEnable.cliproxy?.routing?.strategy).toBe('round-robin'); + expect(afterEnable.cliproxy?.routing?.session_affinity_ttl).toBe('2h'); + + // Disable: user routing must still be intact + disablePoolRouting(8317, { configPath, authDir }); + invalidateConfigCache(); + const afterDisable = loadOrCreateUnifiedConfig(); + expect(afterDisable.cliproxy?.routing?.strategy).toBe('round-robin'); + expect(afterDisable.cliproxy?.routing?.session_affinity_ttl).toBe('2h'); + }); +}); + +// ── Opt-in prompt gating ──────────────────────────────────────────────────── + +describe('Phase 3: maybeOfferPoolRouting gating', () => { + 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 }); + }); + + // ── 7. Non-verified providers skip ──────────────────────────────────────── + it('skips prompt for codex (unverified provider)', async () => { + const { maybeOfferPoolRouting } = await import( + `../routing/pool-opt-in-prompt?p3gate1=${Date.now()}` + ); + const result = await maybeOfferPoolRouting('codex', 1); + expect(result.skipped).toBe(true); + expect(result.skipReason).toContain('codex-unverified'); + }); + + it('skips prompt for gemini (unverified provider)', async () => { + const { maybeOfferPoolRouting } = await import( + `../routing/pool-opt-in-prompt?p3gate2=${Date.now()}` + ); + const result = await maybeOfferPoolRouting('gemini', 1); + expect(result.skipped).toBe(true); + expect(result.skipReason).toContain('gemini-unverified'); + }); + + // ── 8. Dismissed flag ──────────────────────────────────────────────────── + it('skips prompt when prompt_dismissed is true', async () => { + const { mutateConfig, invalidateConfigCache } = await import( + `../../config/config-loader-facade?p3dismissed1=${Date.now()}` + ); + mutateConfig((cfg: { cliproxy?: { pool_routing?: Record } }) => { + cfg.cliproxy = cfg.cliproxy ?? {}; + cfg.cliproxy.pool_routing = { prompt_dismissed: true }; + }); + invalidateConfigCache(); + + const { maybeOfferPoolRouting } = await import( + `../routing/pool-opt-in-prompt?p3dismissed1=${Date.now()}` + ); + const result = await maybeOfferPoolRouting('claude', 1); + expect(result.skipped).toBe(true); + expect(result.skipReason).toBe('dismissed'); + }); + + // ── 9. Remote target hint-not-prompt ────────────────────────────────────── + it('skips interactive prompt for remote target (hint only)', async () => { + // Configure the unified config to use a remote CLIProxy server. + // getProxyTarget() reads cliproxy_server.remote from the config, so this + // is the correct way to exercise the remote branch without cross-module spying. + const { mutateConfig, invalidateConfigCache } = await import( + `../../config/config-loader-facade?p3remote1=${Date.now()}` + ); + mutateConfig( + (cfg: { + cliproxy_server?: { + remote?: { enabled?: boolean; host?: string; protocol?: string }; + }; + }) => { + cfg.cliproxy_server = { + remote: { enabled: true, host: '192.168.1.1', protocol: 'http' }, + }; + } + ); + invalidateConfigCache(); + + const { maybeOfferPoolRouting } = await import( + `../routing/pool-opt-in-prompt?p3remote1=${Date.now()}` + ); + const result = await maybeOfferPoolRouting('claude', 1); + expect(result.skipped).toBe(true); + expect(result.skipReason).toBe('remote-target'); + + // Restore remote config so other tests are not affected + mutateConfig((cfg: { cliproxy_server?: { remote?: { enabled?: boolean } } }) => { + if (cfg.cliproxy_server?.remote) { + cfg.cliproxy_server.remote.enabled = false; + } + }); + invalidateConfigCache(); + }); + + // ── 10. Not-at-transition skip ────────────────────────────────────────── + it('skips when accountCountBefore is 0 (first account, not a transition)', async () => { + const { maybeOfferPoolRouting } = await import( + `../routing/pool-opt-in-prompt?p3trans1=${Date.now()}` + ); + const result = await maybeOfferPoolRouting('claude', 0); + expect(result.skipped).toBe(true); + expect(result.skipReason).toBe('not-at-transition'); + }); + + it('skips when accountCountBefore is 2 (already past transition)', async () => { + const { maybeOfferPoolRouting } = await import( + `../routing/pool-opt-in-prompt?p3trans2=${Date.now()}` + ); + const result = await maybeOfferPoolRouting('claude', 2); + expect(result.skipped).toBe(true); + expect(result.skipReason).toBe('not-at-transition'); + }); + + // ── 11. Already enabled → skip ────────────────────────────────────────── + it('skips when pool routing is already enabled', async () => { + const { mutateConfig, invalidateConfigCache } = await import( + `../../config/config-loader-facade?p3alr1=${Date.now()}` + ); + mutateConfig((cfg: { cliproxy?: { pool_routing?: Record } }) => { + cfg.cliproxy = cfg.cliproxy ?? {}; + cfg.cliproxy.pool_routing = { enabled: true }; + }); + invalidateConfigCache(); + + const { maybeOfferPoolRouting } = await import( + `../routing/pool-opt-in-prompt?p3alr1=${Date.now()}` + ); + const result = await maybeOfferPoolRouting('claude', 1); + expect(result.skipped).toBe(true); + expect(result.skipReason).toBe('already-enabled'); + expect(result.enabled).toBe(true); + }); +}); + +// ── Cross-lane overlap guard ──────────────────────────────────────────────── +// Uses static imports (at top of file) so spyOn targets the same module instance +// as the function under test. Dynamic cache-bust imports create new instances. + +describe('Phase 3: cross-lane email overlap guard', () => { + let tempHome: string; + let originalCcsHome: string | undefined; + let stderrOutput: string[]; + + beforeEach(() => { + tempHome = createTestHome(); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + stderrOutput = []; + }); + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + fs.rmSync(tempHome, { recursive: true, force: true }); + }); + + // ── 12. Same email → warning ──────────────────────────────────────────── + it('warns when CLIProxy agy account email matches native Claude email', () => { + // spyOn the statically-imported module so the same instance is used by + // checkCrossLaneEmailOverlap (which also imports from the same module). + const spy = spyOn(claudeDetector, 'getClaudeAuthStatus').mockReturnValue({ + loggedIn: true, + email: 'test@example.com', + authMethod: 'claude.ai', + 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', 'test@example.com'); + + const combined = stderrOutput.join('\n'); + expect(combined).toContain('cross-lane email overlap'); + + spy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); + + // ── 13. Different email → no warning ──────────────────────────────────── + it('does not warn when emails differ', () => { + const spy = spyOn(claudeDetector, 'getClaudeAuthStatus').mockReturnValue({ + loggedIn: true, + email: 'other@example.com', + authMethod: 'claude.ai', + 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', 'different@example.com'); + + expect(stderrOutput.join('\n')).not.toContain('cross-lane'); + + spy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); + + // ── 14. Not logged in → silent ────────────────────────────────────────── + it('is silent when native Claude is not logged in', () => { + 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('claude', 'test@example.com'); + + expect(stderrOutput.join('\n')).not.toContain('cross-lane'); + + spy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); + + it('is silent when getClaudeAuthStatus throws (CLI not installed)', () => { + const spy = spyOn(claudeDetector, 'getClaudeAuthStatus').mockImplementation(() => { + throw new Error('Command not found: claude'); + }); + + const consoleErrorSpy = spyOn(console, 'error').mockImplementation((msg?: unknown) => { + if (typeof msg === 'string') stderrOutput.push(msg); + }); + + // Must not throw + expect(() => checkCrossLaneEmailOverlap('claude', 'test@example.com')).not.toThrow(); + expect(stderrOutput.join('\n')).not.toContain('cross-lane'); + + spy.mockRestore(); + 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; 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( + `../routing/routing-strategy?p3invariant1=${Date.now()}` + ); + 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 }); + + // Enable then disable + enablePoolRouting(8317, { configPath, authDir }); + disablePoolRouting(8317, { configPath, authDir }); + + const after = fs.readFileSync(configPath, 'utf-8'); + // This is the single-account blackout prevention invariant. + // If this line is ever absent, the old v5 stability regression resurfaces. + expect(after).toContain('disable-cooling: true'); + expect(after).not.toContain('disable-cooling: false'); + }); +}); + +// ── Mixed-state test ──────────────────────────────────────────────────────── + +describe('Phase 3: mixed-state — claude pool + agy multi-account implicit RR', () => { + 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 }); + }); + + it('enabling pool routing while agy has 2+ accounts is instance-global (affects both)', async () => { + const { enablePoolRouting } = await import( + `../routing/routing-strategy?p3mixed1=${Date.now()}` + ); + 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 }); + + const result = enablePoolRouting(8317, { configPath, authDir }); + + // Pool routing is instance-global — written to the single shared config.yaml + expect(result.changed).toBe(true); + const content = fs.readFileSync(configPath, 'utf-8'); + // Both agy and claude accounts go through the same config + expect(content).toContain('strategy: fill-first'); + expect(content).toContain('disable-cooling: false'); + }); + + it('POOL_ROUTING_VERIFIED_PROVIDERS contains claude and agy but not codex or gemini', async () => { + const { POOL_ROUTING_VERIFIED_PROVIDERS } = await import( + `../routing/routing-strategy?p3mixed2=${Date.now()}` + ); + expect(POOL_ROUTING_VERIFIED_PROVIDERS.has('claude')).toBe(true); + expect(POOL_ROUTING_VERIFIED_PROVIDERS.has('agy')).toBe(true); + expect(POOL_ROUTING_VERIFIED_PROVIDERS.has('codex')).toBe(false); + expect(POOL_ROUTING_VERIFIED_PROVIDERS.has('gemini')).toBe(false); + }); +}); + +// ── Prompt accept/decline interactive path ───────────────────────────────── +// Uses static imports (poolOptInModule, promptModule) so spyOn targets the same +// module instance as maybeOfferPoolRouting. + +describe('Phase 3: maybeOfferPoolRouting interactive accept/decline', () => { + 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; + }); + + it('decline: writes prompt_dismissed=true and leaves config.yaml unchanged', async () => { + // Test via config state directly using dismissPoolPrompt + isPoolPromptDismissed + // as the canonical API. No internal spy needed. + + 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 }); + + // Write initial config.yaml so we have a baseline + const { enablePoolRouting, disablePoolRouting } = await import( + `../routing/routing-strategy?p3decline1=${Date.now()}` + ); + // Generate baseline (non-pool) + enablePoolRouting(8317, { configPath, authDir }); + disablePoolRouting(8317, { configPath, authDir }); + const baseline = fs.readFileSync(configPath, 'utf-8'); + + // dismissPoolPrompt sets prompt_dismissed; config.yaml should be unchanged + poolOptInModule.dismissPoolPrompt(); + expect(poolOptInModule.isPoolPromptDismissed()).toBe(true); + // config.yaml is not regenerated by dismiss — only the unified config changes + const afterDismiss = fs.readFileSync(configPath, 'utf-8'); + // Strip the generated timestamp line for comparison (it changes each regen) + const stripTimestamp = (s: string) => s.replace(/# Generated: .+/g, '# Generated: TIMESTAMP'); + expect(stripTimestamp(afterDismiss)).toBe(stripTimestamp(baseline)); + }); + + it('accept: InteractivePrompt.confirm stub + TTY + 2-account state enables pool routing', async () => { + // Flush the shared config cache: prior tests may have left prompt_dismissed or + // pool_routing state that would cause early-exit guards to fire. + invalidateSharedConfigCache(); + + // Stub InteractivePrompt.confirm to return true (user said yes) + const confirmSpy = spyOn(promptModule.InteractivePrompt, 'confirm').mockResolvedValue(true); + + // Stub stdin.isTTY and stderr.isTTY so the TTY guard passes + 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 }); + + 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 }); + + // Write 2 accounts to the temp registry file. + // accounts.json lives at getCcsDir()/cliproxy/accounts.json (getAccountsRegistryPath). + // Token files must exist in authDir so syncRegistryWithTokenFiles does not prune them. + const accountsPath = path.join(ccsDir, 'cliproxy', 'accounts.json'); + const fakeRegistry = { + providers: { + claude: { + default: 'acc1', + accounts: { + acc1: { email: 'a@b.com', tokenFile: 'a.json', nickname: 'a', createdAt: 0 }, + acc2: { email: 'c@d.com', tokenFile: 'c.json', nickname: 'c', createdAt: 0 }, + }, + }, + }, + }; + fs.writeFileSync(accountsPath, JSON.stringify(fakeRegistry), 'utf-8'); + + // Create stub token files so syncRegistryWithTokenFiles does not prune them + fs.writeFileSync(path.join(authDir, 'a.json'), '{}', 'utf-8'); + fs.writeFileSync(path.join(authDir, 'c.json'), '{}', 'utf-8'); + + const result = await poolOptInModule.maybeOfferPoolRouting('claude', 1, 8317); + + // With TTY + 2 accounts + confirm=true, pool routing should be enabled + expect(result.prompted).toBe(true); + expect(result.enabled).toBe(true); + expect(result.skipped).toBe(false); + + // Restore TTY descriptors: if no own descriptor existed, delete the property + // so later tests are not order-dependent on its value (CI leak prevention). + 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; + } + confirmSpy.mockRestore(); + }); + + it('decline path: InteractivePrompt.confirm stub returns false, dismissal persisted, config unchanged', async () => { + // Flush the shared config cache (may have prompt_dismissed:true from accept test) + invalidateSharedConfigCache(); + + const confirmSpy = spyOn(promptModule.InteractivePrompt, 'confirm').mockResolvedValue(false); + + 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 }); + + 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 }); + + // Write 2 claude accounts so the post-add count check passes + const accountsPath = path.join(ccsDir, 'cliproxy', 'accounts.json'); + const fakeRegistry = { + providers: { + claude: { + default: 'acc1', + accounts: { + acc1: { email: 'a@b.com', tokenFile: 'a.json', nickname: 'a', createdAt: 0 }, + acc2: { email: 'c@d.com', tokenFile: 'c.json', nickname: 'c', createdAt: 0 }, + }, + }, + }, + }; + fs.writeFileSync(accountsPath, JSON.stringify(fakeRegistry), 'utf-8'); + fs.writeFileSync(path.join(authDir, 'a.json'), '{}', 'utf-8'); + fs.writeFileSync(path.join(authDir, 'c.json'), '{}', 'utf-8'); + + const result = await poolOptInModule.maybeOfferPoolRouting('claude', 1, 8317); + + expect(result.prompted).toBe(true); + expect(result.enabled).toBe(false); + // Dismissal persisted so prompt does not re-show + expect(poolOptInModule.isPoolPromptDismissed()).toBe(true); + // Pool routing must NOT be enabled in the config + const { loadOrCreateUnifiedConfig } = await import( + `../../config/config-loader-facade?p3declinechk=${Date.now()}` + ); + const cfg = loadOrCreateUnifiedConfig(); + expect(cfg.cliproxy?.pool_routing?.enabled).not.toBe(true); + + // Restore TTY descriptors: if no own descriptor existed, delete the property + // so later tests are not order-dependent on its value (CI leak prevention). + 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; + } + confirmSpy.mockRestore(); + }); + + it('disclosure: prompt copy names all providers with 2+ accounts (agy + claude)', async () => { + // Plan criterion: prompt discloses ALL providers with >=2 accounts. + // Register 2 claude + 2 agy accounts and capture the console output. + // Both provider names must appear in the prompt copy. + invalidateSharedConfigCache(); + + const confirmSpy = spyOn(promptModule.InteractivePrompt, 'confirm').mockResolvedValue(false); + + 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 }); + + 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 }); + + // 2 claude + 2 agy accounts + const mixedRegistry = { + providers: { + claude: { + default: 'c1', + accounts: { + c1: { email: 'c1@b.com', tokenFile: 'c1.json', nickname: 'c1', createdAt: 0 }, + c2: { email: 'c2@b.com', tokenFile: 'c2.json', nickname: 'c2', createdAt: 0 }, + }, + }, + agy: { + default: 'a1', + accounts: { + a1: { email: 'a1@b.com', tokenFile: 'a1.json', nickname: 'a1', createdAt: 0 }, + a2: { email: 'a2@b.com', tokenFile: 'a2.json', nickname: 'a2', createdAt: 0 }, + }, + }, + }, + }; + fs.writeFileSync(accountsPath, JSON.stringify(mixedRegistry), 'utf-8'); + // Create stub token files + for (const f of ['c1.json', 'c2.json', 'a1.json', 'a2.json']) { + fs.writeFileSync(path.join(authDir, f), '{}', 'utf-8'); + } + + const consoleLines: string[] = []; + const consoleSpy = spyOn(console, 'log').mockImplementation((msg?: unknown) => { + if (typeof msg === 'string') consoleLines.push(msg); + }); + + await poolOptInModule.maybeOfferPoolRouting('claude', 1, 8317); + + const promptCopy = consoleLines.join('\n'); + expect(promptCopy).toContain('claude'); + expect(promptCopy).toContain('agy'); + + consoleSpy.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; + } + confirmSpy.mockRestore(); + }); +}); + +// ── Provider enumeration from registry ──────────────────────────────────── +// Ensures getMultiAccountProviders derives from live registry, not a hardcoded list. + +describe('Phase 3: provider enumeration derives from registry', () => { + 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 }); + }); + + it('non-at-transition skip when re-authing single account (accountCountAfter stays 1)', async () => { + // Registers 1 account in the temp registry — accountCountAfter === 1 + // maybeOfferPoolRouting(provider, accountCountBefore=1) must skip because + // actual post-add count is 1 (re-auth dedup scenario). + const ccsDir = path.join(tempHome, '.ccs'); + // accounts.json lives at getCcsDir()/cliproxy/accounts.json + 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 }); + + // Only 1 account registered + const singleAccountRegistry = { + providers: { + claude: { + default: 'acc1', + accounts: { + acc1: { email: 'a@b.com', tokenFile: 'a.json', nickname: 'a', createdAt: 0 }, + }, + }, + }, + }; + fs.writeFileSync(accountsPath, JSON.stringify(singleAccountRegistry), 'utf-8'); + fs.writeFileSync(path.join(authDir, 'a.json'), '{}', 'utf-8'); + + // Flush shared config cache to avoid stale state from prior tests + invalidateSharedConfigCache(); + + const result = await poolOptInModule.maybeOfferPoolRouting('claude', 1); + + // Must skip: accountCountBefore=1 but accountCountAfter=1 (re-auth, not new add) + expect(result.skipped).toBe(true); + expect(result.skipReason).toBe('not-at-transition'); + }); +}); + +// ── routing-subcommand pool-active warning regression ─────────────────────── +// handleRoutingSet must emit a pool-active warning when pool routing is enabled, +// so the user understands the stored strategy is ignored while pool is active. + +import * as routingSubcommandModule from '../../commands/cliproxy/routing-subcommand'; +import * as routingStrategyModule from '../routing/routing-strategy'; + +describe('Phase 3: routing-subcommand pool-active warning regression', () => { + 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 }); + }); + + it('handleRoutingSet warns when pool routing is active', async () => { + // Enable pool routing in the shared config so the check fires. + const { mutateConfig, invalidateConfigCache } = await import( + `../../config/config-loader-facade?p3rsw1=${Date.now()}` + ); + mutateConfig((cfg: { cliproxy?: { pool_routing?: Record } }) => { + cfg.cliproxy = cfg.cliproxy ?? {}; + cfg.cliproxy.pool_routing = { enabled: true }; + }); + invalidateConfigCache(); + invalidateSharedConfigCache(); + + const warnLines: string[] = []; + const consoleSpy = spyOn(console, 'log').mockImplementation((msg?: unknown) => { + if (typeof msg === 'string') warnLines.push(msg); + }); + // Stub applyCliproxyRoutingStrategy so it does not make network calls + const applySpy = spyOn(routingStrategyModule, 'applyCliproxyRoutingStrategy').mockResolvedValue( + { + strategy: 'round-robin', + source: 'config', + target: 'local', + reachable: false, + applied: 'config-only', + } + ); + + await routingSubcommandModule.handleRoutingSet(['round-robin']); + + const output = warnLines.join('\n'); + expect(output).toContain('Pool routing is active'); + + consoleSpy.mockRestore(); + applySpy.mockRestore(); + }); + + it('handleRoutingAffinitySet warns when pool routing is active (affinity parity)', async () => { + // Fix 6: affinity toggle path must emit the same pool-active warning as the + // strategy set path. Without this fix the two paths have divergent UX. + const { mutateConfig, invalidateConfigCache } = await import( + `../../config/config-loader-facade?p3rsa1=${Date.now()}` + ); + mutateConfig((cfg: { cliproxy?: { pool_routing?: Record } }) => { + cfg.cliproxy = cfg.cliproxy ?? {}; + cfg.cliproxy.pool_routing = { enabled: true }; + }); + invalidateConfigCache(); + invalidateSharedConfigCache(); + + const warnLines: string[] = []; + const consoleSpy = spyOn(console, 'log').mockImplementation((msg?: unknown) => { + if (typeof msg === 'string') warnLines.push(msg); + }); + // Stub applyCliproxySessionAffinitySettings so it does not touch the filesystem + const affinitySpy = spyOn( + routingStrategyModule, + 'applyCliproxySessionAffinitySettings' + ).mockResolvedValue({ + enabled: true, + ttl: '1h', + source: 'config', + target: 'local', + reachable: false, + manageable: true, + applied: 'config-only', + }); + + await routingSubcommandModule.handleRoutingAffinitySet(['on']); + + const output = warnLines.join('\n'); + expect(output).toContain('Pool routing is active'); + + consoleSpy.mockRestore(); + 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 } }) => { + 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'); + }); +}); diff --git a/src/cliproxy/accounts/__tests__/drain-order.test.ts b/src/cliproxy/accounts/__tests__/drain-order.test.ts new file mode 100644 index 00000000..a8942d28 --- /dev/null +++ b/src/cliproxy/accounts/__tests__/drain-order.test.ts @@ -0,0 +1,935 @@ +/** + * Tests for drain order priority computation, write path, and attribution stability. + */ +import { describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { runWithScopedCcsHome } from '../../../utils/config-manager'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +async function withIsolatedHome(fn: (homeDir: string) => Promise | T): Promise { + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-drain-order-')); + try { + return await runWithScopedCcsHome(homeDir, () => fn(homeDir)); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + } +} + +/** Create a minimal auth JSON file in the auth dir. */ +function writeAuthFile( + authDir: string, + fileName: string, + fields: Record = {} +): void { + fs.mkdirSync(authDir, { recursive: true, mode: 0o700 }); + const content = JSON.stringify({ type: 'antigravity', email: fileName, ...fields }, null, 2); + fs.writeFileSync(path.join(authDir, fileName), content, { mode: 0o600 }); +} + +function readAuthFile(authDir: string, fileName: string): Record { + return JSON.parse(fs.readFileSync(path.join(authDir, fileName), 'utf-8')) as Record< + string, + unknown + >; +} + +// --------------------------------------------------------------------------- +// Import helpers (avoid module-level imports that capture paths at load time) +// --------------------------------------------------------------------------- + +async function loadDrainOrder() { + return import(`../drain-order?drain-order=${Date.now()}`); +} + +async function loadRegistry() { + return import(`../registry?drain-order-registry=${Date.now()}`); +} + +async function loadStatsTransformer() { + return import(`../../../cliproxy/services/stats-fetcher?drain-order-stats-fetcher=${Date.now()}`); +} + +async function loadOrderSubcommand() { + return import( + `../../../commands/cliproxy/order-subcommand?drain-order-order-subcommand=${Date.now()}` + ); +} + +// --------------------------------------------------------------------------- +// Priority computation tests +// --------------------------------------------------------------------------- + +describe('rankToPriority', () => { + it('maps rank 0 (highest) to total priority', async () => { + const { rankToPriority } = await loadDrainOrder(); + expect(rankToPriority(0, 3)).toBe(3); + }); + + it('maps last rank to MIN_PRIORITY (1)', async () => { + const { rankToPriority, MIN_PRIORITY } = await loadDrainOrder(); + expect(rankToPriority(2, 3)).toBe(MIN_PRIORITY); + expect(rankToPriority(2, 3)).toBeGreaterThanOrEqual(1); + }); + + it('never returns 0', async () => { + const { rankToPriority } = await loadDrainOrder(); + for (let total = 1; total <= 5; total++) { + for (let rank = 0; rank < total; rank++) { + expect(rankToPriority(rank, total)).toBeGreaterThanOrEqual(1); + } + } + }); + + it('single account gets priority 1', async () => { + const { rankToPriority } = await loadDrainOrder(); + expect(rankToPriority(0, 1)).toBe(1); + }); +}); + +describe('computeManualDrainOrder', () => { + it('assigns descending priorities to specified accounts', async () => { + const { computeManualDrainOrder } = await loadDrainOrder(); + const accounts = [ + { accountId: 'a@x.com', tokenFile: 'a.json' }, + { accountId: 'b@x.com', tokenFile: 'b.json' }, + { accountId: 'c@x.com', tokenFile: 'c.json' }, + ]; + const entries = computeManualDrainOrder(['a@x.com', 'b@x.com', 'c@x.com'], accounts); + const byId = new Map( + entries.map((e: { accountId: string; priority: number }) => [e.accountId, e.priority]) + ); + expect(byId.get('a@x.com')).toBeGreaterThan(byId.get('b@x.com')); + expect(byId.get('b@x.com')).toBeGreaterThan(byId.get('c@x.com')); + expect(byId.get('c@x.com')).toBeGreaterThanOrEqual(1); + }); + + it('throws for unknown account ID', async () => { + const { computeManualDrainOrder } = await loadDrainOrder(); + const accounts = [{ accountId: 'a@x.com', tokenFile: 'a.json' }]; + expect(() => computeManualDrainOrder(['z@x.com'], accounts)).toThrow(); + }); + + it('throws for duplicate account ID in the order list', async () => { + const { computeManualDrainOrder } = await loadDrainOrder(); + const accounts = [ + { accountId: 'a@x.com', tokenFile: 'a.json' }, + { accountId: 'b@x.com', tokenFile: 'b.json' }, + ]; + expect(() => computeManualDrainOrder(['a@x.com', 'b@x.com', 'a@x.com'], accounts)).toThrow( + /[Dd]uplicate/ + ); + }); + + it('unspecified accounts get MIN_PRIORITY', async () => { + const { computeManualDrainOrder, MIN_PRIORITY } = await loadDrainOrder(); + const accounts = [ + { accountId: 'a@x.com', tokenFile: 'a.json' }, + { accountId: 'b@x.com', tokenFile: 'b.json' }, + ]; + const entries = computeManualDrainOrder(['a@x.com'], accounts); + const bEntry = entries.find((e: { accountId: string }) => e.accountId === 'b@x.com'); + expect(bEntry?.priority).toBe(MIN_PRIORITY); + }); + + it('is idempotent - same inputs produce same priorities', async () => { + const { computeManualDrainOrder } = await loadDrainOrder(); + const accounts = [ + { accountId: 'a@x.com', tokenFile: 'a.json' }, + { accountId: 'b@x.com', tokenFile: 'b.json' }, + ]; + const entries1 = computeManualDrainOrder(['a@x.com', 'b@x.com'], accounts); + const entries2 = computeManualDrainOrder(['a@x.com', 'b@x.com'], accounts); + expect(JSON.stringify(entries1)).toBe(JSON.stringify(entries2)); + }); + + it('partial --set: every specified account has strictly higher priority than every unspecified account', async () => { + // Regression for the tie at priority 1 when last specified == MIN_PRIORITY == unspecified. + // With a pool of 5, specifying 2 should give the last specified priority > 1. + const { computeManualDrainOrder, MIN_PRIORITY } = await loadDrainOrder(); + const accounts = [ + { accountId: 'a@x.com', tokenFile: 'a.json' }, + { accountId: 'b@x.com', tokenFile: 'b.json' }, + { accountId: 'c@x.com', tokenFile: 'c.json' }, + { accountId: 'd@x.com', tokenFile: 'd.json' }, + { accountId: 'e@x.com', tokenFile: 'e.json' }, + ]; + const entries = computeManualDrainOrder(['a@x.com', 'b@x.com'], accounts); + const byId = new Map( + entries.map((e: { accountId: string; priority: number }) => [e.accountId, e.priority]) + ); + const specifiedPriorities = ['a@x.com', 'b@x.com'].map((id) => byId.get(id) as number); + const unspecifiedPriorities = ['c@x.com', 'd@x.com', 'e@x.com'].map( + (id) => byId.get(id) as number + ); + const minSpecified = Math.min(...specifiedPriorities); + const maxUnspecified = Math.max(...unspecifiedPriorities); + expect(minSpecified).toBeGreaterThan(maxUnspecified); + // Unspecified accounts still get MIN_PRIORITY + for (const p of unspecifiedPriorities) { + expect(p).toBe(MIN_PRIORITY); + } + }); +}); + +describe('computeTierDrainOrder', () => { + it('ultra > pro > free priority ordering', async () => { + const { computeTierDrainOrder } = await loadDrainOrder(); + const accounts = [ + { accountId: 'free@x.com', tokenFile: 'free.json', tier: 'free' as const }, + { accountId: 'ultra@x.com', tokenFile: 'ultra.json', tier: 'ultra' as const }, + { accountId: 'pro@x.com', tokenFile: 'pro.json', tier: 'pro' as const }, + ]; + const entries = computeTierDrainOrder(accounts); + const byId = new Map( + entries.map((e: { accountId: string; priority: number }) => [e.accountId, e.priority]) + ); + expect(byId.get('ultra@x.com')).toBeGreaterThan(byId.get('pro@x.com')); + expect(byId.get('pro@x.com')).toBeGreaterThan(byId.get('free@x.com')); + expect(byId.get('free@x.com')).toBeGreaterThanOrEqual(1); + }); + + it('unknown tier gets lowest priority (MIN_PRIORITY)', async () => { + const { computeTierDrainOrder, MIN_PRIORITY } = await loadDrainOrder(); + const accounts = [ + { accountId: 'ultra@x.com', tokenFile: 'ultra.json', tier: 'ultra' as const }, + { accountId: 'unknown@x.com', tokenFile: 'unknown.json', tier: 'unknown' as const }, + ]; + const entries = computeTierDrainOrder(accounts); + const unknownEntry = entries.find( + (e: { accountId: string }) => e.accountId === 'unknown@x.com' + ); + expect(unknownEntry?.priority).toBe(MIN_PRIORITY); + }); + + it('accounts with same tier get equal priority', async () => { + const { computeTierDrainOrder } = await loadDrainOrder(); + const accounts = [ + { accountId: 'a@x.com', tokenFile: 'a.json', tier: 'pro' as const }, + { accountId: 'b@x.com', tokenFile: 'b.json', tier: 'pro' as const }, + ]; + const entries = computeTierDrainOrder(accounts); + const [a, b] = entries as Array<{ accountId: string; priority: number }>; + expect(a.priority).toBe(b.priority); + }); + + it('tie-break by tokenFile (plain byte order, not locale) matches selector contract', async () => { + // The upstream Go selector breaks priority ties by Auth.ID asc (lowercased file path). + // For the agy prefix-named fleet, file names differ from emails, so verify we sort + // by tokenFile not accountId. + const { computeTierDrainOrder } = await loadDrainOrder(); + const accounts = [ + { accountId: 'z@x.com', tokenFile: 'antigravity-aaa.json', tier: 'pro' as const }, + { accountId: 'a@x.com', tokenFile: 'antigravity-zzz.json', tier: 'pro' as const }, + ]; + // Both same tier -> same priority; sort by tokenFile should put 'aaa' before 'zzz' + const entries = computeTierDrainOrder(accounts) as Array<{ + accountId: string; + tokenFile: string; + priority: number; + }>; + // Confirm equal priorities + expect(entries[0].priority).toBe(entries[1].priority); + // Sort as the display layer does: priority desc, tokenFile lowercase asc + const sorted = entries + .slice() + .sort( + (a, b) => + b.priority - a.priority || + (a.tokenFile.toLowerCase() < b.tokenFile.toLowerCase() ? -1 : 1) + ); + // antigravity-aaa.json sorts before antigravity-zzz.json (byte/file order) + expect(sorted[0].tokenFile).toBe('antigravity-aaa.json'); + expect(sorted[1].tokenFile).toBe('antigravity-zzz.json'); + // accountId order is the reverse, confirming file order != email order for this fleet + expect(sorted[0].accountId).toBe('z@x.com'); + expect(sorted[1].accountId).toBe('a@x.com'); + }); + + it('tierDerived is true only for accounts with a real tier rank', async () => { + const { computeTierDrainOrder } = await loadDrainOrder(); + const accounts = [ + { accountId: 'ultra@x.com', tokenFile: 'ultra.json', tier: 'ultra' as const }, + { accountId: 'unknown@x.com', tokenFile: 'unknown.json', tier: 'unknown' as const }, + ]; + const entries = computeTierDrainOrder(accounts) as Array<{ + accountId: string; + tierDerived: boolean; + }>; + const ultraEntry = entries.find((e) => e.accountId === 'ultra@x.com'); + const unknownEntry = entries.find((e) => e.accountId === 'unknown@x.com'); + expect(ultraEntry?.tierDerived).toBe(true); + expect(unknownEntry?.tierDerived).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Tie-break key (platform-conditional, mirrors upstream synthesizer) +// --------------------------------------------------------------------------- + +describe('tieBreakKey', () => { + it('preserves case on non-Windows (byte order, upper before lower)', async () => { + const { tieBreakKey } = await loadOrderSubcommand(); + // On non-Windows the selector compares raw Auth.ID bytes: 'B' (0x42) < 'a' (0x61). + const files = ['antigravity-aaa.json', 'antigravity-Bbb.json']; + const sorted = files + .slice() + .sort((a, b) => (tieBreakKey(a, 'linux') < tieBreakKey(b, 'linux') ? -1 : 1)); + expect(sorted).toEqual(['antigravity-Bbb.json', 'antigravity-aaa.json']); + }); + + it('lowercases on Windows so mixed case sorts case-insensitively', async () => { + const { tieBreakKey } = await loadOrderSubcommand(); + const files = ['antigravity-aaa.json', 'antigravity-Bbb.json']; + const sorted = files + .slice() + .sort((a, b) => (tieBreakKey(a, 'win32') < tieBreakKey(b, 'win32') ? -1 : 1)); + expect(sorted).toEqual(['antigravity-aaa.json', 'antigravity-Bbb.json']); + }); +}); + +// --------------------------------------------------------------------------- +// Direct file write tests +// --------------------------------------------------------------------------- + +describe('writeAuthFilePriorityDirect', () => { + it('writes priority field to auth JSON', async () => { + await withIsolatedHome(async (homeDir) => { + const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth'); + writeAuthFile(authDir, 'test.json', { email: 'test@x.com' }); + + const { writeAuthFilePriorityDirect } = await loadDrainOrder(); + writeAuthFilePriorityDirect('test.json', 3); + + const data = readAuthFile(authDir, 'test.json'); + expect(data.priority).toBe(3); + }); + }); + + it('preserves all other fields', async () => { + await withIsolatedHome(async (homeDir) => { + const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth'); + writeAuthFile(authDir, 'test.json', { email: 'test@x.com', token: 'abc123' }); + + const { writeAuthFilePriorityDirect } = await loadDrainOrder(); + writeAuthFilePriorityDirect('test.json', 2); + + const data = readAuthFile(authDir, 'test.json'); + expect(data.email).toBe('test@x.com'); + expect(data.token).toBe('abc123'); + expect(data.priority).toBe(2); + }); + }); + + it('rejects priority 0 (management layer treats 0 as delete)', async () => { + await withIsolatedHome(async (homeDir) => { + const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth'); + writeAuthFile(authDir, 'test.json'); + + const { writeAuthFilePriorityDirect } = await loadDrainOrder(); + expect(() => writeAuthFilePriorityDirect('test.json', 0)).toThrow(); + }); + }); + + it('rejects negative priority', async () => { + await withIsolatedHome(async (homeDir) => { + const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth'); + writeAuthFile(authDir, 'test.json'); + + const { writeAuthFilePriorityDirect } = await loadDrainOrder(); + expect(() => writeAuthFilePriorityDirect('test.json', -1)).toThrow(); + }); + }); + + it('throws when file does not exist', async () => { + await withIsolatedHome(async (_homeDir) => { + const { writeAuthFilePriorityDirect } = await loadDrainOrder(); + expect(() => writeAuthFilePriorityDirect('nonexistent.json', 1)).toThrow(); + }); + }); +}); + +describe('readAuthFilePriority', () => { + it('returns the priority from a file that has one', async () => { + await withIsolatedHome(async (homeDir) => { + const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth'); + writeAuthFile(authDir, 'test.json', { priority: 5 }); + + const { readAuthFilePriority } = await loadDrainOrder(); + expect(readAuthFilePriority('test.json')).toBe(5); + }); + }); + + it('returns undefined for file with no priority', async () => { + await withIsolatedHome(async (homeDir) => { + const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth'); + writeAuthFile(authDir, 'test.json', { email: 'x@y.com' }); + + const { readAuthFilePriority } = await loadDrainOrder(); + expect(readAuthFilePriority('test.json')).toBeUndefined(); + }); + }); + + it('returns undefined for missing file', async () => { + await withIsolatedHome(async (_homeDir) => { + const { readAuthFilePriority } = await loadDrainOrder(); + expect(readAuthFilePriority('ghost.json')).toBeUndefined(); + }); + }); + + it('returns undefined for priority 0 (invalid sentinel)', async () => { + await withIsolatedHome(async (homeDir) => { + const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth'); + writeAuthFile(authDir, 'test.json', { priority: 0 }); + + const { readAuthFilePriority } = await loadDrainOrder(); + expect(readAuthFilePriority('test.json')).toBeUndefined(); + }); + }); +}); + +// --------------------------------------------------------------------------- +// applyDrainOrder (direct write path, proxy stopped) +// --------------------------------------------------------------------------- + +describe('applyDrainOrder - direct write path (proxy stopped)', () => { + it('writes priorities to all files when proxy stopped', async () => { + await withIsolatedHome(async (homeDir) => { + const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth'); + writeAuthFile(authDir, 'a.json', { email: 'a@x.com' }); + writeAuthFile(authDir, 'b.json', { email: 'b@x.com' }); + + const { applyDrainOrder } = await loadDrainOrder(); + const entries = [ + { + accountId: 'a@x.com', + tokenFile: 'a.json', + priority: 2, + tierDerived: false, + currentPriority: undefined, + }, + { + accountId: 'b@x.com', + tokenFile: 'b.json', + priority: 1, + tierDerived: false, + currentPriority: undefined, + }, + ]; + const result = await applyDrainOrder(entries, false); + + expect(result.usedManagementApi).toBe(false); + expect(result.written).toHaveLength(2); + expect(result.failed).toHaveLength(0); + + const a = readAuthFile(authDir, 'a.json'); + const b = readAuthFile(authDir, 'b.json'); + expect(a.priority).toBe(2); + expect(b.priority).toBe(1); + }); + }); + + it('skips entries where priority already matches (idempotency)', async () => { + await withIsolatedHome(async (homeDir) => { + const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth'); + writeAuthFile(authDir, 'a.json', { email: 'a@x.com', priority: 3 }); + + const { applyDrainOrder } = await loadDrainOrder(); + const entries = [ + { + accountId: 'a@x.com', + tokenFile: 'a.json', + priority: 3, + tierDerived: false, + currentPriority: undefined, + }, + ]; + const result = await applyDrainOrder(entries, false); + + expect(result.skipped).toHaveLength(1); + expect(result.written).toHaveLength(0); + }); + }); + + it('records failures for missing files', async () => { + await withIsolatedHome(async (_homeDir) => { + const { applyDrainOrder } = await loadDrainOrder(); + const entries = [ + { + accountId: 'ghost@x.com', + tokenFile: 'ghost.json', + priority: 2, + tierDerived: false, + currentPriority: undefined, + }, + ]; + const result = await applyDrainOrder(entries, false); + expect(result.failed).toHaveLength(1); + expect(result.written).toHaveLength(0); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Registry drain order persistence +// --------------------------------------------------------------------------- + +describe('registry drain order persistence', () => { + it('saveDrainOrderConfig persists and loadDrainOrderConfig retrieves', async () => { + await withIsolatedHome(async (homeDir) => { + const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth'); + writeAuthFile(authDir, 'antigravity-a.json', { email: 'a@x.com', type: 'antigravity' }); + + const { registerAccount } = await loadRegistry(); + registerAccount('agy', 'antigravity-a.json', 'a@x.com'); + + const { saveDrainOrderConfig, loadDrainOrderConfig } = await loadRegistry(); + const config = { mode: 'manual' as const, orderedIds: ['a@x.com'] }; + const saved = saveDrainOrderConfig('agy', config); + expect(saved).toBe(true); + + const loaded = loadDrainOrderConfig('agy'); + expect(loaded?.mode).toBe('manual'); + expect(loaded?.orderedIds).toEqual(['a@x.com']); + }); + }); + + it('clearDrainOrderConfig removes persisted config', async () => { + await withIsolatedHome(async (homeDir) => { + const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth'); + writeAuthFile(authDir, 'antigravity-a.json', { email: 'a@x.com', type: 'antigravity' }); + + const { registerAccount } = await loadRegistry(); + registerAccount('agy', 'antigravity-a.json', 'a@x.com'); + + const { saveDrainOrderConfig, loadDrainOrderConfig, clearDrainOrderConfig } = + await loadRegistry(); + saveDrainOrderConfig('agy', { mode: 'tier' }); + const cleared = clearDrainOrderConfig('agy'); + expect(cleared).toBe(true); + + expect(loadDrainOrderConfig('agy')).toBeUndefined(); + }); + }); + + it('returns false for saveDrainOrderConfig when provider not in registry', async () => { + await withIsolatedHome(async (_homeDir) => { + const { saveDrainOrderConfig } = await loadRegistry(); + // No accounts registered for 'gemini' -> returns false + const result = saveDrainOrderConfig('gemini', { mode: 'tier' }); + expect(result).toBe(false); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Attribution stability: auth_index ordering survives priority rewrite +// --------------------------------------------------------------------------- + +describe('attribution stability - auth_index unaffected by priority rewrite', () => { + it('buildAuthIndexToAccountMap produces stable output regardless of priority values', async () => { + // Simulate the auth files list returned by /v0/management/auth-files with various priorities. + // auth_index is assigned by CLIProxy at load time based on file discovery order, + // NOT by priority. So priority rewrites must not change auth_index values. + const { buildAuthIndexToAccountMap } = await loadStatsTransformer(); + + const authFilesBeforeRewrite = [ + { auth_index: 0, email: 'a@x.com', provider: 'antigravity' }, + { auth_index: 1, email: 'b@x.com', provider: 'antigravity' }, + { auth_index: 2, email: 'c@x.com', provider: 'antigravity' }, + ]; + + const authFilesAfterRewrite = [ + // Priority changed but auth_index values are assigned by CLIProxy independently + { auth_index: 0, email: 'a@x.com', provider: 'antigravity', priority: 3 }, + { auth_index: 1, email: 'b@x.com', provider: 'antigravity', priority: 2 }, + { auth_index: 2, email: 'c@x.com', provider: 'antigravity', priority: 1 }, + ]; + + const mapBefore = buildAuthIndexToAccountMap(authFilesBeforeRewrite); + const mapAfter = buildAuthIndexToAccountMap(authFilesAfterRewrite); + + // auth_index -> email mapping must be identical after priority rewrite + expect(mapBefore.get('0')).toBe('a@x.com'); + expect(mapBefore.get('1')).toBe('b@x.com'); + expect(mapBefore.get('2')).toBe('c@x.com'); + + expect(mapAfter.get('0')).toBe(mapBefore.get('0')); + expect(mapAfter.get('1')).toBe(mapBefore.get('1')); + expect(mapAfter.get('2')).toBe(mapBefore.get('2')); + }); + + it('buildAuthIndexToAccountMap skips entries without auth_index', async () => { + const { buildAuthIndexToAccountMap } = await loadStatsTransformer(); + + const authFiles = [ + { email: 'no-index@x.com', provider: 'antigravity' }, // no auth_index + { auth_index: 5, email: 'has-index@x.com', provider: 'antigravity' }, + ]; + + const map = buildAuthIndexToAccountMap(authFiles); + expect(map.size).toBe(1); + expect(map.get('5')).toBe('has-index@x.com'); + expect(map.has('undefined')).toBe(false); + }); + + it('buildAuthIndexToAccountMap handles both numeric and string auth_index keys', async () => { + const { buildAuthIndexToAccountMap } = await loadStatsTransformer(); + + const authFiles = [ + { auth_index: 0, email: 'numeric@x.com', provider: 'antigravity' }, + { auth_index: '1', email: 'string@x.com', provider: 'antigravity' }, + ]; + + const map = buildAuthIndexToAccountMap(authFiles); + // Both stored as String(auth_index) + expect(map.get('0')).toBe('numeric@x.com'); + expect(map.get('1')).toBe('string@x.com'); + }); +}); + +// --------------------------------------------------------------------------- +// resolveEffectiveDrainOrder - shared effective-order resolver +// (consumed by `accounts order` show AND the quota pool section) +// --------------------------------------------------------------------------- + +describe('resolveEffectiveDrainOrder', () => { + it('returns empty file-order result for no active accounts', async () => { + await withIsolatedHome(async () => { + const { resolveEffectiveDrainOrder } = await loadDrainOrder(); + const result = resolveEffectiveDrainOrder('claude', []); + expect(result.mode).toBe('file'); + expect(result.entries).toEqual([]); + expect(result.hasDrift).toBe(false); + }); + }); + + it('uses stable file order (tieBreakKey) and reports no drift when no config stored', async () => { + await withIsolatedHome(async (homeDir) => { + const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth'); + writeAuthFile(authDir, 'claude-b.json', { email: 'b@x.com' }); + writeAuthFile(authDir, 'claude-a.json', { email: 'a@x.com' }); + + const { resolveEffectiveDrainOrder } = await loadDrainOrder(); + const result = resolveEffectiveDrainOrder('claude', [ + { accountId: 'b@x.com', tokenFile: 'claude-b.json' }, + { accountId: 'a@x.com', tokenFile: 'claude-a.json' }, + ]); + expect(result.mode).toBe('file'); + expect(result.entries.map((e: { accountId: string }) => e.accountId)).toEqual([ + 'a@x.com', + 'b@x.com', + ]); + expect(result.hasDrift).toBe(false); + }); + }); + + it('file-order: residual on-disk priorities reorder display to match the selector and flag drift', async () => { + await withIsolatedHome(async (homeDir) => { + const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth'); + // No drain order config stored -> file mode. But residual priorities are + // left on disk (e.g. after `order --reset` did not strip the attribute). + // The selector follows the file, so b (priority 5) must sort before a + // (priority 1) even though tieBreakKey alone would put a first, and drift + // is flagged so the user knows the file disagrees with "plain file order". + writeAuthFile(authDir, 'claude-a.json', { email: 'a@x.com', priority: 1 }); + writeAuthFile(authDir, 'claude-b.json', { email: 'b@x.com', priority: 5 }); + + const { resolveEffectiveDrainOrder } = await loadDrainOrder(); + const result = resolveEffectiveDrainOrder('claude', [ + { accountId: 'a@x.com', tokenFile: 'claude-a.json' }, + { accountId: 'b@x.com', tokenFile: 'claude-b.json' }, + ]); + expect(result.mode).toBe('file'); + expect(result.entries.map((e: { accountId: string }) => e.accountId)).toEqual([ + 'b@x.com', + 'a@x.com', + ]); + expect(result.hasDrift).toBe(true); + }); + }); + + it('sorts manual order by ON-DISK priority, not computed config, and flags drift', async () => { + await withIsolatedHome(async (homeDir) => { + const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth'); + // Two registered accounts; manual config says a then b (a > b computed). + writeAuthFile(authDir, 'antigravity-a.json', { email: 'a@x.com', type: 'antigravity' }); + writeAuthFile(authDir, 'antigravity-b.json', { email: 'b@x.com', type: 'antigravity' }); + + const { registerAccount, saveDrainOrderConfig } = await loadRegistry(); + registerAccount('agy', 'antigravity-a.json', 'a@x.com'); + registerAccount('agy', 'antigravity-b.json', 'b@x.com'); + saveDrainOrderConfig('agy', { mode: 'manual', orderedIds: ['a@x.com', 'b@x.com'] }); + + // On disk, re-auth/--reset left b with a HIGHER priority than a. The + // selector follows the file, so b should come first and drift is flagged. + const { writeAuthFilePriorityDirect, resolveEffectiveDrainOrder } = await loadDrainOrder(); + writeAuthFilePriorityDirect('antigravity-a.json', 1); + writeAuthFilePriorityDirect('antigravity-b.json', 5); + + const result = resolveEffectiveDrainOrder('agy', [ + { accountId: 'a@x.com', tokenFile: 'antigravity-a.json' }, + { accountId: 'b@x.com', tokenFile: 'antigravity-b.json' }, + ]); + expect(result.mode).toBe('manual'); + // b@x.com first because its ON-DISK priority (5) beats a@x.com (1). + expect(result.entries.map((e: { accountId: string }) => e.accountId)).toEqual([ + 'b@x.com', + 'a@x.com', + ]); + expect(result.hasDrift).toBe(true); + }); + }); + + it('reports no drift when on-disk priorities match the computed manual config', async () => { + await withIsolatedHome(async (homeDir) => { + const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth'); + writeAuthFile(authDir, 'antigravity-a.json', { email: 'a@x.com', type: 'antigravity' }); + writeAuthFile(authDir, 'antigravity-b.json', { email: 'b@x.com', type: 'antigravity' }); + + const { registerAccount, saveDrainOrderConfig } = await loadRegistry(); + registerAccount('agy', 'antigravity-a.json', 'a@x.com'); + registerAccount('agy', 'antigravity-b.json', 'b@x.com'); + saveDrainOrderConfig('agy', { mode: 'manual', orderedIds: ['a@x.com', 'b@x.com'] }); + + // Apply the computed config to disk first, then resolve: no drift. + const { computeManualDrainOrder, applyDrainOrder, resolveEffectiveDrainOrder } = + await loadDrainOrder(); + const entries = computeManualDrainOrder( + ['a@x.com', 'b@x.com'], + [ + { accountId: 'a@x.com', tokenFile: 'antigravity-a.json' }, + { accountId: 'b@x.com', tokenFile: 'antigravity-b.json' }, + ] + ); + await applyDrainOrder(entries, false); + + const result = resolveEffectiveDrainOrder('agy', [ + { accountId: 'a@x.com', tokenFile: 'antigravity-a.json' }, + { accountId: 'b@x.com', tokenFile: 'antigravity-b.json' }, + ]); + expect(result.mode).toBe('manual'); + expect(result.entries.map((e: { accountId: string }) => e.accountId)).toEqual([ + 'a@x.com', + 'b@x.com', + ]); + expect(result.hasDrift).toBe(false); + }); + }); +}); + +// --------------------------------------------------------------------------- +// 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; + } + }); + }); +}); diff --git a/src/cliproxy/accounts/__tests__/pool-state.test.ts b/src/cliproxy/accounts/__tests__/pool-state.test.ts new file mode 100644 index 00000000..b3a9d9eb --- /dev/null +++ b/src/cliproxy/accounts/__tests__/pool-state.test.ts @@ -0,0 +1,423 @@ +/** + * Tests for the pool state resolver: per-account state classification + * (available / cooling / paused), drain order modes, and the cooling-vs-paused + * distinction driven by the persisted quota-cooldown store. + */ +import { describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { runWithScopedCcsHome } from '../../../utils/config-manager'; +import type { AccountInfo } from '../types'; +import type { PoolRoutingSettings } from '../pool-state'; + +const SETTINGS_OFF: PoolRoutingSettings = { + poolEnabled: false, + strategy: 'round-robin', + sessionAffinityEnabled: false, + sessionAffinityTtl: '1h', +}; + +function account(partial: Partial & { id: string }): AccountInfo { + return { + provider: 'claude', + isDefault: false, + tokenFile: `${partial.id}.json`, + createdAt: '2026-01-01T00:00:00.000Z', + ...partial, + } as AccountInfo; +} + +async function withIsolatedHome(fn: (homeDir: string) => Promise | T): Promise { + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-pool-state-')); + try { + return await runWithScopedCcsHome(homeDir, () => fn(homeDir)); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + } +} + +const SETTINGS_ON: PoolRoutingSettings = { + poolEnabled: true, + strategy: 'fill-first', + sessionAffinityEnabled: true, + sessionAffinityTtl: '1h', +}; + +async function loadPoolState() { + return import(`../pool-state?pool-state=${Date.now()}`); +} + +describe('resolvePoolState - account state classification', () => { + it('labels an unpaused, non-cooling account as available', async () => { + await withIsolatedHome(async () => { + const { resolvePoolState } = await loadPoolState(); + const pool = resolvePoolState({ + provider: 'claude', + settings: SETTINGS_OFF, + accounts: [account({ id: 'a@x.com' })], + now: 1_000_000, + }); + expect(pool.states[0].state).toBe('available'); + }); + }); + + it('labels a manually paused account (no cooldown record) as paused', async () => { + await withIsolatedHome(async () => { + const { resolvePoolState } = await loadPoolState(); + const pool = resolvePoolState({ + provider: 'claude', + settings: SETTINGS_OFF, + accounts: [account({ id: 'a@x.com', paused: true, pausedAt: '2026-06-01T00:00:00.000Z' })], + now: 1_000_000, + }); + expect(pool.states[0].state).toBe('paused'); + expect(pool.states[0].pausedAt).toBe('2026-06-01T00:00:00.000Z'); + }); + }); + + it('labels a paused account with a matching quota-cooldown record as cooling', async () => { + await withIsolatedHome(async (homeDir) => { + const pausedAt = '2026-06-10T12:00:00.000Z'; + const until = 2_000_000; + 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, + until, + reason: 'quota_exhausted', + }, + ], + }) + ); + + const { resolvePoolState } = await loadPoolState(); + const pool = resolvePoolState({ + provider: 'claude', + settings: SETTINGS_OFF, + accounts: [account({ id: 'a@x.com', paused: true, pausedAt })], + now: 1_000_000, + }); + expect(pool.states[0].state).toBe('cooling'); + expect(pool.states[0].cooldownUntil).toBe(until); + expect(pool.states[0].cooldownSource).toBe('persisted'); + }); + }); + + it('treats a manual pause layered over a stale cooldown record as paused (pausedAt mismatch)', 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: 2_000_000, + reason: 'quota_exhausted', + }, + ], + }) + ); + + const { resolvePoolState } = await loadPoolState(); + const pool = resolvePoolState({ + provider: 'claude', + settings: SETTINGS_OFF, + // Different pausedAt -> not the same pause the cooldown record describes. + accounts: [account({ id: 'a@x.com', paused: true, pausedAt: '2026-06-11T09:00:00.000Z' })], + now: 1_000_000, + }); + expect(pool.states[0].state).toBe('paused'); + }); + }); + + it('does not treat an expired persisted cooldown as cooling', 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: 500_000, // already past relative to now below + reason: 'quota_exhausted', + }, + ], + }) + ); + + const { resolvePoolState } = await loadPoolState(); + const pool = resolvePoolState({ + provider: 'claude', + settings: SETTINGS_OFF, + accounts: [account({ id: 'a@x.com' })], + now: 1_000_000, + }); + expect(pool.states[0].state).toBe('available'); + }); + }); +}); + +describe('resolvePoolState - drain order', () => { + it('uses stable file order when no drain config is stored', async () => { + await withIsolatedHome(async () => { + const { resolvePoolState } = await loadPoolState(); + const pool = resolvePoolState({ + provider: 'claude', + settings: SETTINGS_OFF, + accounts: [ + account({ id: 'b@x.com', tokenFile: 'claude-b.json' }), + account({ id: 'a@x.com', tokenFile: 'claude-a.json' }), + ], + now: 1_000_000, + }); + expect(pool.drainOrder.mode).toBe('file'); + // Token-file byte order: claude-a.json before claude-b.json. + expect(pool.drainOrder.order).toEqual(['a@x.com', 'b@x.com']); + }); + }); + + it('excludes paused accounts from the drain order but keeps them in states', async () => { + await withIsolatedHome(async () => { + const { resolvePoolState } = await loadPoolState(); + const pool = resolvePoolState({ + provider: 'claude', + settings: SETTINGS_OFF, + accounts: [ + account({ id: 'a@x.com', tokenFile: 'claude-a.json' }), + account({ + id: 'b@x.com', + tokenFile: 'claude-b.json', + paused: true, + pausedAt: '2026-06-01T00:00:00.000Z', + }), + ], + now: 1_000_000, + }); + expect(pool.drainOrder.order).toEqual(['a@x.com']); + expect(pool.states.map((s) => s.accountId).sort()).toEqual(['a@x.com', 'b@x.com']); + }); + }); + + it('reports no drift in plain file-order mode', async () => { + await withIsolatedHome(async () => { + const { resolvePoolState } = await loadPoolState(); + const pool = resolvePoolState({ + provider: 'claude', + settings: SETTINGS_OFF, + accounts: [ + account({ id: 'a@x.com', tokenFile: 'claude-a.json' }), + account({ id: 'b@x.com', tokenFile: 'claude-b.json' }), + ], + now: 1_000_000, + }); + expect(pool.drainOrder.hasDrift).toBe(false); + }); + }); + + it('follows on-disk priority and surfaces drift when the stored order diverges', async () => { + await withIsolatedHome(async (homeDir) => { + const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth'); + fs.mkdirSync(authDir, { recursive: true, mode: 0o700 }); + // Auth files on disk with priorities that contradict the stored manual order: + // config says a then b, but disk gives b the higher priority (re-auth/--reset trap). + fs.writeFileSync( + path.join(authDir, 'antigravity-a.json'), + JSON.stringify({ type: 'antigravity', email: 'a@x.com', priority: 1 }) + ); + fs.writeFileSync( + path.join(authDir, 'antigravity-b.json'), + JSON.stringify({ type: 'antigravity', email: 'b@x.com', priority: 5 }) + ); + + const { registerAccount, saveDrainOrderConfig } = await import( + `../registry?pool-state-drift=${Date.now()}` + ); + registerAccount('agy', 'antigravity-a.json', 'a@x.com'); + registerAccount('agy', 'antigravity-b.json', 'b@x.com'); + saveDrainOrderConfig('agy', { mode: 'manual', orderedIds: ['a@x.com', 'b@x.com'] }); + + const { resolvePoolState } = await loadPoolState(); + const pool = resolvePoolState({ + provider: 'agy', + settings: SETTINGS_OFF, + accounts: [ + account({ provider: 'agy', id: 'a@x.com', tokenFile: 'antigravity-a.json' }), + account({ provider: 'agy', id: 'b@x.com', tokenFile: 'antigravity-b.json' }), + ], + now: 1_000_000, + }); + // Selector follows the on-disk priority: b (5) before a (1). + expect(pool.drainOrder.order).toEqual(['b@x.com', 'a@x.com']); + expect(pool.drainOrder.hasDrift).toBe(true); + }); + }); +}); + +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; + } + }); + }); +}); diff --git a/src/cliproxy/accounts/account-safety-cross-lane.ts b/src/cliproxy/accounts/account-safety-cross-lane.ts new file mode 100644 index 00000000..0e489828 --- /dev/null +++ b/src/cliproxy/accounts/account-safety-cross-lane.ts @@ -0,0 +1,164 @@ +/** + * Cross-lane email overlap guard + * + * The documented ban vector: one Google/Anthropic account active in BOTH a + * CLIProxy OAuth lane AND a native Claude Code profile lane simultaneously. + * CLIProxy refreshes tokens server-side while the native profile may be logged + * in via the same account, creating concurrent token usage patterns that + * Google/Anthropic treat as suspicious. + * + * Scope of check: compare the newly registered CLIProxy account email against + * 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. 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; +} + +/** + * Read the OAuth email stored in a native Claude lane's `.claude.json`. + * + * 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).oauthAccount; + if (!oauthAccount || typeof oauthAccount !== 'object') return null; + const email = (oauthAccount as Record).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 + */ +export function checkCrossLaneEmailOverlap(provider: CLIProxyProvider, email: string): void { + if (!CROSS_LANE_RISK_PROVIDERS.includes(provider)) return; + + try { + const normalized = email.toLowerCase().trim(); + if (normalized.length === 0) 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); + + console.error(''); + console.error(warn(`Account safety: cross-lane email overlap detected for ${provider}`)); + console.error(` CLIProxy account: ${masked} (${provider})`); + 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.' + ); + console.error( + ' CLIProxy refreshes tokens server-side; native Claude may do the same concurrently.' + ); + console.error(' If you want to keep access, use separate accounts for each lane.'); + console.error( + ' CCS is provided as-is and cannot take responsibility for access-loss decisions.' + ); + console.error(''); + } catch { + // Silent: CLI not installed, spawn failed, JSON parse error, etc. + } +} diff --git a/src/cliproxy/accounts/account-safety.ts b/src/cliproxy/accounts/account-safety.ts index b3f48784..3173bbfe 100644 --- a/src/cliproxy/accounts/account-safety.ts +++ b/src/cliproxy/accounts/account-safety.ts @@ -112,6 +112,37 @@ function loadQuotaPaused(): QuotaPausedFile { return { entries: [] }; } +/** + * Read-only view of a persisted quota-cooldown pause. + * Exposed for visibility surfaces (e.g. `ccs cliproxy quota` pool section) + * that must distinguish a quota cooldown (with a reset time) from a manual pause. + */ +export interface QuotaCooldownEntry { + provider: CLIProxyProvider; + accountId: string; + /** ISO timestamp when the account was paused (matches AccountInfo.pausedAt) */ + pausedAt: string; + /** Epoch ms when the cooldown is eligible to be lifted */ + until: number; + reason: 'quota_exhausted'; +} + +/** + * Return the persisted quota-cooldown pauses recorded on disk. + * + * This is the cross-process source of truth for quota cooldowns: the in-memory + * cooldown map in quota-manager is process-local, but quota-paused.json is + * written by the long-lived proxy/monitor process and read by short-lived CLI + * invocations. Callers use it to label an account as cooling (vs manually + * paused) and to show the reset time. + * + * Entries are returned as-is (including expired ones); callers decide whether to + * treat `until <= now` as already cooled down. + */ +export function readQuotaCooldownEntries(): QuotaCooldownEntry[] { + return loadQuotaPaused().entries.map((entry) => ({ ...entry })); +} + function saveQuotaPaused(data: QuotaPausedFile): void { const filePath = getQuotaPausedPath(); if (data.entries.length === 0) { @@ -568,8 +599,9 @@ export function restoreAutoPausedAccounts(provider: CLIProxyProvider): void { saveAutoPaused(data); } -// Error patterns that indicate Google has disabled/banned an account -const BAN_PATTERNS = [ +// Error patterns that indicate a provider has disabled/banned an account. +// Shared patterns apply to all providers (Google and Anthropic OAuth flows). +const SHARED_BAN_PATTERNS = [ 'disabled in this account', 'violation of terms of service', 'account has been disabled', @@ -578,12 +610,28 @@ const BAN_PATTERNS = [ 'account has been banned', ]; +// Anthropic-specific disable patterns. Only applied when provider === 'claude' +// to avoid false-positive auto-pause on Google/Codex errors that may reference +// "policy" in rate-limit or scope messages. +const ANTHROPIC_BAN_PATTERNS = ['your account has been blocked', 'account is blocked']; + /** * Check if an error message indicates an account ban/disable. + * Pass the provider so Anthropic-only patterns cannot trip Google providers. */ -export function isBanResponse(errorMessage: string): boolean { +export function isBanResponse(errorMessage: string, provider?: CLIProxyProvider): boolean { const lower = errorMessage.toLowerCase(); - return BAN_PATTERNS.some((pattern) => lower.includes(pattern)); + if (SHARED_BAN_PATTERNS.some((pattern) => lower.includes(pattern))) return true; + if (provider === 'claude' && ANTHROPIC_BAN_PATTERNS.some((pattern) => lower.includes(pattern))) { + return true; + } + return false; +} + +/** Return the actor name (Google, Anthropic, etc.) for ban copy. */ +function banActor(provider: CLIProxyProvider): string { + if (provider === 'claude') return 'Anthropic'; + return 'Google'; } /** @@ -595,10 +643,11 @@ export function handleBanDetection( accountId: string, errorMessage: string ): boolean { - if (!isBanResponse(errorMessage)) return false; + if (!isBanResponse(errorMessage, provider)) return false; + const actor = banActor(provider); console.error(''); - console.error(warn('Account safety: account appears disabled by Google')); + console.error(warn(`Account safety: account appears disabled by ${actor}`)); console.error(` Account "${maskEmail(accountId)}" (${provider}) returned:`); console.error(` "${truncate(errorMessage, 120)}"`); console.error(''); diff --git a/src/cliproxy/accounts/drain-order.ts b/src/cliproxy/accounts/drain-order.ts new file mode 100644 index 00000000..6f1e82ce --- /dev/null +++ b/src/cliproxy/accounts/drain-order.ts @@ -0,0 +1,714 @@ +/** + * Drain Order Management + * + * Manages CLIProxy account drain order via top-level "priority" field in auth JSONs. + * + * Architecture: + * - Priority >= 1 always (management layer treats 0 as delete-the-attribute) + * - Write path is dual: management API when proxy running, direct file when stopped + * - Tier-aware defaults only where AccountTier metadata exists (agy/gemini) + * - Claude pools: tier is unknown -> stable file order + manual --set required + * - Selector drain order: priority bucket desc, then Auth.ID asc + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { getAuthDir } from '../config/config-generator'; +import { + getProxyTarget, + buildProxyUrl, + buildManagementHeaders, +} from '../proxy/proxy-target-resolver'; +import { loadDrainOrderConfig } from './registry'; +import type { AccountTier } from './types'; +import type { CLIProxyProvider } from '../types'; + +/** Minimum valid priority value. Management layer treats 0 as delete. */ +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. + * ultra > pro > free, unknown = no rank (returns undefined). + */ +const TIER_RANK: Record, number> = { + ultra: 3, + pro: 2, + free: 1, +}; + +/** + * Compute 1-based priority from a 0-based rank among N accounts. + * Rank 0 = highest priority = assigned priority N. + * Rank N-1 = lowest priority = assigned priority 1. + * + * Never returns 0 (management layer treats 0 as delete). + */ +export function rankToPriority(rank: number, total: number): number { + const raw = total - rank; + return Math.max(MIN_PRIORITY, raw); +} + +/** Tier rank for a known tier, undefined for unknown. */ +export function tierRank(tier: AccountTier | undefined): number | undefined { + if (!tier || tier === 'unknown') return undefined; + return TIER_RANK[tier]; +} + +/** + * Result of drain order computation for a single account. + */ +export interface DrainOrderEntry { + /** Account ID */ + accountId: string; + /** Token file name (without directory) */ + tokenFile: string; + /** Computed priority (>= 1, higher = drained earlier) */ + priority: number; + /** Tier if known */ + tier?: AccountTier; + /** Whether tier was used to derive this priority */ + tierDerived: boolean; + /** Current priority read from auth file (undefined = not yet set) */ + currentPriority: number | undefined; +} + +/** + * Input record for priority computation. + */ +export interface DrainOrderInput { + accountId: string; + tokenFile: string; + tier?: AccountTier; +} + +/** + * Compute drain order priorities for a list of accounts using manual ordering. + * + * @param orderedIds Account IDs in desired drain order (first = highest priority) + * @param allAccounts All accounts for the provider (some may not be in orderedIds) + * @returns DrainOrderEntry array ordered by resulting priority desc + */ +export function computeManualDrainOrder( + orderedIds: string[], + allAccounts: DrainOrderInput[] +): DrainOrderEntry[] { + const accountMap = new Map(allAccounts.map((a) => [a.accountId, a])); + const result: DrainOrderEntry[] = []; + + // Reject duplicate IDs: a repeated account in --set is ambiguous and would + // otherwise be assigned two different priorities. + const seen = new Set(); + for (const id of orderedIds) { + if (seen.has(id)) { + throw new Error(`Duplicate account ID in order: ${id}`); + } + seen.add(id); + } + + // Validate that all specified IDs exist + for (const id of orderedIds) { + if (!accountMap.has(id)) { + throw new Error(`Account ID not found: ${id}`); + } + } + + const specifiedSet = new Set(orderedIds); + // Use total + 1 so rank (N-1) maps to priority 2, keeping every specified + // account strictly above the MIN_PRIORITY floor assigned to unspecified ones. + const total = orderedIds.length + 1; + + // Assign priorities to specified accounts (first = highest priority) + for (let rank = 0; rank < orderedIds.length; rank++) { + const id = orderedIds[rank]; + const account = accountMap.get(id); + if (!account) continue; + + result.push({ + accountId: id, + tokenFile: account.tokenFile, + priority: rankToPriority(rank, total), + tier: account.tier, + tierDerived: false, + currentPriority: undefined, + }); + } + + // Remaining accounts not in the specified list get priority 1 (lowest) + for (const account of allAccounts) { + if (!specifiedSet.has(account.accountId)) { + result.push({ + accountId: account.accountId, + tokenFile: account.tokenFile, + priority: MIN_PRIORITY, + tier: account.tier, + tierDerived: false, + currentPriority: undefined, + }); + } + } + + return result; +} + +/** + * Compute drain order priorities for accounts using tier metadata. + * Only valid where tier metadata exists (agy/gemini providers). + * + * Accounts with unknown tier are sorted last (priority MIN_PRIORITY). + * Accounts within the same tier bucket are given equal priorities. + * + * @param accounts Accounts with optional tier metadata + * @returns DrainOrderEntry array + */ +export function computeTierDrainOrder(accounts: DrainOrderInput[]): DrainOrderEntry[] { + // Group by tier rank + const withRank: Array<{ input: DrainOrderInput; rank: number }> = accounts.map((a) => ({ + input: a, + rank: tierRank(a.tier) ?? 0, + })); + + // Get unique ranks descending + const uniqueRanks = [...new Set(withRank.map((x) => x.rank))].sort((a, b) => b - a); + const numRanks = uniqueRanks.length; + + // Assign priority buckets: highest rank -> highest priority (numRanks), lowest -> MIN_PRIORITY + const rankToPriorityMap = new Map(); + uniqueRanks.forEach((rank, idx) => { + // idx 0 = highest rank, gets highest priority + const priority = Math.max(MIN_PRIORITY, numRanks - idx); + rankToPriorityMap.set(rank, priority); + }); + + return withRank.map(({ input, rank }) => ({ + accountId: input.accountId, + tokenFile: input.tokenFile, + priority: rankToPriorityMap.get(rank) ?? MIN_PRIORITY, + tier: input.tier, + tierDerived: rank > 0, // only true when tier contributed a real rank + currentPriority: undefined, + })); +} + +/** + * Read the current priority from an auth JSON file. + * Returns undefined if file missing, unreadable, or has no priority field. + */ +export function readAuthFilePriority(tokenFile: string): number | undefined { + try { + const authDir = getAuthDir(); + const filePath = path.join(authDir, tokenFile); + const content = fs.readFileSync(filePath, 'utf-8'); + const data = JSON.parse(content) as { priority?: unknown }; + if (typeof data.priority === 'number' && Number.isFinite(data.priority) && data.priority >= 1) { + return data.priority; + } + return undefined; + } catch { + return undefined; + } +} + +/** + * Write priority directly to an auth JSON file (proxy must be stopped). + * Validates priority >= MIN_PRIORITY before writing. + * Preserves all other fields; uses atomic temp-rename. + */ +export function writeAuthFilePriorityDirect(tokenFile: string, priority: number): void { + if (priority < MIN_PRIORITY) { + throw new Error(`Priority must be >= ${MIN_PRIORITY}, got ${priority}`); + } + + 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; + + data.priority = priority; + + const tempPath = `${filePath}.tmp.${process.pid}`; + fs.writeFileSync(tempPath, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 }); + fs.renameSync(tempPath, filePath); +} + +/** + * Write priority via CLIProxy management API (proxy must be running). + * Uses PATCH /v0/management/auth-files/fields. + * + * @param tokenFile File name (basename, e.g. "antigravity-foo.json") + * @param priority Priority value >= MIN_PRIORITY + * @returns true on success, false on failure + */ +export async function writeAuthFilePriorityViaApi( + tokenFile: string, + priority: number +): Promise { + if (priority < MIN_PRIORITY) { + 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 { + 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 { + const target = getProxyTarget(); + const url = buildProxyUrl(target, AUTH_FILES_FIELDS_PATH); + const headers = buildManagementHeaders(target, { 'Content-Type': 'application/json' }); + + const body = JSON.stringify({ name: tokenFile, priority }); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), MGMT_TIMEOUT_MS); + + try { + const response = await fetch(url, { + method: 'PATCH', + headers, + body, + signal: controller.signal, + }); + + return response.ok; + } catch { + return false; + } finally { + clearTimeout(timeoutId); + } +} + +/** + * 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; + + 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 { + 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. + */ +export interface ApplyDrainOrderResult { + /** Entries that were written successfully */ + written: DrainOrderEntry[]; + /** Entries that were skipped (priority unchanged) */ + skipped: DrainOrderEntry[]; + /** Entries that failed to write */ + failed: Array<{ entry: DrainOrderEntry; reason: string }>; + /** Whether the proxy was running during the write (management API path) */ + usedManagementApi: boolean; +} + +/** + * Apply drain order priorities to auth files. + * + * Write path selection: + * - Proxy running (HTTP health check passes): use management API + * - Proxy stopped: direct file write + * + * Idempotent: skips entries where priority already equals the target value. + * + * @param entries Drain order entries with target priorities + * @param proxyRunning Whether the proxy is currently running + * @returns Result summary + */ +export async function applyDrainOrder( + entries: DrainOrderEntry[], + proxyRunning: boolean +): Promise { + const result: ApplyDrainOrderResult = { + written: [], + skipped: [], + failed: [], + usedManagementApi: proxyRunning, + }; + + for (const entry of entries) { + // Idempotency: read current value and skip if already set + const currentPriority = readAuthFilePriority(entry.tokenFile); + if (currentPriority === entry.priority) { + result.skipped.push({ ...entry, currentPriority }); + continue; + } + + if (proxyRunning) { + const ok = await writeAuthFilePriorityViaApi(entry.tokenFile, entry.priority); + if (ok) { + result.written.push({ ...entry, currentPriority }); + } else { + result.failed.push({ + entry: { ...entry, currentPriority }, + reason: 'management API PATCH failed', + }); + } + } else { + try { + writeAuthFilePriorityDirect(entry.tokenFile, entry.priority); + result.written.push({ ...entry, currentPriority }); + } catch (err) { + result.failed.push({ + entry: { ...entry, currentPriority }, + reason: (err as Error).message, + }); + } + } + } + + return result; +} + +/** + * Read current drain order priorities for a set of accounts. + * Annotates each DrainOrderEntry with its current priority from disk. + * + * @param entries Entries to annotate (modifies currentPriority in-place) + */ +export function annotateCurrentPriorities(entries: DrainOrderEntry[]): void { + for (const entry of entries) { + entry.currentPriority = readAuthFilePriority(entry.tokenFile); + } +} + +/** + * Tie-break key for a token file, matching the upstream Go selector. + * The selector breaks priority ties by Auth.ID asc. Upstream (synthesizer + * file.go:120-122) lowercases Auth.ID only on Windows; on other platforms it + * compares by raw byte order. We mirror that platform-conditional behaviour so + * the displayed order matches what CLIProxy actually drains. + * + * @param platform process.platform; defaults to the current platform. Exposed + * for tests so non-Windows byte-order behaviour can be asserted deterministically. + */ +export function tieBreakKey( + tokenFile: string, + platform: NodeJS.Platform = process.platform +): string { + return platform === 'win32' ? tokenFile.toLowerCase() : tokenFile; +} + +/** How the effective drain order was derived. */ +export type EffectiveDrainOrderMode = 'manual' | 'tier' | 'file'; + +/** + * Effective drain order as the selector actually sees it: computed from the + * stored config mode, then sorted by the on-disk priority each auth file + * carries (the selector's real input), not by the intended config priority. + */ +export interface EffectiveDrainOrder { + /** How the stored config intended to derive order. */ + mode: EffectiveDrainOrderMode; + /** + * Entries in selector pick order (first = drained first), annotated with the + * on-disk priority via annotateCurrentPriorities(). + */ + entries: DrainOrderEntry[]; + /** + * True when the computed config priority diverges from what is actually on + * disk for any account. Drift happens because re-auth rewrites the auth JSON + * and drops the priority attribute, and --reset leaves residual file + * priorities; under drift the selector follows the file, not the config. + */ + hasDrift: boolean; +} + +/** + * Resolve the effective drain order for a provider's active (non-paused) + * accounts, mirroring what `ccs cliproxy accounts order` shows and what the + * selector actually drains. + * + * Semantics (shared by the order subcommand and the quota pool section): + * 1. Pick the config mode (manual when stored ids still resolve, else tier when + * stored, else file order). + * 2. annotateCurrentPriorities() to read the on-disk priority for each account. + * 3. Sort by currentPriority desc (undefined treated as 0, matching the + * selector), tie-break by tieBreakKey(tokenFile) asc. + * 4. Flag drift when the computed config priority != on-disk priority anywhere. + * + * File mode never writes priorities, but it still honours any residual on-disk + * priority the selector reads (e.g. left by `order --reset`): it sorts by that + * priority desc then tieBreakKey asc, and flags drift when any residual is + * non-zero. With no residuals all priorities are 0 and it falls back to plain + * tieBreakKey order with no drift. + * + * @param activeAccounts Accounts in rotation (callers exclude paused accounts). + */ +export function resolveEffectiveDrainOrder( + provider: CLIProxyProvider, + activeAccounts: DrainOrderInput[] +): EffectiveDrainOrder { + if (activeAccounts.length === 0) { + return { mode: 'file', entries: [], hasDrift: false }; + } + + const drainCfg = loadDrainOrderConfig(provider); + + // Manual mode is only usable when at least one stored ID still maps to a live + // account. If every stored ID is stale, fall back to the file-order view. + let manualValidIds: string[] | undefined; + if (drainCfg?.mode === 'manual' && drainCfg.orderedIds && drainCfg.orderedIds.length > 0) { + const existingIds = new Set(activeAccounts.map((a) => a.accountId)); + manualValidIds = drainCfg.orderedIds.filter((id) => existingIds.has(id)); + } + + let entries: DrainOrderEntry[]; + let mode: EffectiveDrainOrderMode; + + if (manualValidIds && manualValidIds.length > 0) { + entries = computeManualDrainOrder(manualValidIds, activeAccounts); + mode = 'manual'; + } else if (drainCfg?.mode === 'tier') { + entries = computeTierDrainOrder(activeAccounts); + mode = 'tier'; + } else { + // File order: config never writes priorities, so entries carry no computed + // priority. The selector still reads any residual on-disk priority (e.g. + // left behind by `order --reset`), so annotate, sort by that priority like + // the other modes, and flag drift when any residual is non-zero. + entries = activeAccounts.map((a) => ({ + accountId: a.accountId, + tokenFile: a.tokenFile, + priority: MIN_PRIORITY, + tier: a.tier, + tierDerived: false, + currentPriority: undefined, + })); + annotateCurrentPriorities(entries); + const fileHasDrift = entries.some((e) => (e.currentPriority ?? 0) !== 0); + entries.sort( + (a, b) => + (b.currentPriority ?? 0) - (a.currentPriority ?? 0) || + (tieBreakKey(a.tokenFile) < tieBreakKey(b.tokenFile) ? -1 : 1) + ); + return { mode: 'file', entries, hasDrift: fileHasDrift }; + } + + // Annotate on-disk priorities (the selector's real input) before sorting. + annotateCurrentPriorities(entries); + + // Drift: computed config priority diverges from on-disk anywhere. Missing + // file priority (undefined) is treated as 0, matching the selector. + const hasDrift = entries.some((e) => (e.currentPriority ?? 0) !== e.priority); + + // Sort by on-disk priority desc (undefined as 0), tie-break by tokenFile asc. + entries.sort( + (a, b) => + (b.currentPriority ?? 0) - (a.currentPriority ?? 0) || + (tieBreakKey(a.tokenFile) < tieBreakKey(b.tokenFile) ? -1 : 1) + ); + + 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 { + 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); + } +} diff --git a/src/cliproxy/accounts/pool-state.ts b/src/cliproxy/accounts/pool-state.ts new file mode 100644 index 00000000..bbeec361 --- /dev/null +++ b/src/cliproxy/accounts/pool-state.ts @@ -0,0 +1,316 @@ +/** + * Pool State Resolver + * + * Resolves the observable state of an account pool for a provider, combining: + * - effective drain order (Phase 4 resolver: manual / tier / file order) + * - per-account state: available / cooling (quota cooldown, with reset time) / paused + * - routing settings (strategy, session affinity) and pool routing mode + * + * These three account states map to DIFFERENT client-visible failure modes: + * - paused : the account is held out of rotation by the user or a safety guard + * - cooling : the account hit a quota limit and is on a timed cooldown + * - available: the account is eligible for selection + * + * They are named distinctly so visibility surfaces never conflate "I paused it" + * 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. + * - The in-memory quota-manager cooldown is process-local; it only contributes + * when this very process applied a cooldown. The persisted entry wins when + * both exist, because that is the routing truth across processes. + */ + +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'; + +/** Providers where tier metadata is tracked and tier-derived order is meaningful. */ +export const POOL_TIER_AWARE_PROVIDERS = new Set(['agy', 'gemini']); + +/** Per-account pool state. */ +export type PoolAccountStateKind = 'available' | 'cooling' | 'paused'; + +/** How the effective drain order was derived. */ +export type PoolDrainOrderMode = 'manual' | 'tier' | 'file'; + +export interface PoolAccountState { + accountId: string; + tokenFile: string; + tier?: AccountTier; + /** Whether this account is the provider default. */ + isDefault: boolean; + /** Resolved state. */ + state: PoolAccountStateKind; + /** + * For state 'cooling': epoch ms when the cooldown is eligible to lift. + * Undefined for other states. + */ + cooldownUntil?: number; + /** + * For state 'cooling': where the cooldown reading came from. + * 'proxy' = live in-proxy 429 cooldown (GET /v0/management/auth-files), + * 'persisted' = quota-paused.json (cross-process), + * 'memory' = in-process quota-manager map. + */ + cooldownSource?: 'proxy' | 'persisted' | 'memory'; + /** ISO timestamp the account was paused (manual pause), when state is 'paused'. */ + pausedAt?: string; +} + +export interface PoolDrainOrderState { + mode: PoolDrainOrderMode; + /** + * Accounts in effective drain order (first = drained first), as the selector + * actually sees it: sorted by on-disk priority, not the intended config. + * Paused accounts are excluded from the drain order (they are not in rotation) + * but still surface in `states`. + */ + order: string[]; + /** + * True when the stored config order diverges from what is on disk (the + * selector's real input). Surfaced so the quota pool section warns instead of + * silently showing a "next account" that disagrees with the selector. + */ + hasDrift: boolean; +} + +export interface PoolRoutingSettings { + /** Whether pool routing (fill-first + affinity + cooling) is enabled. */ + poolEnabled: boolean; + strategy: string; + sessionAffinityEnabled: boolean; + sessionAffinityTtl: string; + /** Max credentials tried per request before a 429, when pool routing is on. */ + maxRetryCredentials?: number; +} + +export interface PoolState { + provider: CLIProxyProvider; + /** All accounts for the provider with resolved per-account state. */ + states: PoolAccountState[]; + drainOrder: PoolDrainOrderState; + settings: PoolRoutingSettings; +} + +export interface ResolvePoolStateInput { + provider: CLIProxyProvider; + settings: PoolRoutingSettings; + /** Override for tests; defaults to getProviderAccounts(provider). */ + 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 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). + * 3. A paused account without a matching cooldown record is a manual/safety pause + * -> 'paused'. + * 4. An unpaused account with an in-memory cooldown still in effect -> 'cooling'. + * 5. Otherwise -> 'available'. + */ +function classifyAccount( + provider: CLIProxyProvider, + account: AccountInfo, + cooldownByAccount: Map, + proxyCooldownByTokenFile: Map, + now: number +): PoolAccountState { + const base: PoolAccountState = { + accountId: account.id, + tokenFile: account.tokenFile, + tier: account.tier, + isDefault: account.isDefault, + 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) { + // A persisted quota cooldown that matches this pause is a cooldown, not a + // manual pause. Match on pausedAt so a manual pause layered over a stale + // cooldown record is not mislabelled. + if (persisted && persisted.pausedAt === account.pausedAt && persisted.until > now) { + return { + ...base, + state: 'cooling', + cooldownUntil: persisted.until, + cooldownSource: 'persisted', + }; + } + return { ...base, state: 'paused', pausedAt: account.pausedAt }; + } + + // Unpaused: a persisted cooldown still in effect is the routing truth. + if (persisted && persisted.until > now) { + return { + ...base, + state: 'cooling', + cooldownUntil: persisted.until, + cooldownSource: 'persisted', + }; + } + + // Fall back to the process-local cooldown map. + const memoryUntil = getCooldownUntil(provider, account.id); + if (memoryUntil !== undefined && memoryUntil > now) { + return { + ...base, + state: 'cooling', + cooldownUntil: memoryUntil, + cooldownSource: 'memory', + }; + } + + return base; +} + +/** + * Compute the effective drain order for the active (non-paused) accounts, + * mirroring the selector's pick order used by `ccs cliproxy accounts order`. + * + * Delegates to the shared resolveEffectiveDrainOrder() so the quota Pool + * section, the `accounts order` show, and the selector all agree: the order is + * sorted by ON-DISK priority (the selector's real input), and any drift between + * the stored config and the auth files is surfaced rather than hidden. + */ +function resolveDrainOrder( + provider: CLIProxyProvider, + activeAccounts: DrainOrderInput[] +): PoolDrainOrderState { + const effective = resolveEffectiveDrainOrder(provider, activeAccounts); + return { + mode: effective.mode, + order: effective.entries.map((e) => e.accountId), + hasDrift: effective.hasDrift, + }; +} + +/** + * 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; + const now = input.now ?? Date.now(); + const accounts = input.accounts ?? getProviderAccounts(provider); + + // Index persisted quota cooldowns for this provider by account id. + const cooldownByAccount = new Map(); + for (const entry of readQuotaCooldownEntries()) { + if (entry.provider !== provider) continue; + 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(); + for (const entry of input.proxyCooldowns ?? []) { + proxyCooldownByTokenFile.set(entry.tokenFile, entry); + } + + const states = accounts.map((account) => + classifyAccount(provider, account, cooldownByAccount, proxyCooldownByTokenFile, now) + ); + + // Drain order is computed over accounts that are in rotation (not paused). + // Cooling accounts remain in the order: cooling is transient and the selector + // still considers them in priority terms once the window lifts. + const activeAccounts: DrainOrderInput[] = accounts + .filter((a) => !a.paused) + .map((a) => ({ accountId: a.id, tokenFile: a.tokenFile, tier: a.tier })); + + const drainOrder = resolveDrainOrder(provider, activeAccounts); + + 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 { + // 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 }); +} diff --git a/src/cliproxy/accounts/registry.ts b/src/cliproxy/accounts/registry.ts index b666c1e9..06ca3dff 100644 --- a/src/cliproxy/accounts/registry.ts +++ b/src/cliproxy/accounts/registry.ts @@ -10,7 +10,7 @@ import { CLIProxyProvider } from '../types'; import { PROVIDER_CAPABILITIES } from '../provider-capabilities'; import { PROVIDER_TYPE_VALUES } from '../auth/auth-types'; import { getAuthDir, getCliproxyDir } from '../config/config-generator'; -import { AccountsRegistry, AccountInfo, PROVIDERS_WITHOUT_EMAIL } from './types'; +import { AccountsRegistry, AccountInfo, PROVIDERS_WITHOUT_EMAIL, DrainOrderConfig } from './types'; import { getAccountsRegistryPath, getPausedDir, @@ -269,6 +269,10 @@ function populateRegistryFromTokenFiles( lastUsedAt: hydratedAccount?.lastUsedAt || (token.stats.mtime || token.stats.birthtime || new Date()).toISOString(), + // Preserve tier from the hydrated registry entry. Without this, populate + // would silently strip tier metadata written by the quota command, breaking + // --by-tier ordering for file-copied fleets that are re-hydrated on read. + tier: hydratedAccount?.tier, }; if (token.paused) { @@ -820,3 +824,48 @@ export function discoverExistingAccounts(): void { populateRegistryFromTokenFiles(registry); }); } + +/** + * Persist drain order configuration for a provider. + * The config survives re-auth and registry sync; priorities are NOT automatically + * re-applied after sync. Re-run `ccs cliproxy accounts order --by-tier` + * or `--set` after adding or re-authing accounts to re-apply. + */ +export function saveDrainOrderConfig( + provider: CLIProxyProvider, + config: DrainOrderConfig +): boolean { + return mutateAccountsRegistry((registry) => { + const providerAccounts = registry.providers[provider]; + if (!providerAccounts) { + return false; + } + providerAccounts.drainOrder = config; + return true; + }); +} + +/** + * Load persisted drain order configuration for a provider. + * Returns undefined if none stored (implies file order). + */ +export function loadDrainOrderConfig(provider: CLIProxyProvider): DrainOrderConfig | undefined { + return withAccountsRegistryLock(() => { + const registry = readAccountsRegistryFromDisk(); + return registry.providers[provider]?.drainOrder; + }); +} + +/** + * Clear drain order configuration for a provider (revert to file order). + */ +export function clearDrainOrderConfig(provider: CLIProxyProvider): boolean { + return mutateAccountsRegistry((registry) => { + const providerAccounts = registry.providers[provider]; + if (!providerAccounts) { + return false; + } + delete providerAccounts.drainOrder; + return true; + }); +} diff --git a/src/cliproxy/accounts/types.ts b/src/cliproxy/accounts/types.ts index 3d098f54..f64b2cd6 100644 --- a/src/cliproxy/accounts/types.ts +++ b/src/cliproxy/accounts/types.ts @@ -41,12 +41,34 @@ export interface AccountInfo { projectId?: string; } +/** + * Drain order mode for a provider's account pool. + * - "manual": user-specified ordered list of account IDs + * - "tier": auto-derived from AccountTier (ultra > pro > free); unknown = last + * - "file": stable file-system order (default, no priority writes) + */ +export type DrainOrderMode = 'manual' | 'tier' | 'file'; + +/** Persisted drain order configuration for a provider */ +export interface DrainOrderConfig { + /** How priorities were last set */ + mode: DrainOrderMode; + /** + * Ordered account IDs for manual mode. + * First = drained first (highest priority). + * Stale IDs (accounts removed since last --set) are silently ignored on re-apply. + */ + orderedIds?: string[]; +} + /** Provider accounts configuration */ export interface ProviderAccounts { /** Default account ID for this provider */ default: string; /** Map of account ID to account metadata */ accounts: Record>; + /** Persisted drain order configuration (optional; absent = file order) */ + drainOrder?: DrainOrderConfig; } /** Accounts registry structure */ diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index dea6cddc..3fe7375d 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -75,6 +75,8 @@ import { warnOAuthBanRisk, warnPossible403Ban, } from '../accounts/account-safety'; +import { maybeOfferPoolRouting } from '../routing/pool-opt-in-prompt'; +import { checkCrossLaneEmailOverlap } from '../accounts/account-safety-cross-lane'; import { ensureCliAntigravityResponsibility } from '../auth/antigravity-responsibility'; import { InteractivePrompt } from '../../utils/prompt'; import { getCcsDir } from '../../utils/config-manager'; @@ -1133,6 +1135,8 @@ export async function triggerOAuth( // Check for existing accounts const existingAccounts = getProviderAccounts(provider); + // Capture count before registration for 1->2 transition detection + const accountCountBeforeAdd = existingAccounts.length; const existingNameMatch = nickname ? findAccountNameMatch(existingAccounts, nickname) : null; const targetAccountId = options.expectedAccountId || existingNameMatch?.id; const nicknameError = !fromUI @@ -1363,6 +1367,38 @@ export async function triggerOAuth( } if (account) { + // Cross-lane overlap guard: warn if this account's email is also active + // in native Claude profiles (same account in two lanes is the documented ban vector). + if (account.email) { + checkCrossLaneEmailOverlap(provider, account.email); + } + + // Pool routing opt-in: offer at the 1->2 account-add transition for verified providers. + // Only runs for local CLI sessions — skip when fromUI is true because the dashboard + // calls triggerOAuth from an HTTP request handler; the server may be running in a + // foreground terminal (ccs api / ccs dashboard) where process.stdin.isTTY is true, + // so reaching InteractivePrompt.confirm would block the HTTP request on the server's + // stdin and show the consent prompt to the wrong audience. + // Dashboard parity for the opt-in belongs to Phase 6. + if (!fromUI) { + try { + await maybeOfferPoolRouting(provider, accountCountBeforeAdd); + } catch (promptErr) { + // A regenerateConfig or prompt failure must not fail triggerOAuth after a + // successful account registration — the account is already registered. + logger.stage( + 'auth', + 'cliproxy.pool-prompt.error', + 'Pool routing prompt failed (non-fatal)', + { provider }, + { level: 'warn' } + ); + if (process.env.CCS_DEBUG) { + console.error('[!] Pool routing prompt error (non-fatal):', promptErr); + } + } + } + logger.stage( 'auth', 'cliproxy.oauth.success', diff --git a/src/cliproxy/claude-shadow-warning.ts b/src/cliproxy/claude-shadow-warning.ts new file mode 100644 index 00000000..76496dd9 --- /dev/null +++ b/src/cliproxy/claude-shadow-warning.ts @@ -0,0 +1,179 @@ +/** + * Claude built-in provider — shadow warning and first-run routing notice + * + * Shadow warning + * -------------- + * The claude and anthropic provider names are reserved built-ins (priority 0.5). + * A user who has a settings profile or account profile named 'claude' or 'anthropic' + * will silently get the built-in rather than their own profile. This module emits + * a one-line, TTY-only, once-per-install warning with a rename hint in that case. + * + * First-run routing notice + * ------------------------ + * On the very first launch of `ccs claude` a one-line notice is printed to stderr + * reminding the user that traffic routes through the local CLIProxy instance and + * that bare `ccs` still launches native Claude Code. + * + * Persistence: marker files inside ~/.ccs/cliproxy/ are used for both notices. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { warn, info } from '../utils/ui'; +import { + getCcsDir, + loadOrCreateUnifiedConfig, + isUnifiedMode, +} from '../config/config-loader-facade'; +import { readConfig } from '../utils/config-manager'; +import { ProfileRegistry } from '../auth/profile-registry'; + +/** Marker file name inside ~/.ccs/cliproxy/ */ +const SHADOW_WARNED_MARKER = '.claude-shadow-warned'; + +/** Names that the claude built-in shadows */ +const SHADOWED_NAMES = new Set(['claude', 'anthropic']); + +/** Get path to the once-per-install dismissal marker. */ +function getShadowWarnedMarkerPath(): string { + return path.join(getCcsDir(), 'cliproxy', SHADOW_WARNED_MARKER); +} + +/** Return true if this install has already shown the shadow warning. */ +function shadowWarnAlreadyShown(): boolean { + try { + return fs.existsSync(getShadowWarnedMarkerPath()); + } catch { + return true; // If we can't read, skip warning. + } +} + +/** Persist the warning dismissal. */ +function markShadowWarnShown(): void { + try { + const dir = path.join(getCcsDir(), 'cliproxy'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(getShadowWarnedMarkerPath(), new Date().toISOString(), { + encoding: 'utf8', + flag: 'w', + }); + } catch { + // Best-effort — failure to persist is not fatal. + } +} + +/** + * Check if the user has a settings or account profile named 'claude' or 'anthropic' + * that is being shadowed by the built-in provider. + */ +function detectShadowedProfile(): string | null { + try { + if (isUnifiedMode()) { + const unified = loadOrCreateUnifiedConfig(); + const profileNames = Object.keys(unified.profiles || {}); + const accountNames = Object.keys(unified.accounts || {}); + const variantNames = Object.keys(unified.cliproxy?.variants || {}); + for (const name of [...profileNames, ...accountNames, ...variantNames]) { + if (SHADOWED_NAMES.has(name.toLowerCase())) return name; + } + } else { + const config = readConfig(); + const profileNames = Object.keys(config.profiles || {}); + const cliproxyNames = Object.keys(config.cliproxy || {}); + // Also check account-based profiles from profiles.json (mirror unified accounts check). + const registry = new ProfileRegistry(); + const accountProfileNames = registry.listProfiles(); + for (const name of [...profileNames, ...cliproxyNames, ...accountProfileNames]) { + if (SHADOWED_NAMES.has(name.toLowerCase())) return name; + } + } + } catch { + // If config is unreadable, skip the check silently. + } + return null; +} + +/** + * Emit the shadow warning if conditions are met. + * + * Conditions: + * - Running in a TTY (no warning in pipes/CI) + * - Warning not already shown for this install + * - User has a profile named 'claude' or 'anthropic' that the built-in shadows + * + * Writes to stderr so it does not pollute stdout piped output. + */ +export function maybeWarnClaudeShadow(): void { + // TTY guard — no warning in non-interactive or CI environments + if (!process.stderr.isTTY) return; + + if (shadowWarnAlreadyShown()) return; + + const shadowedName = detectShadowedProfile(); + if (!shadowedName) return; + + markShadowWarnShown(); + + process.stderr.write('\n'); + process.stderr.write( + warn( + `Profile '${shadowedName}' is shadowed by the built-in claude provider and cannot be reached via 'ccs ${shadowedName}'.` + ) + '\n' + ); + process.stderr.write( + ` Rename it to continue using it: ccs config (or edit ~/.ccs/config.yaml / config.json)\n` + ); + process.stderr.write('\n'); +} + +// ── First-run routing notice ────────────────────────────────────────────────── + +/** Marker file for the once-per-install routing notice. */ +const ROUTING_NOTICE_MARKER = '.claude-routing-noticed'; + +function getRoutingNoticeMarkerPath(): string { + return path.join(getCcsDir(), 'cliproxy', ROUTING_NOTICE_MARKER); +} + +function routingNoticeAlreadyShown(): boolean { + try { + return fs.existsSync(getRoutingNoticeMarkerPath()); + } catch { + return true; + } +} + +function markRoutingNoticeShown(): void { + try { + const dir = path.join(getCcsDir(), 'cliproxy'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(getRoutingNoticeMarkerPath(), new Date().toISOString(), { + encoding: 'utf8', + flag: 'w', + }); + } catch { + // Best-effort. + } +} + +/** + * Print a one-time routing notice when the claude built-in provider first launches. + * + * Informs the user that traffic is routed through the local CLIProxy instance + * (not the Anthropic API directly) and that bare `ccs` still uses native Claude Code. + * + * TTY-only; written to stderr. + */ +export function maybeShowClaudeRoutingNotice(): void { + if (!process.stderr.isTTY) return; + if (routingNoticeAlreadyShown()) return; + + markRoutingNoticeShown(); + + process.stderr.write( + info('ccs claude: traffic routes through the local CLIProxy instance.') + '\n' + ); + process.stderr.write( + ` Native Claude Code (direct Anthropic API) is still available via bare \`ccs\`.\n` + ); +} diff --git a/src/cliproxy/config/__tests__/claude-model-neutral.test.ts b/src/cliproxy/config/__tests__/claude-model-neutral.test.ts new file mode 100644 index 00000000..b9338b9c --- /dev/null +++ b/src/cliproxy/config/__tests__/claude-model-neutral.test.ts @@ -0,0 +1,587 @@ +/** + * Gap 1: claude provider is model-neutral (no ANTHROPIC_MODEL pins in env output). + * Snapshot test proving: + * - getClaudeEnvVars('claude') emits no model env vars + * - getClaudeEnvVars('gemini') still emits model env vars (no spillover) + * - ensureProviderSettings does not write model pins for claude + */ + +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, getRemoteEnvVars } from '../env-builder'; +import { clearConfigCache } from '../base-config-loader'; + +const MODEL_KEYS = [ + 'ANTHROPIC_MODEL', + 'ANTHROPIC_DEFAULT_OPUS_MODEL', + 'ANTHROPIC_DEFAULT_SONNET_MODEL', + 'ANTHROPIC_DEFAULT_HAIKU_MODEL', +] as const; + +describe('claude provider model-neutral passthrough (Gap 1)', () => { + let tempHome: string; + let originalCcsHome: string | undefined; + + beforeEach(() => { + originalCcsHome = process.env.CCS_HOME; + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-claude-neutral-')); + process.env.CCS_HOME = tempHome; + clearConfigCache(); + }); + + afterEach(() => { + process.env.CCS_HOME = originalCcsHome; + fs.rmSync(tempHome, { recursive: true, force: true }); + clearConfigCache(); + }); + + it('emits no model env vars for the claude provider', () => { + const env = getClaudeEnvVars('claude'); + + for (const key of MODEL_KEYS) { + expect(env[key]).toBeUndefined(); + } + }); + + it('still sets ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN for claude', () => { + const env = getClaudeEnvVars('claude'); + + expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/claude'); + expect(env.ANTHROPIC_AUTH_TOKEN).toBeDefined(); + }); + + it('still emits model env vars for gemini provider (no spillover)', () => { + const env = getClaudeEnvVars('gemini'); + + for (const key of MODEL_KEYS) { + expect(typeof env[key]).toBe('string'); + expect((env[key] as string).length).toBeGreaterThan(0); + } + }); + + it('ensureProviderSettings does not write model pins for claude', () => { + process.env.CCS_HOME = tempHome; + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + ensureProviderSettings('claude'); + + // Non-cursor providers use the legacy top-level path: ~/.ccs/claude.settings.json + const settingsPath = path.join(ccsDir, 'claude.settings.json'); + expect(fs.existsSync(settingsPath)).toBe(true); + + const written = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { + env: Record; + }; + + for (const key of MODEL_KEYS) { + // Model keys must not be present (or must be undefined/empty) + const value = written.env[key]; + expect(!value || value.trim().length === 0).toBe(true); + } + + // Transport keys must be present + expect(written.env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/claude'); + expect(written.env.ANTHROPIC_AUTH_TOKEN).toBeDefined(); + }); + + // ── Upgrade-path: existing claude.settings.json with stale default model pins ── + + it('strips stale default model pins from existing claude.settings.json on ensureProviderSettings', () => { + process.env.CCS_HOME = tempHome; + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + // Simulate a user who ran `ccs claude` before the model-neutral change. + // These are the exact default values that were auto-written by older CCS. + const stalePins = { + 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', + }; + 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', + ...stalePins, + }, + }), + 'utf-8' + ); + + ensureProviderSettings('claude'); + + const repaired = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { + env: Record; + }; + + // All stale pins must be removed + for (const key of MODEL_KEYS) { + const value = repaired.env[key]; + expect(!value || value.trim().length === 0).toBe(true); + } + + // Transport keys must still be present + expect(repaired.env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/claude'); + expect(repaired.env.ANTHROPIC_AUTH_TOKEN).toBeDefined(); + }); + + it('preserves user-customised model pin that differs from the stale default', () => { + process.env.CCS_HOME = tempHome; + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + const settingsPath = path.join(ccsDir, 'claude.settings.json'); + // User has customised ANTHROPIC_MODEL to a non-default value + 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-7', // customised — not the stale sonnet default + }, + }), + 'utf-8' + ); + + ensureProviderSettings('claude'); + + const result = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { + env: Record; + }; + + // Custom value must be preserved + expect(result.env.ANTHROPIC_MODEL).toBe('claude-opus-4-7'); + }); + + // ── One-shot migration guard (launch N+1) ───────────────────────────────────── + + it('stale-pin migration runs at most once (marker prevents re-strip on launch N+1)', () => { + process.env.CCS_HOME = tempHome; + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + // Pre-place the migration marker so migration is considered done. + const markerDir = path.join(ccsDir, 'cliproxy'); + fs.mkdirSync(markerDir, { recursive: true }); + fs.writeFileSync(path.join(markerDir, '.claude-model-migrated'), new Date().toISOString()); + + // Write a settings file that contains stale default pins. + const stalePins = { + ANTHROPIC_MODEL: 'claude-sonnet-4-6', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-7', + }; + 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', + ...stalePins, + }, + }), + 'utf-8' + ); + + ensureProviderSettings('claude'); + + const result = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { + env: Record; + }; + + // Migration already marked done — stale pins must NOT be stripped again. + // This proves a user re-pin that equals a stale default value survives on launch N+1. + expect(result.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6'); + expect(result.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-7'); + }); + + it('settings file without ANTHROPIC_MODEL triggers no rewrite after migration marker set', () => { + process.env.CCS_HOME = tempHome; + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + // Pre-place the migration marker. + const markerDir = path.join(ccsDir, 'cliproxy'); + fs.mkdirSync(markerDir, { recursive: true }); + fs.writeFileSync(path.join(markerDir, '.claude-model-migrated'), new Date().toISOString()); + + 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', + }, + }, + null, + 2 + ); + fs.writeFileSync(settingsPath, originalContent + '\n', 'utf-8'); + const statBefore = fs.statSync(settingsPath); + + ensureProviderSettings('claude'); + + const statAfter = fs.statSync(settingsPath); + // mtime should not change — no rewrite triggered + expect(statAfter.mtimeMs).toBe(statBefore.mtimeMs); + + const result = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { + env: Record; + }; + // ANTHROPIC_MODEL must still be absent + expect(result.env.ANTHROPIC_MODEL).toBeUndefined(); + }); + + // ── Historical-default set coverage ────────────────────────────────────────── + + it('strips gen-2 pins (sonnet-4-5-20250929 / opus-4-5-20251101) on migration', () => { + 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-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' + ); + + ensureProviderSettings('claude'); + + const result = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { + env: Record; + }; + + for (const key of MODEL_KEYS) { + const value = result.env[key]; + expect(!value || value.trim().length === 0).toBe(true); + } + }); + + it('strips gen-3 mixed pin (opus-4-6) on migration', () => { + 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-sonnet-4-6', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-3-5-20241022', + }, + }), + 'utf-8' + ); + + ensureProviderSettings('claude'); + + const result = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { + env: Record; + }; + + for (const key of MODEL_KEYS) { + const value = result.env[key]; + expect(!value || value.trim().length === 0).toBe(true); + } + }); + + it('preserves an explicit user pin to a value not in any stale-defaults set (e.g. claude-opus-4-8)', () => { + 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', + }, + }), + 'utf-8' + ); + + ensureProviderSettings('claude'); + + const result = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { + env: Record; + }; + + // claude-opus-4-8 is not in any stale-defaults set; must survive migration + expect(result.env.ANTHROPIC_MODEL).toBe('claude-opus-4-8'); + }); + + // ── Fresh-file migration marker and tier-pin survival ───────────────────────── + + it('marks migration done on fresh-file creation so tier pins written by ccs claude --config survive', () => { + 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'); + + // Step 1: fresh ensureProviderSettings creates the file and the marker. + expect(fs.existsSync(settingsPath)).toBe(false); + ensureProviderSettings('claude'); + expect(fs.existsSync(settingsPath)).toBe(true); + expect(fs.existsSync(markerPath)).toBe(true); + + // Step 2: simulate 'ccs claude --config' pinning all four tier models. + const tierPins = { + 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 existing = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { + env: Record; + }; + fs.writeFileSync( + settingsPath, + JSON.stringify({ ...existing, env: { ...existing.env, ...tierPins } }, null, 2) + '\n', + 'utf-8' + ); + + // Step 3: second ensureProviderSettings must NOT strip the user-written pins + // because the migration marker already exists. + ensureProviderSettings('claude'); + + const result = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { + env: Record; + }; + 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'); + }); + + // ── 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; + }; + 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; + }; + 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; + }; + 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'); + }); + + it('getRemoteEnvVars (claude): migration marker set means pins are user-intentional and NOT filtered', () => { + process.env.CCS_HOME = tempHome; + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + // Marker present: the file was already cleaned once, so any pin that + // exists now was put there deliberately (e.g. ccs claude --config picked + // a value that happens to equal a historical default). + const markerDir = path.join(ccsDir, 'cliproxy'); + fs.mkdirSync(markerDir, { recursive: true }); + fs.writeFileSync(path.join(markerDir, '.claude-model-migrated'), new Date().toISOString()); + + 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-sonnet-4-6', // equals a stale default, but post-migration = explicit + }, + }), + 'utf-8' + ); + + const env = getRemoteEnvVars('claude', { + host: 'example.com', + port: 8317, + protocol: 'http', + }); + + expect(env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6'); + }); +}); diff --git a/src/cliproxy/config/base-config-loader.ts b/src/cliproxy/config/base-config-loader.ts index 4f64121c..d4ea308f 100644 --- a/src/cliproxy/config/base-config-loader.ts +++ b/src/cliproxy/config/base-config-loader.ts @@ -16,10 +16,10 @@ interface BaseSettings { env: { ANTHROPIC_BASE_URL: string; ANTHROPIC_AUTH_TOKEN: string; - ANTHROPIC_MODEL: string; - ANTHROPIC_DEFAULT_OPUS_MODEL: string; - ANTHROPIC_DEFAULT_SONNET_MODEL: string; - ANTHROPIC_DEFAULT_HAIKU_MODEL: string; + ANTHROPIC_MODEL?: string; + ANTHROPIC_DEFAULT_OPUS_MODEL?: string; + ANTHROPIC_DEFAULT_SONNET_MODEL?: string; + ANTHROPIC_DEFAULT_HAIKU_MODEL?: string; }; } @@ -66,16 +66,20 @@ export function loadBaseConfig(provider: CLIProxyProvider): BaseSettings { throw new Error('Missing or invalid "env" object'); } - const required = [ - 'ANTHROPIC_MODEL', - 'ANTHROPIC_DEFAULT_OPUS_MODEL', - 'ANTHROPIC_DEFAULT_SONNET_MODEL', - 'ANTHROPIC_DEFAULT_HAIKU_MODEL', - ]; + // claude provider is model-neutral: it does not pin model env vars so that + // the user's own Claude Code model selection is respected end-to-end. + if (provider !== 'claude') { + const required = [ + 'ANTHROPIC_MODEL', + 'ANTHROPIC_DEFAULT_OPUS_MODEL', + 'ANTHROPIC_DEFAULT_SONNET_MODEL', + 'ANTHROPIC_DEFAULT_HAIKU_MODEL', + ]; - for (const field of required) { - if (!settings.env[field as keyof BaseSettings['env']]) { - throw new Error(`Missing required field: env.${field}`); + for (const field of required) { + if (!settings.env[field as keyof BaseSettings['env']]) { + throw new Error(`Missing required field: env.${field}`); + } } } @@ -90,14 +94,17 @@ export function loadBaseConfig(provider: CLIProxyProvider): BaseSettings { /** * Get model mapping from base config - * Extracts model names from env vars + * Extracts model names from env vars. + * Returns undefined model fields for the claude provider (model-neutral passthrough). */ export function getModelMappingFromConfig(provider: CLIProxyProvider): ProviderModelMapping { const config = loadBaseConfig(provider); + // claude is model-neutral: ANTHROPIC_MODEL is absent from its config; callers + // that need model IDs (e.g. getClaudeEnvVars) guard on provider !== 'claude'. return { - defaultModel: config.env.ANTHROPIC_MODEL, - claudeModel: config.env.ANTHROPIC_MODEL, + defaultModel: config.env.ANTHROPIC_MODEL ?? '', + claudeModel: config.env.ANTHROPIC_MODEL ?? '', opusModel: config.env.ANTHROPIC_DEFAULT_OPUS_MODEL, sonnetModel: config.env.ANTHROPIC_DEFAULT_SONNET_MODEL, haikuModel: config.env.ANTHROPIC_DEFAULT_HAIKU_MODEL, diff --git a/src/cliproxy/config/env-builder.ts b/src/cliproxy/config/env-builder.ts index 4b66f87f..998a10f1 100644 --- a/src/cliproxy/config/env-builder.ts +++ b/src/cliproxy/config/env-builder.ts @@ -31,7 +31,7 @@ import { normalizeIFlowLegacyModelAliases, normalizeModelIdForProvider, } from '../ai-providers/model-id-normalizer'; -import { getGlobalEnvConfig } from '../../config/config-loader-facade'; +import { getGlobalEnvConfig, getCcsDir } from '../../config/config-loader-facade'; /** Settings file structure for user overrides */ interface ProviderSettings { @@ -52,6 +52,9 @@ const REQUIRED_PROVIDER_ENV_KEYS = [ 'ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL', ] as const; + +/** Minimum required env vars for the claude built-in provider (model-neutral). */ +const REQUIRED_CLAUDE_ENV_KEYS = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN'] as const; const CURSOR_LEGACY_ENV_OVERRIDE_KEYS = new Set([ 'ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', @@ -200,28 +203,18 @@ export function getModelMapping(provider: CLIProxyProvider): ProviderModelMappin * Get environment variables for Claude CLI (bundled defaults) * Uses provider-specific endpoint (e.g., /api/provider/gemini) for explicit routing. * This enables concurrent gemini/codex usage - each session routes to its provider via URL path. + * + * For the claude built-in provider the model env vars are intentionally omitted so that + * the user's own Claude Code /model selection is honored end-to-end (model-neutral passthrough). */ export function getClaudeEnvVars( provider: CLIProxyProvider, port: number = CLIPROXY_DEFAULT_PORT ): NodeJS.ProcessEnv { - const models = getModelMapping(provider); - // Base env vars from config file (includes ANTHROPIC_MAX_TOKENS, etc.) const baseEnvVars = getEnvVarsFromConfig(provider); - // Core env vars that we always set dynamically - const coreEnvVars = { - // Provider-specific endpoint - routes to correct provider via URL path - ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}/api/provider/${provider}`, - ANTHROPIC_AUTH_TOKEN: getEffectiveApiKey(), - ANTHROPIC_MODEL: models.claudeModel, - ANTHROPIC_DEFAULT_OPUS_MODEL: models.opusModel || models.claudeModel, - ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnetModel || models.claudeModel, - ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haikuModel || models.claudeModel, - }; - - // Filter out core env vars from base config to avoid conflicts + // Filter out model pins and URL/auth from base config (we set them dynamically) const { ANTHROPIC_BASE_URL: _baseUrl, ANTHROPIC_AUTH_TOKEN: _authToken, @@ -232,6 +225,22 @@ export function getClaudeEnvVars( ...additionalEnvVars } = baseEnvVars; + // Core transport env vars set dynamically for all providers + const coreEnvVars: NodeJS.ProcessEnv = { + ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}/api/provider/${provider}`, + ANTHROPIC_AUTH_TOKEN: getEffectiveApiKey(), + }; + + // Model pins: omitted for claude provider (model-neutral passthrough). + // For all other providers, set model vars from the base config model mapping. + if (provider !== 'claude') { + const models = getModelMapping(provider); + coreEnvVars.ANTHROPIC_MODEL = models.claudeModel; + coreEnvVars.ANTHROPIC_DEFAULT_OPUS_MODEL = models.opusModel || models.claudeModel; + coreEnvVars.ANTHROPIC_DEFAULT_SONNET_MODEL = models.sonnetModel || models.claudeModel; + coreEnvVars.ANTHROPIC_DEFAULT_HAIKU_MODEL = models.haikuModel || models.claudeModel; + } + // Merge core env vars with additional env vars from base config const mergedEnv = { ...coreEnvVars, @@ -509,6 +518,98 @@ export function getEffectiveEnvVars( return { ...globalEnv, ...getClaudeEnvVars(provider, port) }; } +/** + * All historically-shipped default model pins that CCS auto-wrote into + * claude.settings.json before the model-neutral passthrough change. + * A key is removed only when the stored value exactly matches one of the values + * in that key's set, so user-customised values are always preserved. + */ +const CLAUDE_STALE_MODEL_DEFAULTS: Record> = { + ANTHROPIC_MODEL: new Set([ + 'claude-sonnet-4-20250514', + 'claude-sonnet-4-5-20250929', + 'claude-sonnet-4-6', + ]), + ANTHROPIC_DEFAULT_OPUS_MODEL: new Set([ + 'claude-opus-4-20250514', + 'claude-opus-4-5-20251101', + 'claude-opus-4-6', + 'claude-opus-4-7', + ]), + ANTHROPIC_DEFAULT_SONNET_MODEL: new Set([ + 'claude-sonnet-4-20250514', + 'claude-sonnet-4-5-20250929', + 'claude-sonnet-4-6', + ]), + ANTHROPIC_DEFAULT_HAIKU_MODEL: new Set([ + 'claude-haiku-3-5-20241022', + 'claude-haiku-4-5-20251001', + ]), +}; + +/** Marker file that records when the one-time stale-pin migration has run. */ +const CLAUDE_MODEL_MIGRATED_MARKER = '.claude-model-migrated'; + +/** Return true if the one-time stale-pin migration has already been applied. */ +function claudeModelMigrationDone(): boolean { + try { + return fs.existsSync(path.join(getCcsDir(), 'cliproxy', CLAUDE_MODEL_MIGRATED_MARKER)); + } catch { + return true; // Cannot read — treat as done to avoid repeated rewrites. + } +} + +/** Record that the one-time stale-pin migration has been applied. */ +function markClaudeModelMigrationDone(): void { + try { + const dir = path.join(getCcsDir(), 'cliproxy'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, CLAUDE_MODEL_MIGRATED_MARKER), new Date().toISOString(), { + encoding: 'utf8', + flag: 'w', + }); + } catch { + // Best-effort — failure to persist is not fatal. + } +} + +/** + * Remove stale model pins from an existing claude.settings.json env block. + * Only removes keys whose values appear in the set of historically-shipped + * defaults for that key, preserving user-customised values. + * Returns true when at least one key was removed (signals file needs rewriting). + */ +function migrateClaudeStaleModelPins(env: Record): boolean { + let mutated = false; + for (const [key, staleValues] of Object.entries(CLAUDE_STALE_MODEL_DEFAULTS)) { + if (staleValues.has(env[key])) { + delete env[key]; + mutated = true; + } + } + 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): Record { + 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 @@ -525,8 +626,13 @@ export function ensureProviderSettings(provider: CLIProxyProvider): void { }; // Create initial file when missing. + // A freshly created file has no stale pins by construction, so mark migration + // done immediately to prevent the one-time strip from running unnecessarily. if (!fs.existsSync(settingsPath)) { writeSettings({ env: defaultEnv }); + if (provider === 'claude') { + markClaudeModelMigrationDone(); + } return; } @@ -564,11 +670,28 @@ export function ensureProviderSettings(provider: CLIProxyProvider): void { : {}; let mutated = !(envCandidate && typeof envCandidate === 'object' && !Array.isArray(envCandidate)); - for (const key of REQUIRED_PROVIDER_ENV_KEYS) { + + // One-time migration: strip stale model pins written by older CCS versions into + // claude.settings.json. Guarded by a marker file so a user-re-pin that happens + // to equal a stale default value is not silently stripped on every subsequent launch. + if (provider === 'claude' && !claudeModelMigrationDone()) { + if (migrateClaudeStaleModelPins(mergedEnv)) { + mutated = true; + } + markClaudeModelMigrationDone(); + } + + // claude is model-neutral: only transport keys (URL + auth) are required; model pins are omitted. + const requiredKeys = + provider === 'claude' ? REQUIRED_CLAUDE_ENV_KEYS : REQUIRED_PROVIDER_ENV_KEYS; + for (const key of requiredKeys) { const current = mergedEnv[key]; if (typeof current !== 'string' || current.trim().length === 0) { - mergedEnv[key] = defaultEnv[key] || ''; - mutated = true; + const fallback = defaultEnv[key]; + if (fallback) { + mergedEnv[key] = fallback; + mutated = true; + } } } @@ -680,6 +803,21 @@ export function getRemoteEnvVars( migrateDeprecatedModelNames(settingsPath, provider, settings); migrateIFlowPlaceholderModel(settingsPath, provider, settings); userEnvVars = settings.env as Record; + // 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. Once the migration marker + // exists the file has already been cleaned, so any pin present after + // that is user-intentional (e.g. an explicit `ccs claude --config` + // pick that happens to equal a historical default) and must NOT be + // filtered. Priority 1 (explicit custom settings path) is + // intentionally left untouched: an explicitly passed settings file + // is the user's deliberate choice. + if (provider === 'claude' && !claudeModelMigrationDone()) { + userEnvVars = filterClaudeStaleModelPins(userEnvVars); + } } } catch { // Invalid JSON - fall through to base config @@ -689,7 +827,6 @@ export function getRemoteEnvVars( // Priority 3: Base config defaults if (Object.keys(userEnvVars).length === 0) { - const models = getModelMapping(provider); const baseEnvVars = getEnvVarsFromConfig(provider); // Filter out URL/auth from base config (we'll set those from remote config) const { @@ -697,13 +834,25 @@ export function getRemoteEnvVars( ANTHROPIC_AUTH_TOKEN: _authToken, ...additionalEnvVars } = baseEnvVars; - userEnvVars = { - ...additionalEnvVars, - ANTHROPIC_MODEL: models.claudeModel, - ANTHROPIC_DEFAULT_OPUS_MODEL: models.opusModel || models.claudeModel, - ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnetModel || models.claudeModel, - ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haikuModel || models.claudeModel, - }; + // claude is model-neutral: omit model pins so Claude Code's own /model + // selection is respected on remote launches too. + if (provider === 'claude') { + // Filter out undefined values coming from NodeJS.ProcessEnv spread. + userEnvVars = Object.fromEntries( + Object.entries(additionalEnvVars).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string' + ) + ); + } else { + const models = getModelMapping(provider); + userEnvVars = { + ...additionalEnvVars, + ANTHROPIC_MODEL: models.claudeModel, + ANTHROPIC_DEFAULT_OPUS_MODEL: models.opusModel || models.claudeModel, + ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnetModel || models.claudeModel, + ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haikuModel || models.claudeModel, + }; + } } // Build final env: global + user settings + remote URL/auth override diff --git a/src/cliproxy/config/generator.ts b/src/cliproxy/config/generator.ts index ac8c19c6..a12906fb 100644 --- a/src/cliproxy/config/generator.ts +++ b/src/cliproxy/config/generator.ts @@ -44,8 +44,9 @@ export const CCS_CONTROL_PANEL_SECRET = 'ccs'; * v17: Persist routing.strategy from CCS unified config * v18: Persist routing.session-affinity and routing.session-affinity-ttl from CCS unified config * v19: Persist backend-aware management panel repository from CCS unified config + * v20: Pool-gated cooling/routing/retry-cap block; disable-cooling flips to false for pool users */ -export const CLIPROXY_CONFIG_VERSION = 19; +export const CLIPROXY_CONFIG_VERSION = 20; export const ORIGINAL_MANAGEMENT_PANEL_REPOSITORY = 'https://github.com/router-for-me/Cli-Proxy-API-Management-Center'; @@ -138,6 +139,45 @@ function getLoggingSettings(): { loggingToFile: boolean; requestLog: boolean } { }; } +/** + * Check whether pool routing is enabled in the CCS unified config. + * + * Cooling archaeology (CCS v5, commit fb77d72a, Jan 26 2026): + * disable-cooling: true was added for stability because single-account users + * hit a blackout cliff when their only credential entered cooldown. Two upstream + * bugs compounded the issue: + * 1. Transient-error blackout (fixed upstream Jan 21 2026, commit 30a59168) + * 2. 401/402/403/404 ignoring the disable-cooling flag entirely + * (fixed upstream Apr 7 2026, commit 0ea76801) + * + * The concern no longer applies on current CLIProxy versions (fallback 6.9.45, + * which postdates both fixes). For pool users (2+ accounts per provider) + * cooling-ON is the correct behavior: a suspended credential rotates out and + * a healthy one takes over, which is the pool-routing value proposition. + * For single-account users the original stability concern still holds, so + * disable-cooling: true is preserved when pool routing is disabled. + * + * Per-auth metadata override cannot be used to enable cooling — the upstream + * override is "true-only" (types.go:384-404): a false per-auth value falls back + * to the global flag. The flip must be at the top-level config key. + * + * Retry-cap note: max-retry-credentials is ONLY valid with cooling ON. + * Without cooling, a just-exhausted credential is still "available" on the + * next request; retry-cap would not prevent re-targeting it. + */ +function isPoolRoutingEnabled(): boolean { + return loadOrCreateUnifiedConfig().cliproxy?.pool_routing?.enabled === true; +} + +/** + * Get max-retry-credentials for pool routing config. + * Only consulted when pool routing is enabled. + * Defaults to 3 (try up to 3 credentials before returning 429 to caller). + */ +function getPoolMaxRetryCredentials(): number { + return loadOrCreateUnifiedConfig().cliproxy?.pool_routing?.max_retry_credentials ?? 3; +} + function getRoutingStrategy(): 'round-robin' | 'fill-first' { const config = loadOrCreateUnifiedConfig(); return config.cliproxy?.routing?.strategy === 'fill-first' ? 'fill-first' : 'round-robin'; @@ -615,6 +655,7 @@ function generateUnifiedConfigContent( // Get logging settings from user config (disabled by default) const { loggingToFile, requestLog } = getLoggingSettings(); + const poolEnabled = isPoolRoutingEnabled(); const routingStrategy = getRoutingStrategy(); const sessionAffinityEnabled = getSessionAffinityEnabled(); const sessionAffinityTtl = getSessionAffinityTtl(); @@ -632,6 +673,29 @@ function generateUnifiedConfigContent( ); const apiKeysYaml = allApiKeys.map((key) => ` - "${key}"`).join('\n'); + // Pool routing block: emitted only when pool routing is enabled. + // When pool routing is off, disable-cooling stays true (v5 stability default). + // See isPoolRoutingEnabled() and the cooling archaeology comment above it. + // + // Hot-reload note: CLIProxy watches config for changes and hot-reloads it + // (server.go calls auth.SetQuotaCooldownDisabled on config update). + // Changing pool routing state writes a new config; CLIProxy will pick it up + // live and evict all SessionAffinity pins on the next request (one recompute + // per conversation). A restart is not required. + const poolMaxRetry = poolEnabled ? getPoolMaxRetryCredentials() : null; + const disableCoolingValue = poolEnabled ? 'false' : 'true'; + const coolingComment = poolEnabled + ? '# Pool routing enabled: cooling ON so exhausted accounts enter backoff and rotate out.\n# First 429 gets a 1s backoff (exponential to 30m cap); Retry-After header is honored.\n# Retry-cap below stops burn loops from retrying already-known-bad credentials.' + : '# Disable quota cooldown scheduling for stability.\n# Pool routing is off: cooling stays disabled to prevent single-account blackouts.\n# Re-enabled automatically when pool routing is turned on (ccs cliproxy pool --enable).'; + const poolRoutingBlock = poolEnabled + ? `\n# Max credentials to try per request before returning 429 to caller.\n# ONLY valid with cooling on (above). Without cooling a just-exhausted credential\n# remains "available" and retry-cap would not prevent re-targeting it.\nmax-retry-credentials: ${poolMaxRetry}\n` + : ''; + const routingBlock = `# Credential selection strategy when multiple matching accounts are available +routing: + strategy: ${poolEnabled ? 'fill-first' : routingStrategy} + session-affinity: ${poolEnabled ? 'true' : sessionAffinityEnabled} + session-affinity-ttl: "${poolEnabled ? '1h' : sessionAffinityTtl}"`; + // Unified config with enhanced CLIProxyAPI features const config = `# CLIProxyAPI config generated by CCS v${CLIPROXY_CONFIG_VERSION} # Supports: gemini, codex, agy, qwen, iflow (concurrent usage) @@ -683,24 +747,20 @@ remote-management: # Reliability & Quota Management # ============================================================================= -# Disable quota cooldown scheduling for stability -disable-cooling: true +${coolingComment} +disable-cooling: ${disableCoolingValue} # Auto-retry on transient errors (403, 408, 500, 502, 503, 504) request-retry: 0 max-retry-interval: 0 - +${poolRoutingBlock} # Auto-switch accounts on quota exceeded (429) # This enables seamless multi-account rotation when rate limited quota-exceeded: switch-project: true switch-preview-model: true -# Credential selection strategy when multiple matching accounts are available -routing: - strategy: ${routingStrategy} - session-affinity: ${sessionAffinityEnabled} - session-affinity-ttl: "${sessionAffinityTtl}" +${routingBlock} # ============================================================================= # Authentication diff --git a/src/cliproxy/executor/__tests__/auth-coordinator.test.ts b/src/cliproxy/executor/__tests__/auth-coordinator.test.ts index d7617fe6..bfe8ec14 100644 --- a/src/cliproxy/executor/__tests__/auth-coordinator.test.ts +++ b/src/cliproxy/executor/__tests__/auth-coordinator.test.ts @@ -484,4 +484,17 @@ describe('ensureModelConfiguration', () => { await ensureModelConfiguration('codex', cfg, false); expect(mockReconcileCodexModel).not.toHaveBeenCalled(); }); + + // claude is model-neutral passthrough — must never auto-prompt at launch + it('claude non-composite → configureProviderModel NOT called (model-neutral)', async () => { + const cfg = { isComposite: false, customSettingsPath: undefined } as ExecutorConfig; + await ensureModelConfiguration('claude', cfg, false); + expect(mockConfigureProviderModel).not.toHaveBeenCalled(); + }); + + it('claude composite → configureProviderModel NOT called', async () => { + const cfg = { isComposite: true } as ExecutorConfig; + await ensureModelConfiguration('claude', cfg, false); + expect(mockConfigureProviderModel).not.toHaveBeenCalled(); + }); }); diff --git a/src/cliproxy/executor/auth-coordinator.ts b/src/cliproxy/executor/auth-coordinator.ts index 4e1f2f42..9a30b930 100644 --- a/src/cliproxy/executor/auth-coordinator.ts +++ b/src/cliproxy/executor/auth-coordinator.ts @@ -369,13 +369,17 @@ export function runAccountSafetyGuards( * Ensure provider model is configured on first run. * Skipped for composite variants and remote proxy mode. * Also reconciles Codex model for active plan. + * + * claude is model-neutral passthrough: the user's own /model selection inside + * Claude Code governs which model is used, so no auto-prompt at launch. + * Use `ccs claude --config` for an explicit pin opt-in. */ export async function ensureModelConfiguration( provider: CLIProxyProvider, cfg: ExecutorConfig, verbose: boolean ): Promise { - if (!cfg.isComposite && supportsModelConfig(provider)) { + if (!cfg.isComposite && provider !== 'claude' && supportsModelConfig(provider)) { await configureProviderModel(provider, false, cfg.customSettingsPath); } diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index 0a5ee7fa..7fc457aa 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -67,6 +67,7 @@ import { resolveExecutorProxy, resolveExecutorProxyConfig } from './proxy-resolv import { buildProxyChain } from './proxy-chain-builder'; import { warnBrokenModels } from './model-warnings'; import { launchClaude } from './claude-launcher'; +import { maybeWarnClaudeShadow, maybeShowClaudeRoutingNotice } from '../claude-shadow-warning'; /** Local alias so internal call sites need no change */ const resolveRuntimeQuotaMonitorProviders = _resolveRuntimeQuotaMonitorProviders; @@ -161,6 +162,11 @@ export async function execClaudeWithCLIProxy( const providerConfig = getProviderConfig(provider); log(`Provider: ${providerConfig.displayName}`); + // claude built-in: warn once if a user profile is being shadowed + if (provider === 'claude') { + maybeWarnClaudeShadow(); + } + // Variables for local proxy mode let sessionId: string | undefined; @@ -224,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); } @@ -279,6 +296,11 @@ export async function execClaudeWithCLIProxy( // 5. Check for broken models (multi-tier for composite) warnBrokenModels({ provider, cfg, compositeProviders, skipLocalAuth }); + // 5a. claude built-in: one-time routing notice (first launch only) + if (provider === 'claude') { + maybeShowClaudeRoutingNotice(); + } + // 6. Ensure user settings file exists ensureProviderSettingsFile(provider); diff --git a/src/cliproxy/quota/quota-manager.ts b/src/cliproxy/quota/quota-manager.ts index fcef7c44..81f38150 100644 --- a/src/cliproxy/quota/quota-manager.ts +++ b/src/cliproxy/quota/quota-manager.ts @@ -262,6 +262,26 @@ export function isOnCooldown(provider: CLIProxyProvider, accountId: string): boo return true; } +/** + * Get the epoch-ms timestamp when an account's in-memory cooldown expires. + * Returns undefined when the account is not on cooldown (or the cooldown has + * already lapsed). This is the process-local cooldown; for the cross-process + * source of truth see readQuotaCooldownEntries() in account-safety. + */ +export function getCooldownUntil( + provider: CLIProxyProvider, + accountId: string +): number | undefined { + const key = getCacheKey(provider, accountId); + const entry = cooldownMap.get(key); + if (!entry) return undefined; + if (Date.now() > entry.until) { + cooldownMap.delete(key); + return undefined; + } + return entry.until; +} + /** * Apply cooldown to an exhausted account */ diff --git a/src/cliproxy/routing/__tests__/routing-strategy.test.ts b/src/cliproxy/routing/__tests__/routing-strategy.test.ts index 959e63bd..3005620f 100644 --- a/src/cliproxy/routing/__tests__/routing-strategy.test.ts +++ b/src/cliproxy/routing/__tests__/routing-strategy.test.ts @@ -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'); + }); + }); }); diff --git a/src/cliproxy/routing/pool-onboarding-hint.ts b/src/cliproxy/routing/pool-onboarding-hint.ts new file mode 100644 index 00000000..1eb1ad0e --- /dev/null +++ b/src/cliproxy/routing/pool-onboarding-hint.ts @@ -0,0 +1,190 @@ +/** + * Pool onboarding hint + * + * Fires once (per install, TTY only) when a user has >= 2 native Claude account + * profiles and has not yet enabled pool routing. Covers the #1464 use case: + * existing users with multiple native profiles who have never touched CLIProxy + * and would miss the 1->2 account-add opt-in prompt. + * + * Three hint sites call this module: + * 1. ccs doctor (post-checks summary, fires for ALL install types) + * 2. account-flow launch (print-only, pre-spawn) - unified config only + * 3. ccs auth create (before spawning Claude for a 2nd+ profile) - unified config only + * + * Sites 2 and 3 are gated on hasUnifiedConfig() so that legacy profiles.json-only + * installs receive the hint exclusively from ccs doctor (site 1). This ensures + * once-per-install dismissal semantics for every install type: unified installs + * dismiss via config.yaml on first show; legacy installs see the hint from + * doctor only (which is the natural discovery surface for that population). + * + * Dismissal: stored in pool_routing.onboarding_hint_dismissed in config.yaml. + * Reuses the Phase 3 pool_routing schema family - one schema home, no duplicate + * plumbing. Legacy installs cannot persist a dismissal without config.yaml; + * this is intentional - dismissal for legacy users happens via ccs migrate or + * pool enable (both create config.yaml). + * + * TTY contract: non-TTY sessions never print the hint (single line only). + * The hint is informational only - it never blocks the calling flow. + * + * History note: pooled sessions live in the CLIProxy lane, not the native + * profile's CLAUDE_CONFIG_DIR lane. Transcripts and --continue inventory do + * NOT follow. This is stated on the docs page linked in the hint copy. + */ + +import { info } from '../../utils/ui'; +import { + loadOrCreateUnifiedConfig, + mutateConfig, + hasUnifiedConfig, +} from '../../config/config-loader-facade'; +import type { UnifiedConfig } from '../../config/unified-config-types'; +import { ProfileRegistry } from '../../auth/profile-registry'; + +// Docs anchor - Phase 7 acceptance item: verify curl returns HTTP 200 before release. +// Pattern matches canonical docs domain used in error-codes.ts and version-command.ts. +export const POOL_DOCS_LINK = 'https://docs.ccs.kaitran.ca/features/pool'; + +/** + * Whether the one-time pool onboarding hint has already been dismissed. + * + * @param config - Optional pre-loaded config to avoid a redundant disk read + + * YAML parse. When omitted the config is loaded here. + */ +export function isOnboardingHintDismissed(config?: UnifiedConfig): boolean { + const cfg = config ?? loadOrCreateUnifiedConfig(); + return cfg.cliproxy?.pool_routing?.onboarding_hint_dismissed === true; +} + +/** + * Mark the pool onboarding hint as permanently dismissed. + * Written after first display so subsequent invocations are silent. + * + * Guard: skipped when config.yaml does not yet exist (legacy profiles.json-only + * installs). Writing here would create config.yaml, flipping isUnifiedMode() + * and silently mode-migrating the install outside the deliberate ccs migrate + * flow. For those users the hint may re-appear across process restarts; that + * is acceptable -- the hint is informational and non-blocking, and the user + * can silence it permanently via ccs migrate. + */ +export function dismissOnboardingHint(): void { + if (!hasUnifiedConfig()) { + // Legacy-only install: skip persist to avoid implicit config.yaml creation. + return; + } + mutateConfig((cfg) => { + if (!cfg.cliproxy) return; + cfg.cliproxy.pool_routing = { + ...cfg.cliproxy.pool_routing, + onboarding_hint_dismissed: true, + }; + }); +} + +/** + * Whether pool routing is currently enabled. + * + * @param config - Optional pre-loaded config to avoid a redundant disk read + + * YAML parse. When omitted the config is loaded here. + */ +function isPoolEnabled(config?: UnifiedConfig): boolean { + const cfg = config ?? loadOrCreateUnifiedConfig(); + return cfg.cliproxy?.pool_routing?.enabled === true; +} + +/** + * Count native Claude account profiles (type === 'account'). + * + * Uses ProfileRegistry so the count respects both legacy profiles.json and + * unified config accounts - same source of truth as the rest of the codebase. + * + * type === 'account' implies Claude: per the profile-registry schema, every + * account profile is an isolated Claude instance (CLAUDE_CONFIG_DIR lane) with + * no CLI discriminator. Gemini/Codex multi-account lives in CLIProxy auth + * files, never in profiles.json, so this filter cannot over-count. + */ +export function countNativeClaudeProfiles(): number { + try { + const registry = new ProfileRegistry(); + const profiles = registry.getAllProfilesMerged(); + return Object.values(profiles).filter((p) => p.type === 'account').length; + } catch { + return 0; + } +} + +export interface OnboardingHintResult { + /** Whether the hint was printed */ + printed: boolean; + /** Why the hint was skipped (only set when printed === false) */ + skipReason?: string; +} + +/** + * Maybe print the one-time pool onboarding hint. + * + * Skips silently when any of the following are true: + * - Not a TTY (piped / CI / non-interactive) + * - Fewer than 2 native Claude account profiles exist + * - Pool routing is already enabled + * - The hint was already dismissed + * - Any error occurs while deciding (e.g. malformed config.yaml) + * + * Checks run cheapest-first: TTY and profile count are evaluated before any + * config read, and the config is loaded exactly once for the two + * config-derived checks. The entire decision is wrapped in try/catch so a + * hint failure can never break an account launch. + * + * When the hint is printed for the first time it is also dismissed so it + * never appears again (it is informational, not an interactive prompt). + * + * @param profileCount - Pre-computed profile count (pass undefined to compute + * it here). The caller may pass a count it already has to avoid a second + * registry read. + */ +export function maybeShowPoolOnboardingHint(profileCount?: number): OnboardingHintResult { + // A hint must never break a launch. Any error in the decision path + // (malformed config.yaml, registry read failure, etc.) silently skips the + // hint rather than rethrowing into the caller's account-launch flow. + try { + // Cheapest checks first, no disk read required. + // Non-TTY: stay silent (piped / CI / non-interactive). + if (!process.stdout.isTTY) { + return { printed: false, skipReason: 'non-tty' }; + } + + const count = profileCount ?? countNativeClaudeProfiles(); + if (count < 2) { + return { printed: false, skipReason: 'fewer-than-2-profiles' }; + } + + // Load config once and reuse it for both config-derived checks below, + // avoiding two disk reads + YAML parses per call. + const config = loadOrCreateUnifiedConfig(); + + if (isPoolEnabled(config)) { + return { printed: false, skipReason: 'pool-already-enabled' }; + } + + if (isOnboardingHintDismissed(config)) { + return { printed: false, skipReason: 'dismissed' }; + } + + console.log( + info( + `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}` + ) + ); + + // Dismiss so subsequent invocations (other sites or next run) are silent. + try { + dismissOnboardingHint(); + } catch { + // Best-effort: a config write failure must not surface to the user here. + } + + return { printed: true }; + } catch { + // Decision failed (e.g. malformed config) - skip the hint, never throw. + return { printed: false, skipReason: 'error' }; + } +} diff --git a/src/cliproxy/routing/pool-opt-in-prompt.ts b/src/cliproxy/routing/pool-opt-in-prompt.ts new file mode 100644 index 00000000..c0f29f6e --- /dev/null +++ b/src/cliproxy/routing/pool-opt-in-prompt.ts @@ -0,0 +1,280 @@ +/** + * Pool routing opt-in prompt + * + * Fires at the 1->2 account-add transition for verified providers (claude, agy). + * Informed-consent copy: discloses instance-global effect and lists ALL providers + * with >=2 accounts that will be affected. + * + * Codex/gemini get no prompt until failover behavior is verified for pool routing + * (spike Test D pending). They continue with implicit round-robin. + * + * Remote/Docker targets: prompt replaced by manual-config hint because session + * affinity is not remotely toggleable from CCS (PR #1117 precedent). + */ + +import { info, warn } from '../../utils/ui'; +import { InteractivePrompt } from '../../utils/prompt'; +import { getProxyTarget } from '../proxy/proxy-target-resolver'; +import { + enablePoolRouting, + POOL_ROUTING_VERIFIED_PROVIDERS, + POOL_MAX_RETRY_CREDENTIALS, +} from './routing-strategy'; +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'; +import type { CLIProxyProvider } from '../types'; +import { getProviderAccounts } from '../accounts/account-manager'; +import { loadAccountsRegistry } from '../accounts/registry'; + +/** + * Collect names of all providers that currently have >= 2 accounts registered. + * Derived from the registry's actual provider keys so this list can never drift + * from the CLIProxyProvider type union (spec requirement: disclose ALL affected + * providers, not just a hardcoded subset). + */ +function getMultiAccountProviders(): CLIProxyProvider[] { + const result: CLIProxyProvider[] = []; + try { + const registry = loadAccountsRegistry(); + for (const p of Object.keys(registry.providers) as CLIProxyProvider[]) { + try { + if (getProviderAccounts(p).length >= 2) result.push(p); + } catch { + // Provider registry entry present but accounts unreadable — skip + } + } + } catch { + // Registry unreadable (first-run, corrupt) — return empty; caller falls back to provider param + } + return result; +} + +/** + * Whether the pool routing opt-in prompt has been permanently dismissed. + * Dismissal is recorded per-provider in pool_routing.prompt_dismissed. + */ +export function isPoolPromptDismissed(): boolean { + return loadOrCreateUnifiedConfig().cliproxy?.pool_routing?.prompt_dismissed === true; +} + +/** + * 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 = { + ...cfg.cliproxy.pool_routing, + prompt_dismissed: true, + }; + }); +} + +/** + * Show the remote/Docker hint instead of an interactive prompt. + * Session affinity is not remotely toggleable from CCS (fail-closed + * per PR #1117 precedent) so we emit guidance only. + */ +function printRemoteHint(provider: CLIProxyProvider): void { + console.log(''); + console.log( + info(`[i] Pool routing hint: you now have 2+ ${provider} accounts on a remote/Docker CLIProxy.`) + ); + console.log( + ' CCS cannot toggle session affinity on remote targets yet (management API limitation).' + ); + 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 interface PoolOptInResult { + /** Whether the prompt was shown */ + prompted: boolean; + /** Whether pool routing was enabled */ + enabled: boolean; + /** Whether the prompt was skipped (remote, dismissed, not verified, already enabled) */ + skipped: boolean; + skipReason?: string; +} + +/** + * Offer pool routing opt-in when the account count crosses 1->2 for a verified provider. + * + * Call this immediately after a successful account registration when the provider + * transitions from 1 to 2 accounts. The function is a no-op when: + * - Pool routing is already enabled + * - Provider is not in the verified pool list (codex, gemini, etc.) + * - The prompt was previously dismissed + * - Target is non-TTY (piped input / CI) + * + * Remote/Docker targets get a manual-config hint instead of an interactive prompt. + * + * @param provider - The provider that just reached 2 accounts + * @param accountCountBefore - Number of accounts before this add (should be 1 for transition) + * @param port - CLIProxy port (default: 8317) + */ +export async function maybeOfferPoolRouting( + provider: CLIProxyProvider, + accountCountBefore: number, + port: number = CLIPROXY_DEFAULT_PORT +): Promise { + // Fast path: only fire at the 1->2 transition (accountCountBefore check only). + // The actual post-add count is verified below, after cheap early-exit guards pass, + // so that provider/dismissed/remote guards still return their expected skipReason + // regardless of whether the registry has been populated in the test environment. + if (accountCountBefore !== 1) { + 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. + // Silent by design: legacy installs receive pool guidance exclusively from + // ccs doctor (pool-onboarding-hint site 1); printing here would nag on every + // account add with no dismissal available. + if (!hasUnifiedConfig()) { + 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 { + prompted: false, + enabled: false, + skipped: true, + skipReason: `provider-${provider}-unverified`, + }; + } + + // No-op if already enabled + const config = loadOrCreateUnifiedConfig(); + if (config.cliproxy?.pool_routing?.enabled === true) { + return { prompted: false, enabled: true, skipped: true, skipReason: 'already-enabled' }; + } + + // No-op if user already dismissed + if (isPoolPromptDismissed()) { + return { prompted: false, enabled: false, skipped: true, skipReason: 'dismissed' }; + } + + // Remote / Docker target: print hint, do not prompt. + // Check before account count so remote targets with locally-unknown registries + // still get the hint (the remote CLIProxy holds the authoritative account list). + const target = getProxyTarget(); + if (target.isRemote) { + printRemoteHint(provider); + return { prompted: false, enabled: false, skipped: true, skipReason: 'remote-target' }; + } + + // Verify the actual post-add count is >= 2. registerAccount deduplicates by + // email/token-file so re-authenticating the single existing account keeps the + // count at 1 — not a real 1->2 transition. Checking here (before TTY) ensures + // re-auth dedup always returns not-at-transition even in non-TTY/CI sessions. + const accountCountAfter = getProviderAccounts(provider).length; + if (accountCountAfter < 2) { + return { prompted: false, enabled: false, skipped: true, skipReason: 'not-at-transition' }; + } + + // Non-TTY (piped input / CI): skip silently + if (!process.stdin.isTTY || !process.stderr.isTTY) { + 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 = + multiAccountProviders.length > 0 ? multiAccountProviders.join(', ') : provider; + + console.log(''); + console.log(warn('Pool routing: you now have 2+ accounts for ' + provider)); + console.log(''); + console.log(' CCS can enable pool routing (fill-first + session affinity + 429 cooldown).'); + console.log(' This is an INSTANCE-GLOBAL change: it affects account selection for ALL'); + console.log(` CLIProxy providers on this machine: ${providerList}`); + console.log(''); + console.log(' What changes:'); + console.log(' - disable-cooling: false (cooldown is required for retry-cap to work)'); + console.log(' A 429 suspends a credential briefly (1s -> 30m exp backoff).'); + console.log(' A 401/403 suspends it for 30 minutes (correct: broken auth = no traffic).'); + console.log(' - routing: fill-first (drain one account before switching)'); + console.log(' - session-affinity: true (TTL 1h, pinned per conversation)'); + console.log( + ` - max-retry-credentials: ${POOL_MAX_RETRY_CREDENTIALS} (stop after ${POOL_MAX_RETRY_CREDENTIALS} attempts per request)` + ); + console.log(''); + console.log(' You can roll back at any time: ccs cliproxy pool --disable'); + console.log(' (Or re-enable later: ccs cliproxy pool --enable)'); + console.log(''); + + const yes = await InteractivePrompt.confirm( + ' Enable pool routing for all CLIProxy providers?', + { default: false } + ); + + if (!yes) { + // Persist decline so we don't re-ask on every subsequent add + dismissPoolPrompt(); + console.log( + info( + " Declined. Pool routing stays off. Run 'ccs cliproxy pool --enable' to opt in later." + ) + ); + console.log(''); + return { prompted: true, enabled: false, skipped: false }; + } + + const configPath = getConfigPathForPort(port); + const authDir = getAuthDir(); + 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)); + } + console.log(''); + + // User accepted and enablePoolRouting completed: pool routing is enabled + // even when this call was an idempotent no-op (changed=false). + return { prompted: true, enabled: true, skipped: false }; +} diff --git a/src/cliproxy/routing/routing-strategy.ts b/src/cliproxy/routing/routing-strategy.ts index d6ed1ec2..e5defa79 100644 --- a/src/cliproxy/routing/routing-strategy.ts +++ b/src/cliproxy/routing/routing-strategy.ts @@ -1,3 +1,5 @@ +import * as fs from 'fs'; +import * as yaml from 'js-yaml'; import { regenerateConfig } from '../config/generator'; import { getAuthDir, getConfigPathForPort } from '../config/path-resolver'; import { @@ -7,20 +9,356 @@ import { } from './routing-strategy-http'; import type { CliproxyRoutingStrategy } from '../types'; import { loadOrCreateUnifiedConfig, mutateConfig } from '../../config/config-loader-facade'; +import { getInstalledCliproxyVersion } from '../binary-manager'; +import { compareVersions } from '../../utils/update-checker'; +import { getConfigYamlPath } from '../../config/loader/io-locks'; export const DEFAULT_CLIPROXY_ROUTING_STRATEGY: CliproxyRoutingStrategy = 'round-robin'; export const DEFAULT_CLIPROXY_SESSION_AFFINITY_ENABLED = false; export const DEFAULT_CLIPROXY_SESSION_AFFINITY_TTL = '1h'; +/** + * Pool routing defaults written to config when pool routing is enabled. + * fill-first + session affinity drains one account before using another, + * maximising per-account context depth while honouring cooldown windows. + */ +export const POOL_ROUTING_STRATEGY: CliproxyRoutingStrategy = 'fill-first'; +export const POOL_SESSION_AFFINITY_ENABLED = true; +export const POOL_SESSION_AFFINITY_TTL = '1h'; +export const POOL_MAX_RETRY_CREDENTIALS = 3; + +/** + * Providers for which pool routing is available and the opt-in prompt + * shows the full cooling/routing disclosure. Others (codex, gemini) + * have failover behaviour that is unverified for pool routing; they get + * a softened prompt variant or no prompt until spike Test D confirms. + */ +export const POOL_ROUTING_VERIFIED_PROVIDERS = new Set(['claude', 'agy']); + +/** + * Minimum CLIProxy version that supports pool routing keys: + * max-retry-credentials and the cooling flip. + * Older binaries silently ignore unknown keys — pool rails would appear active + * but have no effect. Warn the user at enable time if below this version. + * + * NOTE: Update this constant when upstream first ships these keys. + * Current best estimate based on spec; adjust after spike Test D confirms. + */ +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; +} + +/** + * Read the raw (pre-defaults-merger) CCS config YAML. + * The loaded config always injects `strategy: round-robin`, `session_affinity: false`, + * `session_affinity_ttl: 1h` as defaults — so we cannot use the merged config to + * detect whether the user actually wrote these keys. This helper reads the raw YAML + * and returns the partial routing block as-written on disk. + * + * Returns null if the config file does not exist or cannot be parsed. + */ +function readRawRoutingConfig(): { + strategy?: unknown; + session_affinity?: unknown; + session_affinity_ttl?: unknown; +} | null { + try { + const yamlPath = getConfigYamlPath(); + if (!fs.existsSync(yamlPath)) return null; + const raw = yaml.load(fs.readFileSync(yamlPath, 'utf8')) as Record | null; + if (!raw || typeof raw !== 'object') return null; + const cliproxy = raw['cliproxy'] as Record | undefined; + if (!cliproxy || typeof cliproxy !== 'object') return null; + const routing = cliproxy['routing'] as Record | undefined; + if (!routing || typeof routing !== 'object') return null; + return routing as { + strategy?: unknown; + session_affinity?: unknown; + session_affinity_ttl?: unknown; + }; + } catch { + return null; + } +} + +/** + * Detect whether the user has set a routing strategy that differs from the + * injected default (round-robin). + * + * Background: loadOrCreateUnifiedConfig persists injected defaults to disk on + * first load, so `strategy: round-robin` may appear in the raw YAML even on a + * pristine config — it was injected by CCS, not written by the user. A stored + * value EQUAL to the default is therefore treated as NOT explicit so that + * enablePoolRouting does not falsely claim to be "preserving a custom strategy". + * + * A value that DIFFERS from the default (e.g. fill-first) is treated as + * user-managed: preserve it and warn. + */ +export function hasExplicitRoutingStrategy(): boolean { + const rawRouting = readRawRoutingConfig(); + if (rawRouting?.strategy === undefined) return false; + // Equal to the injected default -> not explicitly customised by the user + return rawRouting.strategy !== DEFAULT_CLIPROXY_ROUTING_STRATEGY; +} + +/** + * Detect whether the user has set session-affinity to a value that differs + * from the injected default (false). + * + * Same rationale as hasExplicitRoutingStrategy: loadOrCreateUnifiedConfig may + * persist `session_affinity: false` as a default, so presence alone is not + * sufficient — only a value that differs from the default counts as explicit. + */ +export function hasExplicitSessionAffinity(): boolean { + const rawRouting = readRawRoutingConfig(); + if (rawRouting?.session_affinity === undefined) return false; + // Equal to the injected default -> not explicitly customised by the user + return rawRouting.session_affinity !== DEFAULT_CLIPROXY_SESSION_AFFINITY_ENABLED; +} + +/** + * Enable pool routing: write pool_routing.enabled = true and the canonical + * pool defaults (fill-first, session affinity 1h, max-retry-credentials: 3) + * to the CCS unified config. Regenerates the CLIProxy config.yaml so the + * cooling flip and routing block take effect immediately (CLIProxy hot-reloads). + * + * Explicit user routing settings are preserved and a warning is emitted. + * The pool flag is written regardless — the generator uses fill-first/affinity + * from the pool defaults when pool is enabled, bypassing any stored routing. + * + * Idempotent: calling when already enabled is a no-op. + */ +export function enablePoolRouting( + port: number, + options: { configPath?: string; authDir?: string } = {} +): 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, + message: 'Pool routing is already enabled.', + }; + } + + const preservedExplicitSetting = hasExplicitRoutingStrategy() || hasExplicitSessionAffinity(); + + // Spec step 3 / architecture: assert minimum CLIProxy version at enable time. + // Stale binaries silently ignore max-retry-credentials and the cooling flip, + // so pool rails would appear active but have no effect. Warn and proceed. + try { + const installedVersion = getInstalledCliproxyVersion(); + if (compareVersions(installedVersion, POOL_ROUTING_MIN_VERSION) < 0) { + console.warn( + `[!] CLIProxy v${installedVersion} is older than the pool routing minimum (v${POOL_ROUTING_MIN_VERSION}).\n` + + ` The max-retry-credentials and cooling keys may be silently ignored by the running binary.\n` + + ` Run 'ccs cliproxy --latest' to update CLIProxy, then restart with 'ccs cliproxy restart'.` + ); + } + } catch { + // Binary not installed yet (first setup) — skip the version check silently + } + + mutateConfig((cfg) => { + if (!cfg.cliproxy) return; + // Write only the pool flag and retry-cap. User's routing values (strategy, + // session_affinity, session_affinity_ttl) are intentionally left untouched so + // disablePoolRouting can restore them without needing a separate backup. + // The generator uses pool constants (fill-first, affinity 1h) when pool is + // enabled, bypassing whatever is stored in cfg.cliproxy.routing. + cfg.cliproxy.pool_routing = { + ...cfg.cliproxy.pool_routing, + enabled: true, + max_retry_credentials: POOL_MAX_RETRY_CREDENTIALS, + }; + }); + + // 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, + preservedExplicitSetting, + message: preservedExplicitSetting + ? '[!] Pool routing enabled. Your existing routing setting is preserved in config.\n The generator uses pool defaults (fill-first, affinity 1h) while pool is active.\n To restore your setting, disable pool routing first: ccs cliproxy pool --disable' + : '[OK] Pool routing enabled. CLIProxy config regenerated with cooling ON,\n fill-first strategy, session affinity 1h, max-retry-credentials 3.\n CLIProxy will hot-reload the change; live session pins will re-pin on\n next request.', + }; +} + +/** + * Disable pool routing: clear pool_routing.enabled and restore the non-pool + * config defaults (disable-cooling: true, round-robin, no affinity). + * Regenerates the CLIProxy config.yaml. + * + * IMPORTANT: disablePoolRouting MUST explicitly restore routing to round-robin + * and session_affinity to false. Simply clearing pool_routing.enabled is not + * sufficient because the upstream CLIProxy default for disable-cooling is false + * (cooling ON) when the key is absent. Leaving cooling ON for a user who has + * disabled pool routing would reintroduce the single-account blackout that v5 + * (commit fb77d72a) fixed. + * + * Idempotent: calling when already disabled is a no-op. + */ +export function disablePoolRouting( + port: number, + options: { configPath?: string; authDir?: string } = {} +): 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, + message: 'Pool routing is not enabled.', + }; + } + + mutateConfig((cfg) => { + if (!cfg.cliproxy) return; + // Only clear the pool flag — user's routing values (strategy, session_affinity, + // session_affinity_ttl) were never overwritten on enable, so they are naturally + // restored here. The generator emits disable-cooling: true when pool is off. + cfg.cliproxy.pool_routing = { + ...cfg.cliproxy.pool_routing, + enabled: false, + }; + }); + + // 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, + message: + '[OK] Pool routing disabled. CLIProxy config regenerated with cooling disabled (stability mode).\n' + + ' Your original routing settings are restored.\n' + + ' If you have multiple accounts and want fair distribution, round-robin is active.\n' + + ' To avoid cache-burn with large multi-account fleets, consider reducing to 1 account\n' + + ' or re-enabling pool routing: ccs cliproxy pool --enable', + }; +} + const GO_DURATION_SEGMENT = String.raw`(?:\d+(?:\.\d+)?(?:ns|us|µs|μs|ms|s|m|h))`; const GO_DURATION_PATTERN = new RegExp(`^${GO_DURATION_SEGMENT}+$`); +/** + * Pool routing mode summary surfaced alongside routing state for the dashboard. + * When pool routing is enabled the generator forces fill-first + session affinity + * + cooling, regardless of stored routing values. + */ +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 { strategy: CliproxyRoutingStrategy; source: 'live' | 'config'; target: 'local' | 'remote'; reachable: boolean; message?: string; + /** + * 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; } export interface CliproxyRoutingApplyResult extends CliproxyRoutingState { @@ -142,15 +480,48 @@ export async function fetchLiveCliproxyRoutingStrategy(): Promise { const target = getCliproxyRoutingTarget(); + 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: { + 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.', + }, }; } @@ -160,6 +531,7 @@ export async function readCliproxyRoutingState(): Promise source: 'live', target: 'local', reachable: true, + poolRouting, }; } catch { return { @@ -168,6 +540,7 @@ export async function readCliproxyRoutingState(): Promise target: 'local', reachable: false, message: 'Local CLIProxy is not reachable. Showing the saved startup default.', + poolRouting, }; } } @@ -223,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 }; @@ -238,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 { @@ -247,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, }; } } @@ -278,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 = { @@ -298,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, }; } diff --git a/src/commands/cliproxy/__tests__/order-subcommand.test.ts b/src/commands/cliproxy/__tests__/order-subcommand.test.ts new file mode 100644 index 00000000..e85a00b9 --- /dev/null +++ b/src/commands/cliproxy/__tests__/order-subcommand.test.ts @@ -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; + let lines: string[]; + + function authDir(): string { + return path.join(tempHome, '.ccs', 'cliproxy', 'auth'); + } + + function writeAuthFile(fileName: string, fields: Record = {}): 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 { + 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'); + }); + }); +}); diff --git a/src/commands/cliproxy/__tests__/pool-state-renderer.test.ts b/src/commands/cliproxy/__tests__/pool-state-renderer.test.ts new file mode 100644 index 00000000..00b43aa0 --- /dev/null +++ b/src/commands/cliproxy/__tests__/pool-state-renderer.test.ts @@ -0,0 +1,224 @@ +/** + * Tests for the CLI pool-state renderer helpers: state labels (the three + * account states must be NAMED differently because they map to different client + * failure modes), reset formatting, and drain-order mode labels. + */ +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'; + +function state( + partial: Partial & { state: PoolAccountState['state'] } +): PoolAccountState { + return { + accountId: 'a@x.com', + tokenFile: 'a.json', + isDefault: false, + ...partial, + } as PoolAccountState; +} + +describe('describeAccountState', () => { + it('names available, cooling, and paused states distinctly', () => { + const now = 1_000_000; + const available = describeAccountState(state({ state: 'available' }), now); + const paused = describeAccountState(state({ state: 'paused' }), now); + const cooling = describeAccountState( + state({ state: 'cooling', cooldownUntil: now + 5 * 60 * 1000, cooldownSource: 'persisted' }), + now + ); + + expect(available.label).toBe('available'); + expect(available.tone).toBe('available'); + // 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'); + + // The three labels must be mutually distinct (no conflation). + const labels = new Set([available.label, paused.label, cooling.label]); + expect(labels.size).toBe(3); + }); + + it('annotates an in-process (memory) cooldown source', () => { + const now = 1_000_000; + const cooling = describeAccountState( + state({ state: 'cooling', cooldownUntil: now + 60_000, cooldownSource: 'memory' }), + now + ); + expect(cooling.label).toContain('[in-process]'); + }); + + it('shows unknown when a cooling account has no reset time', () => { + const cooling = describeAccountState(state({ state: 'cooling' }), 1_000_000); + expect(cooling.label).toContain('unknown'); + }); +}); + +describe('formatCooldownReset', () => { + it('returns now for a non-positive delta', () => { + expect(formatCooldownReset(1_000_000, 1_000_000)).toContain('now'); + }); + + it('uses minute granularity within the hour', () => { + const now = 1_000_000; + expect(formatCooldownReset(now + 5 * 60 * 1000, now)).toContain('5m'); + }); + + it('uses hour granularity within the day', () => { + const now = 1_000_000; + expect(formatCooldownReset(now + 3 * 3600 * 1000, now)).toContain('3h'); + }); +}); + +describe('modeLabel', () => { + it('maps each drain order mode to a stable label', () => { + expect(modeLabel('manual')).toBe('manual (--set)'); + expect(modeLabel('tier')).toBe('tier-derived (--by-tier)'); + 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; + let lines: string[]; + + function writeAuthFile(fileName: string, fields: Record = {}): 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 ). + expect(output).toContain('Resume:'); + expect(output).toContain('ccs cliproxy resume '); + }); + + 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'); + }); +}); diff --git a/src/commands/cliproxy/__tests__/pool-subcommand.test.ts b/src/commands/cliproxy/__tests__/pool-subcommand.test.ts new file mode 100644 index 00000000..e8e39f78 --- /dev/null +++ b/src/commands/cliproxy/__tests__/pool-subcommand.test.ts @@ -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; + 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-.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); + }); +}); diff --git a/src/commands/cliproxy/help-subcommand.ts b/src/commands/cliproxy/help-subcommand.ts index a7ec3fd4..27e61e14 100644 --- a/src/commands/cliproxy/help-subcommand.ts +++ b/src/commands/cliproxy/help-subcommand.ts @@ -56,13 +56,32 @@ export async function showHelp(): Promise { ['default ', 'Set default account for rotation'], ['pause ', 'Pause account (skip in rotation)'], ['resume ', 'Resume paused account'], - ['quota', 'Show quota status for all providers (Codex/Claude include 5h + weekly reset)'], + [ + 'quota', + 'Show quota status + pool context (drain order, per-account available/cooling/paused)', + ], ['quota --provider ', `Filter by provider (${QUOTA_PROVIDER_HELP_TEXT})`], ['routing', 'Show current routing strategy and manual guidance'], ['routing explain', 'Explain strategy vs session-affinity and how sessions are recognized'], ['routing set ', 'Explicitly set round-robin or fill-first'], ['routing affinity', 'Show local session-affinity status and TTL'], ['routing affinity [--ttl ]', 'Toggle local session-affinity settings'], + ['pool', 'Show pool routing status (fill-first + affinity + 429 cooldown)'], + ['pool --enable', 'Enable pool routing (writes cooling/affinity/retry-cap to config)'], + ['pool --disable', 'Disable pool routing and restore non-pool config'], + [ + 'accounts order ', + 'Show effective drain order (priority bucket desc, then ID asc)', + ], + [ + 'accounts order --by-tier', + 'Set drain order from tier metadata (ultra > pro > free)', + ], + [ + 'accounts order --set a,b,c', + 'Set manual drain order (comma-separated account IDs)', + ], + ['accounts order --reset', 'Revert drain order to stable file order'], ], ], [ diff --git a/src/commands/cliproxy/index.ts b/src/commands/cliproxy/index.ts index 02ae8bc5..b580fdda 100644 --- a/src/commands/cliproxy/index.ts +++ b/src/commands/cliproxy/index.ts @@ -48,6 +48,8 @@ import { handleCatalogReset, handleCatalogJson, } from './catalog-subcommand'; +import { handlePoolSubcommand } from './pool-subcommand'; +import { handleOrderSubcommand } from './order-subcommand'; /** * Parse --backend flag from args @@ -186,6 +188,26 @@ export async function handleCliproxyCommand(args: string[]): Promise { return; } + if (command === 'pool') { + await handlePoolSubcommand(remainingArgs.slice(1)); + return; + } + + if (command === 'accounts') { + const subcommand = remainingArgs[1]; + if (subcommand === 'order') { + await handleOrderSubcommand(remainingArgs.slice(2)); + return; + } + // Unknown (or missing) accounts subcommand: report and show help. + // 'order' is currently the only accounts subcommand. + console.error(`[X] Unknown accounts subcommand: ${subcommand ?? '(none)'}`); + console.error(' Usage: ccs cliproxy accounts order '); + process.exitCode = 1; + await showHelp(); + return; + } + if (command === 'routing') { const subcommand = remainingArgs[1]; if (subcommand === 'set') { diff --git a/src/commands/cliproxy/order-subcommand.ts b/src/commands/cliproxy/order-subcommand.ts new file mode 100644 index 00000000..b2b50061 --- /dev/null +++ b/src/commands/cliproxy/order-subcommand.ts @@ -0,0 +1,675 @@ +/** + * CLIProxy Accounts Drain Order Subcommand + * + * Handles: + * ccs cliproxy accounts order + * ccs cliproxy accounts order --by-tier + * ccs cliproxy accounts order --set a@x.com,b@y.com,... + * ccs cliproxy accounts order --reset + */ + +import { initUI, header, subheader, color, dim, ok, fail, warn, info } from '../../utils/ui'; +import { extractOption, hasAnyFlag } from '../arg-extractor'; +import { saveDrainOrderConfig, clearDrainOrderConfig } from '../../cliproxy/accounts/registry'; +import { getProviderAccounts } from '../../cliproxy/accounts/query'; +import { + computeManualDrainOrder, + computeTierDrainOrder, + applyDrainOrder, + resolveEffectiveDrainOrder, + clearDrainOrderPriorities, + tieBreakKey, + type DrainOrderEntry, + type DrainOrderInput, +} from '../../cliproxy/accounts/drain-order'; +import { detectRunningProxy } from '../../cliproxy/proxy/proxy-detector'; +import { getProxyTarget } from '../../cliproxy/proxy/proxy-target-resolver'; +import { mapExternalProviderName } from '../../cliproxy/provider-capabilities'; +import type { CLIProxyProvider } from '../../cliproxy/types'; +import type { AccountTier } from '../../cliproxy/accounts/types'; +import { resolveLifecyclePort } from '../../cliproxy/config/port-manager'; + +/** Providers where tier metadata is expected and --by-tier is meaningful. */ +const TIER_AWARE_PROVIDERS = new Set(['agy', 'gemini']); + +// tieBreakKey now lives in drain-order.ts (shared with the quota pool section); +// re-exported here so existing callers/tests keep importing it from this module. +export { tieBreakKey }; + +function formatTierLabel(tier: AccountTier | undefined, tierDerived: boolean): string { + if (!tier || tier === 'unknown') { + return dim('unknown'); + } + const label = + tier === 'ultra' ? color(tier, 'success') : tier === 'pro' ? color(tier, 'info') : dim(tier); + return tierDerived ? label : dim(tier); +} + +function printOrderTable( + entries: DrainOrderEntry[], + showCurrentPriority: boolean, + sortByFilePriority: boolean = false +): void { + const rows = entries.slice().sort((a, b) => { + if (sortByFilePriority) { + // Sort by currentPriority (the selector's actual input), treating undefined as 0. + // This is the priority the selector actually sees on disk, not the computed config priority. + const aCurrent = a.currentPriority ?? 0; + const bCurrent = b.currentPriority ?? 0; + return bCurrent - aCurrent || (tieBreakKey(a.tokenFile) < tieBreakKey(b.tokenFile) ? -1 : 1); + } + // Tie-break by tokenFile ascending, matching the Go selector. Upstream sorts + // by Auth.ID byte order and only lowercases on Windows (see tieBreakKey). + // localeCompare is intentionally avoided - Go sorts by byte value, not locale. + return ( + b.priority - a.priority || (tieBreakKey(a.tokenFile) < tieBreakKey(b.tokenFile) ? -1 : 1) + ); + }); + + const maxId = Math.max(...rows.map((r) => r.accountId.length), 'Account'.length); + const maxPri = Math.max(...rows.map((r) => String(r.priority).length), 'Priority'.length); + + console.log( + ` ${'#'.padStart(3)} ${color('Account'.padEnd(maxId), 'command')} ${'Priority'.padStart(maxPri)} Tier` + ); + console.log(` ${'---'} ${'-'.repeat(maxId)} ${'-'.repeat(maxPri)} ----`); + + rows.forEach((entry, idx) => { + const pos = String(idx + 1).padStart(3); + const id = entry.accountId.padEnd(maxId); + const pri = String(entry.priority).padStart(maxPri); + const tierLabel = formatTierLabel(entry.tier, entry.tierDerived); + const currentNote = + showCurrentPriority && entry.currentPriority !== undefined + ? dim(` (file: ${entry.currentPriority})`) + : ''; + console.log( + ` ${pos} ${color(id, 'command')} ${color(pri, 'info')} ${tierLabel}${currentNote}` + ); + }); +} + +/** + * Show effective drain order for a provider without making any changes. + */ +async function handleOrderShow(provider: CLIProxyProvider): Promise { + // Use getProviderAccounts() so auth files not yet in accounts.json + // (e.g. file-copied fleets) are visible and tier metadata is preserved. + const allAccounts = getProviderAccounts(provider); + + if (allAccounts.length === 0) { + console.log(warn(`No accounts found for provider: ${provider}`)); + console.log(''); + return; + } + + const accounts: DrainOrderInput[] = allAccounts + .filter((a) => !a.paused) + .map((a) => ({ + accountId: a.id, + tokenFile: a.tokenFile, + tier: a.tier, + })); + + if (accounts.length === 0) { + console.log(warn(`All accounts for ${provider} are paused.`)); + console.log(''); + return; + } + + // Shared effective-order resolver: same semantics the quota Pool section and + // the selector use (sort by on-disk priority, surface drift). + const effective = resolveEffectiveDrainOrder(provider, accounts); + + if (effective.mode === 'file') { + // 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; + } + + const entries = effective.entries; + const modeLabel = + effective.mode === 'manual' + ? `manual (${color('--set', 'command')})` + : `tier-derived (${color('--by-tier', 'command')})`; + const hasDrift = effective.hasDrift; + + console.log(` Mode: ${modeLabel}`); + if (hasDrift) { + console.log(''); + console.log( + warn( + `Drift detected: stored order does not match auth files; re-run --set/--by-tier to re-apply.` + ) + ); + } + console.log(''); + // Show rows in selector pick order (currentPriority desc, tokenFile asc on tie). + // This reflects what CLIProxy will actually drain, not just the intended config. + console.log(subheader('Effective drain order (selector pick order, highest priority first):')); + printOrderTable(entries, true, true); + console.log(''); + console.log( + dim(` To update: ccs cliproxy accounts order ${provider} --set `) + ); + if (TIER_AWARE_PROVIDERS.has(provider)) { + console.log(dim(` Tier-based: ccs cliproxy accounts order ${provider} --by-tier`)); + } + console.log(dim(` To reset: ccs cliproxy accounts order ${provider} --reset`)); + console.log(''); +} + +function filePriorityNote(entry: DrainOrderEntry): string { + return entry.currentPriority !== undefined ? dim(` [priority: ${entry.currentPriority}]`) : ''; +} + +/** + * 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, + entries: DrainOrderEntry[], + hasDrift: boolean +): void { + if (!TIER_AWARE_PROVIDERS.has(provider)) { + console.log( + info( + `Tier unknown for ${provider} accounts - using file order.\n` + + ` Use --set to specify manual order.` + ) + ); + } + // 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 (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( + dim(` To set manual order: ccs cliproxy accounts order ${provider} --set a@x.com,b@y.com`) + ); + if (TIER_AWARE_PROVIDERS.has(provider)) { + console.log( + dim(` To use tier-based order: ccs cliproxy accounts order ${provider} --by-tier`) + ); + } + console.log(''); +} + +/** + * Apply tier-derived drain order for a provider. + */ +async function handleOrderByTier(provider: CLIProxyProvider): Promise { + if (!TIER_AWARE_PROVIDERS.has(provider)) { + console.log( + warn( + `Tier metadata is not available for ${provider} accounts.\n` + + ` Tier is tracked for: ${[...TIER_AWARE_PROVIDERS].join(', ')}\n` + + ` Use --set for manual order instead.` + ) + ); + console.log(''); + process.exitCode = 1; + return; + } + + // Use getProviderAccounts() so auth files not yet in accounts.json + // (e.g. file-copied fleets) are visible and tier metadata is preserved. + const allProviderAccounts = getProviderAccounts(provider); + + if (allProviderAccounts.length === 0) { + console.log(warn(`No accounts found for provider: ${provider}`)); + console.log(''); + process.exitCode = 1; + return; + } + + const accounts: DrainOrderInput[] = allProviderAccounts + .filter((a) => !a.paused) + .map((a) => ({ + accountId: a.id, + tokenFile: a.tokenFile, + tier: a.tier, + })); + + if (accounts.length === 0) { + console.log(warn(`All ${provider} accounts are paused.`)); + console.log(''); + process.exitCode = 1; + return; + } + + const allUnknown = accounts.every((a) => !a.tier || a.tier === 'unknown'); + if (allUnknown) { + console.log( + warn( + `All ${provider} accounts have unknown tier.\n` + + ` Run quota to populate tier metadata, or use --set for manual order.` + ) + ); + console.log(''); + process.exitCode = 1; + return; + } + + const entries = computeTierDrainOrder(accounts); + + console.log(subheader('Computed tier-based drain order:')); + printOrderTable(entries, false); + console.log(''); + + // Check proxy state for write path selection. + // Use the configured local port (not the hardcoded default) for detection. + // Remote targets: refuse with guidance - detection and current-priority reads + // require management API access that is not wired in v1. + const proxyTarget = getProxyTarget(); + if (proxyTarget.isRemote) { + console.log( + warn( + `Remote proxy target detected. Drain order management for remote proxies is not\n` + + ` supported in v1. Run this command on the host running CLIProxy instead.` + ) + ); + console.log(''); + process.exitCode = 1; + return; + } + + const localPort = resolveLifecyclePort(); + const proxyStatus = await detectRunningProxy(localPort); + + // Ambiguous state: proxy process is alive but not yet responding to HTTP. + // A direct file write while CLIProxy is serving could be silently clobbered + // by the MarkResult-persist path. Refuse rather than risk priority loss. + if (proxyStatus.running && !proxyStatus.verified) { + console.log(fail('Proxy state ambiguous - 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 - writing via management API (avoids clobber race).')); + } else { + console.log(info('Proxy is stopped - writing directly to auth files.')); + } + console.log(''); + + const result = await applyDrainOrder(entries, proxyRunning); + + if (result.written.length > 0) { + console.log(ok(`Set priorities for ${result.written.length} account(s).`)); + } + if (result.skipped.length > 0) { + console.log(info(`Skipped ${result.skipped.length} account(s) (priority already correct).`)); + } + if (result.failed.length > 0) { + for (const f of result.failed) { + console.log(fail(`Failed to set priority for ${f.entry.accountId}: ${f.reason}`)); + } + process.exitCode = 1; + } + + if (result.failed.length === 0) { + // Persist mode + const persisted = saveDrainOrderConfig(provider, { mode: 'tier' }); + if (persisted) { + console.log( + ok( + `Drain order mode saved as "tier". Re-run "ccs cliproxy accounts order ${provider} --by-tier" after adding or re-authing accounts to re-apply.` + ) + ); + } else { + console.log( + warn( + `Order applied to auth files but mode not persisted - no registered accounts for ${provider}; run ccs cliproxy quota to register, then re-run` + ) + ); + } + } + console.log(''); +} + +/** + * Apply manual drain order specified as comma-separated account IDs. + */ +async function handleOrderSet(provider: CLIProxyProvider, setArg: string): Promise { + const orderedIds = setArg + .split(',') + .map((s) => s.trim()) + .filter((s) => s.length > 0); + + if (orderedIds.length === 0) { + console.log(fail('--set requires a comma-separated list of account IDs.')); + console.log(''); + process.exitCode = 1; + return; + } + + // Use getProviderAccounts() so auth files not yet in accounts.json + // (e.g. file-copied fleets) are visible and tier metadata is preserved. + const allProviderAccounts = getProviderAccounts(provider); + + if (allProviderAccounts.length === 0) { + console.log(warn(`No accounts found for provider: ${provider}`)); + console.log(''); + process.exitCode = 1; + return; + } + + const allAccounts: DrainOrderInput[] = allProviderAccounts + .filter((a) => !a.paused) + .map((a) => ({ + accountId: a.id, + tokenFile: a.tokenFile, + tier: a.tier, + })); + + let entries: DrainOrderEntry[]; + try { + entries = computeManualDrainOrder(orderedIds, allAccounts); + } catch (err) { + console.log(fail(`${(err as Error).message}`)); + console.log(''); + process.exitCode = 1; + return; + } + + console.log(subheader('Computed manual drain order:')); + printOrderTable(entries, false); + console.log(''); + + // Use the configured local port (not the hardcoded default) for detection. + // Remote targets: refuse with guidance. + const proxyTarget = getProxyTarget(); + if (proxyTarget.isRemote) { + console.log( + warn( + `Remote proxy target detected. Drain order management for remote proxies is not\n` + + ` supported in v1. Run this command on the host running CLIProxy instead.` + ) + ); + console.log(''); + process.exitCode = 1; + return; + } + + const localPort = resolveLifecyclePort(); + const proxyStatus = await detectRunningProxy(localPort); + + // Ambiguous state: proxy process is alive but not yet responding to HTTP. + // A direct file write while CLIProxy is serving could be silently clobbered + // by the MarkResult-persist path. Refuse rather than risk priority loss. + if (proxyStatus.running && !proxyStatus.verified) { + console.log(fail('Proxy state ambiguous - 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 - writing via management API (avoids clobber race).')); + } else { + console.log(info('Proxy is stopped - writing directly to auth files.')); + } + console.log(''); + + const result = await applyDrainOrder(entries, proxyRunning); + + if (result.written.length > 0) { + console.log(ok(`Set priorities for ${result.written.length} account(s).`)); + } + if (result.skipped.length > 0) { + console.log(info(`Skipped ${result.skipped.length} account(s) (priority already correct).`)); + } + if (result.failed.length > 0) { + for (const f of result.failed) { + console.log(fail(`Failed to set priority for ${f.entry.accountId}: ${f.reason}`)); + } + process.exitCode = 1; + } + + if (result.failed.length === 0) { + const persisted = saveDrainOrderConfig(provider, { mode: 'manual', orderedIds }); + if (persisted) { + console.log( + ok( + `Drain order mode saved as "manual". Re-run "ccs cliproxy accounts order ${provider} --set " after adding or re-authing accounts to re-apply.` + ) + ); + } else { + console.log( + warn( + `Order applied to auth files but mode not persisted - no registered accounts for ${provider}; run ccs cliproxy quota to register, then re-run` + ) + ); + } + } + console.log(''); +} + +/** + * 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 { + 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( + 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(''); +} + +function printOrderHelp(provider?: string): void { + const prov = provider ?? ''; + console.log(subheader('Usage:')); + console.log(` ${color(`ccs cliproxy accounts order ${prov}`, 'command')}`); + console.log(` ${color(`ccs cliproxy accounts order ${prov} --by-tier`, 'command')}`); + console.log(` ${color(`ccs cliproxy accounts order ${prov} --set a@x.com,b@y.com`, 'command')}`); + console.log(` ${color(`ccs cliproxy accounts order ${prov} --reset`, 'command')}`); + console.log(''); + console.log(subheader('Options:')); + console.log(` ${dim('(no flags)')} Show effective drain order`); + console.log( + ` ${color('--by-tier', 'command')} Derive order from tier metadata (ultra > pro > free)` + ); + console.log( + ` ${color('--set', 'command')} Set manual order (comma-separated account IDs)` + ); + console.log(` ${color('--reset', 'command')} Revert to stable file order`); + console.log(''); + console.log(dim(' Tier-based ordering is available for: agy, gemini')); + console.log(dim(' Claude accounts have unknown tier; use --set for manual order.')); + console.log(''); +} + +/** + * Main handler for `ccs cliproxy accounts order [flags]` + */ +export async function handleOrderSubcommand(args: string[]): Promise { + await initUI(); + console.log(''); + console.log(header('CLIProxy Drain Order')); + console.log(''); + + if (hasAnyFlag(args, ['--help', '-h']) || args.length === 0) { + printOrderHelp(); + return; + } + + // First positional arg is provider name + const providerRaw = args[0]; + if (!providerRaw || providerRaw.startsWith('-')) { + console.log(fail('Provider name required. Usage: ccs cliproxy accounts order ')); + console.log(''); + printOrderHelp(); + process.exitCode = 1; + return; + } + + const provider = mapExternalProviderName(providerRaw) as CLIProxyProvider | null; + if (!provider) { + console.log(fail(`Unknown provider: ${providerRaw}`)); + console.log(''); + process.exitCode = 1; + return; + } + + const subArgs = args.slice(1); + + if (hasAnyFlag(subArgs, ['--reset'])) { + await handleOrderReset(provider); + return; + } + + if (hasAnyFlag(subArgs, ['--by-tier'])) { + await handleOrderByTier(provider); + return; + } + + const extracted = extractOption(subArgs, ['--set']); + if (extracted.found) { + if (extracted.missingValue || !extracted.value) { + console.log(fail('--set requires a value: --set a@x.com,b@y.com,...')); + console.log(''); + process.exitCode = 1; + return; + } + await handleOrderSet(provider, extracted.value); + return; + } + + // Default: show current order + await handleOrderShow(provider); +} diff --git a/src/commands/cliproxy/pool-state-renderer.ts b/src/commands/cliproxy/pool-state-renderer.ts new file mode 100644 index 00000000..4a896c47 --- /dev/null +++ b/src/commands/cliproxy/pool-state-renderer.ts @@ -0,0 +1,271 @@ +/** + * Pool State Renderer (CLI) + * + * Renders the pool context section appended to `ccs cliproxy quota` per provider: + * - routing mode line (pool on/off, strategy, session affinity) + * - effective drain order (Phase 4 resolver) + * - per-account state: available / cooling-until-