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..2d4e33f7 --- /dev/null +++ b/src/cliproxy/__tests__/pool-routing-phase3.test.ts @@ -0,0 +1,1282 @@ +/** + * 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(); + }); + + // ── 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(); + }); +}); 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..18b1a636 --- /dev/null +++ b/src/cliproxy/accounts/account-safety-cross-lane.ts @@ -0,0 +1,72 @@ +/** + * 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 the currently active native Claude Code profile (via `claude + * auth status`). The profiles.json v3.0 schema removed the email field, + * so we rely on the live auth status command which is already used by + * quota-fetcher-claude.ts. + * + * 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. + */ + +import { warn } from '../../utils/ui'; +import { getClaudeAuthStatus } from '../../utils/claude-detector'; +import { maskEmail } from './account-safety'; +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']; + +/** + * Check whether the newly added CLIProxy account email matches the email of + * the currently active native Claude Code profile. + * + * Emits a warning to stderr if an overlap is detected. Silent on errors + * (CLI not found, not logged in, etc.) — the check is best-effort. + * + * @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 status = getClaudeAuthStatus(); + if (!status?.loggedIn || !status.email) return; + + const normalized = email.toLowerCase().trim(); + const nativeNormalized = status.email.toLowerCase().trim(); + + if (normalized !== nativeNormalized) return; + + const masked = maskEmail(email); + const nativeMasked = maskEmail(status.email); + + console.error(''); + console.error(warn(`Account safety: cross-lane email overlap detected for ${provider}`)); + console.error(` CLIProxy account: ${masked} (${provider})`); + console.error(` Native Claude Code profile: ${nativeMasked} (logged in)`); + 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/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/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/routing/pool-opt-in-prompt.ts b/src/cliproxy/routing/pool-opt-in-prompt.ts new file mode 100644 index 00000000..70a2a361 --- /dev/null +++ b/src/cliproxy/routing/pool-opt-in-prompt.ts @@ -0,0 +1,236 @@ +/** + * 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 } 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 { + 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' }; + } + + // 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 + const target = getProxyTarget(); + if (target.isRemote) { + printRemoteHint(provider); + return { prompted: false, enabled: false, skipped: true, skipReason: 'remote-target' }; + } + + // Non-TTY (piped input / CI): skip silently + if (!process.stdin.isTTY || !process.stderr.isTTY) { + return { prompted: false, enabled: false, skipped: true, skipReason: 'non-tty' }; + } + + // 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 (after TTY and remote + // guards) ensures the re-auth path correctly falls back to not-at-transition + // without triggering the prompt for a single-account user. + const accountCountAfter = getProviderAccounts(provider).length; + if (accountCountAfter < 2) { + return { prompted: false, enabled: false, skipped: true, skipReason: 'not-at-transition' }; + } + + // 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.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..ee94c6db 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,11 +9,248 @@ 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'; + +export interface EnablePoolRoutingResult { + /** Whether the pool routing state actually changed */ + changed: boolean; + /** Whether an existing explicit user routing setting was preserved */ + preservedExplicitSetting: boolean; + message: string; +} + +export interface DisablePoolRoutingResult { + changed: 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; + if (already) { + 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, + }; + }); + + const configPath = options.configPath ?? getConfigPathForPort(port); + const authDir = options.authDir ?? getAuthDir(); + regenerateConfig(port, { configPath, authDir }); + + 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; + 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, + }; + }); + + const configPath = options.configPath ?? getConfigPathForPort(port); + const authDir = options.authDir ?? getAuthDir(); + regenerateConfig(port, { configPath, authDir }); + + 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}+$`); diff --git a/src/commands/cliproxy/help-subcommand.ts b/src/commands/cliproxy/help-subcommand.ts index a7ec3fd4..1bc7ab10 100644 --- a/src/commands/cliproxy/help-subcommand.ts +++ b/src/commands/cliproxy/help-subcommand.ts @@ -63,6 +63,9 @@ export async function showHelp(): Promise { ['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'], ], ], [ diff --git a/src/commands/cliproxy/index.ts b/src/commands/cliproxy/index.ts index 02ae8bc5..34a3ca78 100644 --- a/src/commands/cliproxy/index.ts +++ b/src/commands/cliproxy/index.ts @@ -48,6 +48,7 @@ import { handleCatalogReset, handleCatalogJson, } from './catalog-subcommand'; +import { handlePoolSubcommand } from './pool-subcommand'; /** * Parse --backend flag from args @@ -186,6 +187,11 @@ export async function handleCliproxyCommand(args: string[]): Promise { return; } + if (command === 'pool') { + await handlePoolSubcommand(remainingArgs.slice(1)); + return; + } + if (command === 'routing') { const subcommand = remainingArgs[1]; if (subcommand === 'set') { diff --git a/src/commands/cliproxy/pool-subcommand.ts b/src/commands/cliproxy/pool-subcommand.ts new file mode 100644 index 00000000..c248066b --- /dev/null +++ b/src/commands/cliproxy/pool-subcommand.ts @@ -0,0 +1,66 @@ +/** + * CLIProxy Pool Routing Subcommand + * + * Handles: + * ccs cliproxy pool --enable Enable pool routing (fill-first + affinity + cooling ON) + * ccs cliproxy pool --disable Disable pool routing and restore non-pool config + * ccs cliproxy pool Show current pool routing state + */ + +import { initUI, header, ok, warn, info } from '../../utils/ui'; +import { enablePoolRouting, disablePoolRouting } from '../../cliproxy/routing/routing-strategy'; +import { loadOrCreateUnifiedConfig } from '../../config/config-loader-facade'; +import { CLIPROXY_DEFAULT_PORT } from '../../cliproxy/config/port-manager'; +import { getConfigPathForPort, getAuthDir } from '../../cliproxy/config/path-resolver'; +import { hasAnyFlag } from '../arg-extractor'; + +export async function handlePoolSubcommand(args: string[]): Promise { + await initUI(); + console.log(''); + console.log(header('CLIProxy Pool Routing')); + console.log(''); + + const port = CLIPROXY_DEFAULT_PORT; + const configPath = getConfigPathForPort(port); + const authDir = getAuthDir(); + + if (hasAnyFlag(args, ['--enable'])) { + const result = enablePoolRouting(port, { configPath, authDir }); + if (result.changed) { + console.log(ok(result.message)); + } else { + console.log(info(result.message)); + } + console.log(''); + return; + } + + if (hasAnyFlag(args, ['--disable'])) { + const result = disablePoolRouting(port, { configPath, authDir }); + if (result.changed) { + console.log(ok(result.message)); + } else { + console.log(info(result.message)); + } + console.log(''); + return; + } + + // Default: show status + const config = loadOrCreateUnifiedConfig(); + const enabled = config.cliproxy?.pool_routing?.enabled === true; + const dismissed = config.cliproxy?.pool_routing?.prompt_dismissed === true; + const maxRetry = config.cliproxy?.pool_routing?.max_retry_credentials; + + console.log(` Status: ${enabled ? ok('enabled') : warn('disabled')}`); + if (enabled && maxRetry !== undefined) { + console.log(` Max retry: ${maxRetry}`); + } + if (!enabled && dismissed) { + console.log(` Dismissed: ${info('yes (prompt will not re-show)')}`); + } + console.log(''); + console.log(` Enable: ccs cliproxy pool --enable`); + console.log(` Disable: ccs cliproxy pool --disable`); + console.log(''); +} diff --git a/src/commands/cliproxy/routing-subcommand.ts b/src/commands/cliproxy/routing-subcommand.ts index 6e0b9895..002d5522 100644 --- a/src/commands/cliproxy/routing-subcommand.ts +++ b/src/commands/cliproxy/routing-subcommand.ts @@ -1,4 +1,4 @@ -import { initUI, header, subheader, color, dim, ok, fail, infoBox } from '../../utils/ui'; +import { initUI, header, subheader, color, dim, ok, fail, infoBox, warn } from '../../utils/ui'; import { extractOption } from '../arg-extractor'; import { applyCliproxyRoutingStrategy, @@ -9,6 +9,7 @@ import { readCliproxyRoutingState, readCliproxySessionAffinityState, } from '../../cliproxy/routing/routing-strategy'; +import { loadOrCreateUnifiedConfig } from '../../config/config-loader-facade'; function printStrategyGuide(): void { console.log(subheader('Routing Modes:')); @@ -134,6 +135,20 @@ export async function handleRoutingSet(args: string[]): Promise { console.log(header('Update CLIProxy Routing')); console.log(''); + // Pool routing is active: the generator bypasses stored routing and emits + // fill-first/affinity regardless. Storing a strategy here creates a divergence + // between the unified config and the emitted config.yaml — warn the user. + const config = loadOrCreateUnifiedConfig(); + if (config.cliproxy?.pool_routing?.enabled === true) { + console.log( + warn( + '[!] Pool routing is active. The stored strategy will not take effect\n' + + ' until pool routing is disabled: ccs cliproxy pool --disable' + ) + ); + console.log(''); + } + const result = await applyCliproxyRoutingStrategy(requested); console.log(ok(`Routing strategy set to ${requested}`)); console.log(` Applied: ${color(result.applied, 'info')}`); @@ -223,6 +238,20 @@ export async function handleRoutingAffinitySet(args: string[]): Promise { console.log(header('Update CLIProxy Session Affinity')); console.log(''); + // Pool routing is active: the generator bypasses stored session-affinity and emits + // affinity:true/1h regardless. Storing a value here creates a divergence between + // the unified config and the emitted config.yaml — warn the user. + const affinityConfig = loadOrCreateUnifiedConfig(); + if (affinityConfig.cliproxy?.pool_routing?.enabled === true) { + console.log( + warn( + '[!] Pool routing is active. The stored affinity setting will not take effect\n' + + ' until pool routing is disabled: ccs cliproxy pool --disable' + ) + ); + console.log(''); + } + const result = await applyCliproxySessionAffinitySettings({ enabled: requested, ttl, diff --git a/src/config/schemas/cliproxy.ts b/src/config/schemas/cliproxy.ts index 0ac4695a..c5a9d5d3 100644 --- a/src/config/schemas/cliproxy.ts +++ b/src/config/schemas/cliproxy.ts @@ -122,6 +122,36 @@ export interface CLIProxyRoutingConfig { session_affinity_ttl?: string; } +/** + * Pool routing configuration for multi-account CLIProxy rotation. + * + * Pool routing is opt-in at the 1->2 account-add transition. + * When enabled: fill-first strategy, session affinity (1h TTL), cooling ON, + * and max-retry-credentials: 3 are written to the generated CLIProxy config. + * + * Cooling note: disable-cooling flips to false when pool routing is enabled. + * This is intentional — cooling is required for retry-cap to function correctly. + * See archaeology comment in generator.ts (CCS v5 commit fb77d72a). + */ +export interface CLIProxyPoolRoutingConfig { + /** + * Whether pool routing is active for this provider. + * Written by enablePoolRouting(); cleared by disablePoolRouting(). + */ + enabled?: boolean; + /** + * Max credentials to try per request before returning 429 to the caller. + * Effective only when pool routing (and therefore cooling) is enabled. + * Defaults to 3 when pool routing is enabled. + */ + max_retry_credentials?: number; + /** + * Whether the user has dismissed the pool routing opt-in prompt. + * Prevents re-prompting after an explicit decline. + */ + prompt_dismissed?: boolean; +} + /** * CLIProxy configuration section. */ @@ -150,4 +180,6 @@ export interface CLIProxyConfig { auto_sync?: boolean; /** Routing strategy for multi-account CLIProxy selection */ routing?: CLIProxyRoutingConfig; + /** Pool routing opt-in state and configuration */ + pool_routing?: CLIProxyPoolRoutingConfig; }