diff --git a/src/cliproxy/__tests__/pool-onboarding-phase5.test.ts b/src/cliproxy/__tests__/pool-onboarding-phase5.test.ts index 2bf32d5c..bf8ab6e2 100644 --- a/src/cliproxy/__tests__/pool-onboarding-phase5.test.ts +++ b/src/cliproxy/__tests__/pool-onboarding-phase5.test.ts @@ -383,8 +383,8 @@ describe('Phase 5: Pool Onboarding Hint', () => { } }); - // ── 13. Hint copy includes 'ccs claude' and quota trade-off language ────── - it("hint copy mentions 'ccs claude' and quota trade-off", async () => { + // ── 13. Hint copy is opt-in framed and names the enable command ────────── + it("hint copy mentions 'ccs claude' and the opt-in enable command", async () => { const ccsDir = path.join(tempHome, '.ccs'); writeProfiles(ccsDir, ['work', 'personal']); @@ -397,7 +397,12 @@ describe('Phase 5: Pool Onboarding Hint', () => { expect(result.printed).toBe(true); const allOutput = consoleSpy.mock.calls.map((c) => String(c[0])).join('\n'); expect(allOutput).toContain('ccs claude'); - expect(allOutput).toContain('share quota'); + // Copy must be opt-in (not declarative "already pools") and name the + // actual enable command so the user has an actionable next step. + expect(allOutput).toContain('ccs cliproxy pool --enable'); + expect(allOutput.toLowerCase()).toContain('can auto-continue'); + // Must NOT read as already-active behavior. + expect(allOutput).not.toContain('pool auto-continues'); } finally { consoleSpy.mockRestore(); } diff --git a/src/cliproxy/__tests__/pool-routing-phase3.test.ts b/src/cliproxy/__tests__/pool-routing-phase3.test.ts index 2d4e33f7..da79ad16 100644 --- a/src/cliproxy/__tests__/pool-routing-phase3.test.ts +++ b/src/cliproxy/__tests__/pool-routing-phase3.test.ts @@ -793,6 +793,118 @@ describe('Phase 3: cross-lane email overlap guard', () => { consoleErrorSpy.mockRestore(); }); + // ── Account-profile lane enumeration (isolated CLAUDE_CONFIG_DIR lanes) ── + // The ambient ~/.claude check alone misses the isolated lanes of CCS account + // profiles — exactly the multi-account population the guard protects. These + // tests prove the guard also reads each profile lane's .claude.json email. + + /** Write a profiles.json account entry + its instance-lane .claude.json email. */ + function seedAccountLane(ccsDir: string, name: string, email: string | null): void { + const profilesPath = path.join(ccsDir, 'profiles.json'); + let payload: { version: string; profiles: Record; default: string | null }; + try { + payload = JSON.parse(fs.readFileSync(profilesPath, 'utf-8')); + } catch { + payload = { version: '2.0.0', profiles: {}, default: null }; + } + payload.profiles[name] = { + type: 'account', + created: new Date().toISOString(), + last_used: null, + }; + fs.writeFileSync(profilesPath, JSON.stringify(payload, null, 2), 'utf8'); + + const instanceDir = path.join(ccsDir, 'instances', name); + fs.mkdirSync(instanceDir, { recursive: true }); + if (email !== null) { + fs.writeFileSync( + path.join(instanceDir, '.claude.json'), + JSON.stringify({ oauthAccount: { emailAddress: email } }, null, 2), + 'utf8' + ); + } + } + + it('warns when a CCS account-profile lane email matches, even though ambient ~/.claude is logged out', () => { + const ccsDir = path.join(tempHome, '.ccs'); + seedAccountLane(ccsDir, 'work', 'lane@example.com'); + + // Ambient default lane is logged OUT — old guard would stay silent. + const spy = spyOn(claudeDetector, 'getClaudeAuthStatus').mockReturnValue({ + loggedIn: false, + email: null, + authMethod: null, + apiProvider: null, + orgId: null, + orgName: null, + subscriptionType: null, + }); + const consoleErrorSpy = spyOn(console, 'error').mockImplementation((msg?: unknown) => { + if (typeof msg === 'string') stderrOutput.push(msg); + }); + + checkCrossLaneEmailOverlap('agy', 'lane@example.com'); + + const combined = stderrOutput.join('\n'); + expect(combined).toContain('cross-lane email overlap'); + // The matching profile lane is named so the user knows which lane overlaps. + expect(combined).toContain('profile "work"'); + + spy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); + + it('does not warn when no account-profile lane email matches', () => { + const ccsDir = path.join(tempHome, '.ccs'); + seedAccountLane(ccsDir, 'work', 'someone-else@example.com'); + + const spy = spyOn(claudeDetector, 'getClaudeAuthStatus').mockReturnValue({ + loggedIn: false, + email: null, + authMethod: null, + apiProvider: null, + orgId: null, + orgName: null, + subscriptionType: null, + }); + const consoleErrorSpy = spyOn(console, 'error').mockImplementation((msg?: unknown) => { + if (typeof msg === 'string') stderrOutput.push(msg); + }); + + checkCrossLaneEmailOverlap('agy', 'mine@example.com'); + + expect(stderrOutput.join('\n')).not.toContain('cross-lane'); + + spy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); + + it('silently skips an account-profile lane with a missing/unreadable .claude.json', () => { + const ccsDir = path.join(tempHome, '.ccs'); + // Account profile exists but its lane has no .claude.json (email === null). + seedAccountLane(ccsDir, 'broken', null); + + const spy = spyOn(claudeDetector, 'getClaudeAuthStatus').mockReturnValue({ + loggedIn: false, + email: null, + authMethod: null, + apiProvider: null, + orgId: null, + orgName: null, + subscriptionType: null, + }); + const consoleErrorSpy = spyOn(console, 'error').mockImplementation((msg?: unknown) => { + if (typeof msg === 'string') stderrOutput.push(msg); + }); + + // Must not warn and must not throw on the unreadable lane. + expect(() => checkCrossLaneEmailOverlap('agy', 'lane@example.com')).not.toThrow(); + expect(stderrOutput.join('\n')).not.toContain('cross-lane'); + + spy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); + // ── 15. Safety invariant: disablePoolRouting always restores cooling=true ─ it('safety invariant: disablePoolRouting ALWAYS writes disable-cooling: true', async () => { const { enablePoolRouting, disablePoolRouting } = await import( @@ -1280,3 +1392,194 @@ describe('Phase 3: routing-subcommand pool-active warning regression', () => { affinitySpy.mockRestore(); }); }); + +// ── PR #1514 review fixes ──────────────────────────────────────────────────── +// Legacy-config gate, automation bypass, enable rollback-on-regenerate-failure, +// remote pool-state manageable flag, and apply-message pool override note. + +describe('PR #1514: maybeOfferPoolRouting legacy-config and automation guards', () => { + let tempHome: string; + let originalCcsHome: string | undefined; + + beforeEach(() => { + tempHome = createTestHome(); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + invalidateSharedConfigCache(); + }); + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + fs.rmSync(tempHome, { recursive: true, force: true }); + delete process.env.CCS_YES; + }); + + // Fix index 5: legacy profiles.json-only install (no config.yaml) must skip the + // prompt before any prompting OR dismissal persistence, so we never implicitly + // create config.yaml and silently flip isUnifiedMode(). + it('skips with legacy-config and does not create config.yaml (decline path not reached)', async () => { + // Remove the config.yaml createTestHome wrote so hasUnifiedConfig() is false. + const configYaml = path.join(tempHome, '.ccs', 'config.yaml'); + fs.rmSync(configYaml, { force: true }); + invalidateSharedConfigCache(); + + const result = await poolOptInModule.maybeOfferPoolRouting('claude', 1); + + expect(result.skipped).toBe(true); + expect(result.skipReason).toBe('legacy-config'); + // Critical: the dismissal/accept paths (which write config.yaml) must NOT run. + expect(fs.existsSync(configYaml)).toBe(false); + }); + + // Fix index 9: --yes / CCS_YES must NOT auto-accept this instance-global consent. + // It must skip WITHOUT printing the prompt and WITHOUT persisting dismissal. + it('skips with automation-bypass under CCS_YES=1 without prompting or dismissing', async () => { + process.env.CCS_YES = '1'; + + // 2 accounts so we are past the post-add count check and reach the TTY/bypass guards. + const ccsDir = path.join(tempHome, '.ccs'); + const accountsPath = path.join(ccsDir, 'cliproxy', 'accounts.json'); + const authDir = path.join(ccsDir, 'cliproxy', 'auth'); + fs.mkdirSync(path.dirname(accountsPath), { recursive: true }); + fs.mkdirSync(authDir, { recursive: true }); + fs.writeFileSync( + accountsPath, + JSON.stringify({ + providers: { + claude: { + default: 'a1', + accounts: { + a1: { email: 'a@b.com', tokenFile: 'a.json', nickname: 'a', createdAt: 0 }, + a2: { email: 'c@d.com', tokenFile: 'c.json', nickname: 'c', createdAt: 0 }, + }, + }, + }, + }), + 'utf-8' + ); + fs.writeFileSync(path.join(authDir, 'a.json'), '{}', 'utf-8'); + fs.writeFileSync(path.join(authDir, 'c.json'), '{}', 'utf-8'); + + // Force TTY so only the automation guard (not the non-tty guard) can fire. + const origStdinTTY = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'); + const origStderrTTY = Object.getOwnPropertyDescriptor(process.stderr, 'isTTY'); + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + Object.defineProperty(process.stderr, 'isTTY', { value: true, configurable: true }); + + // confirm must NEVER be called on the bypass path. + const confirmSpy = spyOn(promptModule.InteractivePrompt, 'confirm').mockResolvedValue(true); + + const result = await poolOptInModule.maybeOfferPoolRouting('claude', 1, 8317); + + expect(result.skipped).toBe(true); + expect(result.skipReason).toBe('automation-bypass'); + expect(result.enabled).toBe(false); + expect(confirmSpy).not.toHaveBeenCalled(); + // Dismissal must NOT be persisted (future interactive run should still offer). + expect(poolOptInModule.isPoolPromptDismissed()).toBe(false); + + confirmSpy.mockRestore(); + if (origStdinTTY) { + Object.defineProperty(process.stdin, 'isTTY', origStdinTTY); + } else { + delete (process.stdin as { isTTY?: boolean }).isTTY; + } + if (origStderrTTY) { + Object.defineProperty(process.stderr, 'isTTY', origStderrTTY); + } else { + delete (process.stderr as { isTTY?: boolean }).isTTY; + } + }); +}); + +describe('PR #1514: enablePoolRouting rollback on regenerate failure', () => { + let tempHome: string; + let originalCcsHome: string | undefined; + + beforeEach(() => { + tempHome = createTestHome(); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + invalidateSharedConfigCache(); + }); + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + fs.rmSync(tempHome, { recursive: true, force: true }); + }); + + // Fix index 6: if regenerateConfig throws, the pool_routing.enabled flag must be + // rolled back so status surfaces do not lie, and a recovery message is returned. + // Force a real fs failure (no mock): place a regular FILE where the config + // directory must be, so regenerateConfig's mkdirSync(dirname) throws ENOTDIR. + it('rolls back enabled flag and returns failure recovery copy when regenerate throws', async () => { + const ts = Date.now(); + const { enablePoolRouting } = await import(`../routing/routing-strategy?p1514fail=${ts}`); + const { loadOrCreateUnifiedConfig } = await import( + `../../config/config-loader-facade?p1514fail=${ts}` + ); + const ccsDir = path.join(tempHome, '.ccs'); + const cliproxyDir = path.join(ccsDir, 'cliproxy'); + fs.mkdirSync(cliproxyDir, { recursive: true }); + // Block the config dir: create a FILE named "blocked" then aim the config path + // at blocked/config.yaml so mkdirSync(dirname) hits ENOTDIR. + const blockedFile = path.join(cliproxyDir, 'blocked'); + fs.writeFileSync(blockedFile, 'not a dir', 'utf-8'); + const configPath = path.join(blockedFile, 'config.yaml'); + const authDir = path.join(cliproxyDir, 'auth'); + + const result = enablePoolRouting(8317, { configPath, authDir }); + + expect(result.failed).toBe(true); + expect(result.changed).toBe(false); + expect(result.message).toContain('[X] Could not write CLIProxy config'); + expect(result.message).toContain('ccs cliproxy pool --enable'); + + // The flag must be rolled back to NOT-enabled so status surfaces do not lie. + const cfg = loadOrCreateUnifiedConfig(); + expect(cfg.cliproxy?.pool_routing?.enabled).not.toBe(true); + }); + + // Repair path: when the flag is already true (a prior regenerate failed), + // pool --enable must re-run regenerateConfig instead of a dead no-op. + it('already-enabled path re-runs regenerateConfig (idempotent repair)', async () => { + const ts = Date.now(); + // Persist enabled=true WITHOUT a regenerated config.yaml (simulate prior failure). + const { mutateConfig, invalidateConfigCache } = await import( + `../../config/config-loader-facade?p1514repair=${ts}` + ); + mutateConfig((cfg: { cliproxy?: { pool_routing?: Record } }) => { + cfg.cliproxy = cfg.cliproxy ?? {}; + cfg.cliproxy.pool_routing = { enabled: true, max_retry_credentials: 3 }; + }); + invalidateConfigCache(); + invalidateSharedConfigCache(); + + const { enablePoolRouting } = await import(`../routing/routing-strategy?p1514repair=${ts}`); + const ccsDir = path.join(tempHome, '.ccs'); + const configPath = path.join(ccsDir, 'cliproxy', 'config.yaml'); + const authDir = path.join(ccsDir, 'cliproxy', 'auth'); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.mkdirSync(authDir, { recursive: true }); + // Config does NOT exist yet — the repair must create it. + expect(fs.existsSync(configPath)).toBe(false); + + const result = enablePoolRouting(8317, { configPath, authDir }); + + // Idempotent (changed=false) but the config was regenerated with pool rails. + expect(result.changed).toBe(false); + expect(result.failed).toBeFalsy(); + expect(fs.existsSync(configPath)).toBe(true); + const content = fs.readFileSync(configPath, 'utf-8'); + expect(content).toContain('disable-cooling: false'); + expect(content).toContain('strategy: fill-first'); + }); +}); diff --git a/src/cliproxy/accounts/__tests__/drain-order.test.ts b/src/cliproxy/accounts/__tests__/drain-order.test.ts index 42ad8242..a8942d28 100644 --- a/src/cliproxy/accounts/__tests__/drain-order.test.ts +++ b/src/cliproxy/accounts/__tests__/drain-order.test.ts @@ -721,3 +721,215 @@ describe('resolveEffectiveDrainOrder', () => { }); }); }); + +// --------------------------------------------------------------------------- +// clearAuthFilePriorityDirect + clearDrainOrderPriorities +// (the missing half of `order --reset`: actually strip residual priorities) +// --------------------------------------------------------------------------- + +describe('clearAuthFilePriorityDirect', () => { + it('removes the priority field and preserves all other fields', async () => { + await withIsolatedHome(async (homeDir) => { + const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth'); + writeAuthFile(authDir, 'a.json', { email: 'a@x.com', priority: 4, keep: 'me' }); + + const { clearAuthFilePriorityDirect } = await loadDrainOrder(); + const removed = clearAuthFilePriorityDirect('a.json'); + + expect(removed).toBe(true); + const data = readAuthFile(authDir, 'a.json'); + expect('priority' in data).toBe(false); + expect(data.keep).toBe('me'); + expect(data.email).toBe('a@x.com'); + }); + }); + + it('returns false (no-op) when the file has no priority field', async () => { + await withIsolatedHome(async (homeDir) => { + const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth'); + writeAuthFile(authDir, 'a.json', { email: 'a@x.com' }); + + const { clearAuthFilePriorityDirect } = await loadDrainOrder(); + expect(clearAuthFilePriorityDirect('a.json')).toBe(false); + }); + }); + + it('throws when the file does not exist', async () => { + await withIsolatedHome(async () => { + const { clearAuthFilePriorityDirect } = await loadDrainOrder(); + expect(() => clearAuthFilePriorityDirect('ghost.json')).toThrow(); + }); + }); +}); + +describe('clearDrainOrderPriorities - direct path (proxy stopped)', () => { + it('clears residual priorities from all files via direct write', async () => { + await withIsolatedHome(async (homeDir) => { + const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth'); + writeAuthFile(authDir, 'a.json', { email: 'a@x.com', priority: 3 }); + writeAuthFile(authDir, 'b.json', { email: 'b@x.com' }); // already clear + + const { clearDrainOrderPriorities } = await loadDrainOrder(); + const result = await clearDrainOrderPriorities(['a.json', 'b.json'], false); + + expect(result.usedManagementApi).toBe(false); + expect(result.cleared).toEqual(['a.json']); + expect(result.alreadyClear).toEqual(['b.json']); + expect(result.failed).toHaveLength(0); + expect('priority' in readAuthFile(authDir, 'a.json')).toBe(false); + }); + }); + + it('records a failure for a missing file', async () => { + await withIsolatedHome(async () => { + const { clearDrainOrderPriorities } = await loadDrainOrder(); + const result = await clearDrainOrderPriorities(['ghost.json'], false); + expect(result.failed).toHaveLength(1); + expect(result.cleared).toHaveLength(0); + }); + }); +}); + +describe('clearDrainOrderPriorities - management API path (proxy running)', () => { + it('PATCHes priority:0 (delete) for files with a residual priority and skips clear ones', async () => { + await withIsolatedHome(async (homeDir) => { + const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth'); + writeAuthFile(authDir, 'a.json', { email: 'a@x.com', priority: 3 }); + writeAuthFile(authDir, 'b.json', { email: 'b@x.com' }); // already clear -> no API call + + const calls: Array<{ url: string; body: unknown }> = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = + typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + calls.push({ url, body: JSON.parse(String(init?.body ?? '{}')) }); + return new Response('{}', { status: 200 }); + }) as typeof fetch; + + try { + const { clearDrainOrderPriorities } = await loadDrainOrder(); + const result = await clearDrainOrderPriorities(['a.json', 'b.json'], true); + + expect(result.usedManagementApi).toBe(true); + expect(result.cleared).toEqual(['a.json']); + expect(result.alreadyClear).toEqual(['b.json']); + expect(result.failed).toHaveLength(0); + + // Only a.json (which had a residual priority) triggered an API call, + // and it patched priority:0 (the delete sentinel). + expect(calls).toHaveLength(1); + expect(calls[0].url).toContain('/v0/management/auth-files/fields'); + expect(calls[0].body).toEqual({ name: 'a.json', priority: 0 }); + } finally { + globalThis.fetch = originalFetch; + } + }); + }); + + it('records a failure when the PATCH returns a non-ok status', async () => { + await withIsolatedHome(async (homeDir) => { + const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth'); + writeAuthFile(authDir, 'a.json', { email: 'a@x.com', priority: 3 }); + + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => new Response('nope', { status: 500 })) as typeof fetch; + + try { + const { clearDrainOrderPriorities } = await loadDrainOrder(); + const result = await clearDrainOrderPriorities(['a.json'], true); + expect(result.cleared).toHaveLength(0); + expect(result.failed).toHaveLength(1); + expect(result.failed[0].tokenFile).toBe('a.json'); + } finally { + globalThis.fetch = originalFetch; + } + }); + }); +}); + +// --------------------------------------------------------------------------- +// fetchProxyAuthCooldowns - live in-proxy 429 cooldowns +// --------------------------------------------------------------------------- + +describe('fetchProxyAuthCooldowns', () => { + it('classifies unavailable entries as cooldowns and parses next_retry_after', async () => { + await withIsolatedHome(async () => { + const next = '2026-06-11T12:00:00.000Z'; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + files: [ + { name: 'cooling.json', unavailable: true, next_retry_after: next }, + { name: 'no-retry.json', unavailable: true }, + { name: 'healthy.json', unavailable: false }, + ], + }), + { status: 200 } + )) as typeof fetch; + + try { + const { fetchProxyAuthCooldowns } = await loadDrainOrder(); + const cooldowns = await fetchProxyAuthCooldowns(); + + // Only the two unavailable entries are returned; healthy.json is excluded. + expect(cooldowns.map((c: { tokenFile: string }) => c.tokenFile).sort()).toEqual([ + 'cooling.json', + 'no-retry.json', + ]); + const cooling = cooldowns.find( + (c: { tokenFile: string }) => c.tokenFile === 'cooling.json' + ); + expect(cooling?.cooldownUntil).toBe(Date.parse(next)); + const noRetry = cooldowns.find( + (c: { tokenFile: string }) => c.tokenFile === 'no-retry.json' + ); + expect(noRetry?.cooldownUntil).toBeUndefined(); + } finally { + globalThis.fetch = originalFetch; + } + }); + }); + + it('degrades silently to [] on a non-ok response', async () => { + await withIsolatedHome(async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => new Response('err', { status: 404 })) as typeof fetch; + try { + const { fetchProxyAuthCooldowns } = await loadDrainOrder(); + expect(await fetchProxyAuthCooldowns()).toEqual([]); + } finally { + globalThis.fetch = originalFetch; + } + }); + }); + + it('degrades silently to [] on a network error', async () => { + await withIsolatedHome(async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => { + throw new TypeError('network down'); + }) as typeof fetch; + try { + const { fetchProxyAuthCooldowns } = await loadDrainOrder(); + expect(await fetchProxyAuthCooldowns()).toEqual([]); + } finally { + globalThis.fetch = originalFetch; + } + }); + }); + + it('degrades silently to [] on an unexpected response shape', async () => { + await withIsolatedHome(async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response(JSON.stringify({ unexpected: 'shape' }), { status: 200 })) as typeof fetch; + try { + const { fetchProxyAuthCooldowns } = await loadDrainOrder(); + expect(await fetchProxyAuthCooldowns()).toEqual([]); + } finally { + globalThis.fetch = originalFetch; + } + }); + }); +}); diff --git a/src/cliproxy/accounts/__tests__/pool-state.test.ts b/src/cliproxy/accounts/__tests__/pool-state.test.ts index afb9301b..b3a9d9eb 100644 --- a/src/cliproxy/accounts/__tests__/pool-state.test.ts +++ b/src/cliproxy/accounts/__tests__/pool-state.test.ts @@ -37,6 +37,13 @@ async function withIsolatedHome(fn: (homeDir: string) => Promise | T): Pro } } +const SETTINGS_ON: PoolRoutingSettings = { + poolEnabled: true, + strategy: 'fill-first', + sessionAffinityEnabled: true, + sessionAffinityTtl: '1h', +}; + async function loadPoolState() { return import(`../pool-state?pool-state=${Date.now()}`); } @@ -260,3 +267,157 @@ describe('resolvePoolState - drain order', () => { }); }); }); + +describe('resolvePoolState - in-proxy cooldown classification', () => { + it('classifies an injected proxy cooldown as cooling with source "proxy"', async () => { + await withIsolatedHome(async () => { + const { resolvePoolState } = await loadPoolState(); + const until = 2_000_000; + const pool = resolvePoolState({ + provider: 'claude', + settings: SETTINGS_ON, + accounts: [account({ id: 'a@x.com', tokenFile: 'claude-a.json' })], + proxyCooldowns: [{ tokenFile: 'claude-a.json', cooldownUntil: until }], + now: 1_000_000, + }); + expect(pool.states[0].state).toBe('cooling'); + expect(pool.states[0].cooldownUntil).toBe(until); + expect(pool.states[0].cooldownSource).toBe('proxy'); + }); + }); + + it('treats a proxy cooldown with no next_retry_after as cooling (no reset time)', async () => { + await withIsolatedHome(async () => { + const { resolvePoolState } = await loadPoolState(); + const pool = resolvePoolState({ + provider: 'claude', + settings: SETTINGS_ON, + accounts: [account({ id: 'a@x.com', tokenFile: 'claude-a.json' })], + proxyCooldowns: [{ tokenFile: 'claude-a.json' }], + now: 1_000_000, + }); + expect(pool.states[0].state).toBe('cooling'); + expect(pool.states[0].cooldownUntil).toBeUndefined(); + expect(pool.states[0].cooldownSource).toBe('proxy'); + }); + }); + + it('proxy cooldown wins over a persisted quota cooldown for the same account', async () => { + await withIsolatedHome(async (homeDir) => { + const quotaPausedPath = path.join(homeDir, '.ccs', 'cliproxy', 'quota-paused.json'); + fs.mkdirSync(path.dirname(quotaPausedPath), { recursive: true }); + fs.writeFileSync( + quotaPausedPath, + JSON.stringify({ + entries: [ + { + provider: 'claude', + accountId: 'a@x.com', + pausedAt: '2026-06-10T12:00:00.000Z', + until: 1_500_000, + reason: 'quota_exhausted', + }, + ], + }) + ); + + const { resolvePoolState } = await loadPoolState(); + const pool = resolvePoolState({ + provider: 'claude', + settings: SETTINGS_ON, + accounts: [account({ id: 'a@x.com', tokenFile: 'claude-a.json' })], + proxyCooldowns: [{ tokenFile: 'claude-a.json', cooldownUntil: 9_000_000 }], + now: 1_000_000, + }); + // Proxy (live routing truth) wins: source 'proxy', its reset time, not the + // persisted quota-paused.json one. + expect(pool.states[0].cooldownSource).toBe('proxy'); + expect(pool.states[0].cooldownUntil).toBe(9_000_000); + }); + }); + + it('leaves accounts available when no proxy cooldown is provided (graceful degradation)', async () => { + await withIsolatedHome(async () => { + const { resolvePoolState, hasProxySourcedCooling } = await loadPoolState(); + const pool = resolvePoolState({ + provider: 'claude', + settings: SETTINGS_ON, + accounts: [account({ id: 'a@x.com', tokenFile: 'claude-a.json' })], + // proxyCooldowns omitted -> CCS-side view only. + now: 1_000_000, + }); + expect(pool.states[0].state).toBe('available'); + expect(hasProxySourcedCooling(pool)).toBe(false); + }); + }); + + it('hasProxySourcedCooling reports true only when a proxy-sourced cooling exists', async () => { + await withIsolatedHome(async () => { + const { resolvePoolState, hasProxySourcedCooling } = await loadPoolState(); + const pool = resolvePoolState({ + provider: 'claude', + settings: SETTINGS_ON, + accounts: [ + account({ id: 'a@x.com', tokenFile: 'claude-a.json' }), + account({ id: 'b@x.com', tokenFile: 'claude-b.json' }), + ], + proxyCooldowns: [{ tokenFile: 'claude-b.json', cooldownUntil: 2_000_000 }], + now: 1_000_000, + }); + expect(hasProxySourcedCooling(pool)).toBe(true); + }); + }); +}); + +describe('resolvePoolStateWithProxyCooldowns - fetch + degradation', () => { + it('skips the proxy fetch and stays available when pool routing is OFF', async () => { + await withIsolatedHome(async () => { + const { resolvePoolStateWithProxyCooldowns } = await loadPoolState(); + let fetched = false; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => { + fetched = true; + return new Response('{}', { status: 200 }); + }) as typeof fetch; + try { + const pool = await resolvePoolStateWithProxyCooldowns({ + provider: 'claude', + settings: SETTINGS_OFF, + accounts: [account({ id: 'a@x.com', tokenFile: 'claude-a.json' })], + now: 1_000_000, + }); + expect(pool.states[0].state).toBe('available'); + // Pool OFF -> no management API call at all. + expect(fetched).toBe(false); + } finally { + globalThis.fetch = originalFetch; + } + }); + }); + + it('honours injected proxyCooldowns without fetching, even when pool is ON', async () => { + await withIsolatedHome(async () => { + const { resolvePoolStateWithProxyCooldowns } = await loadPoolState(); + let fetched = false; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => { + fetched = true; + return new Response('{}', { status: 200 }); + }) as typeof fetch; + try { + const pool = await resolvePoolStateWithProxyCooldowns({ + provider: 'claude', + settings: SETTINGS_ON, + accounts: [account({ id: 'a@x.com', tokenFile: 'claude-a.json' })], + proxyCooldowns: [{ tokenFile: 'claude-a.json', cooldownUntil: 2_000_000 }], + now: 1_000_000, + }); + expect(pool.states[0].cooldownSource).toBe('proxy'); + // Injected cooldowns short-circuit the fetch. + expect(fetched).toBe(false); + } finally { + globalThis.fetch = originalFetch; + } + }); + }); +}); diff --git a/src/cliproxy/accounts/account-safety-cross-lane.ts b/src/cliproxy/accounts/account-safety-cross-lane.ts index 18b1a636..0e489828 100644 --- a/src/cliproxy/accounts/account-safety-cross-lane.ts +++ b/src/cliproxy/accounts/account-safety-cross-lane.ts @@ -8,30 +8,107 @@ * Google/Anthropic treat as suspicious. * * Scope of check: compare the newly registered CLIProxy account email against - * the email of the currently active native Claude Code profile (via `claude - * auth status`). The profiles.json v3.0 schema removed the email field, - * so we rely on the live auth status command which is already used by - * quota-fetcher-claude.ts. + * the email of every native Claude Code lane the user has: + * 1. The ambient ~/.claude login (via `claude auth status`). + * 2. Each isolated CCS account profile lane (per-profile CLAUDE_CONFIG_DIR + * instance dir). These are the exact multi-account population the guard + * exists to protect, so they MUST be inspected, not just the default lane. + * + * Lane emails are read directly from each lane's `.claude.json` + * (`oauthAccount.emailAddress`) — cheap file reads, no CLI spawn. The + * profiles.json v3.0 schema removed the email field, so the per-lane config is + * the source of truth for an account profile's logged-in email. * * This guard is advisory only: it warns on stderr but does not block the add. * The user may intentionally separate accounts; a false positive is less harmful - * than silently allowing a true overlap. + * than silently allowing a true overlap. Missing or unreadable lane files are + * silently skipped — a corrupt profile must never block or crash auth. */ +import * as fs from 'fs'; +import * as path from 'path'; import { warn } from '../../utils/ui'; import { getClaudeAuthStatus } from '../../utils/claude-detector'; import { maskEmail } from './account-safety'; +import InstanceManager from '../../management/instance-manager'; +import { ProfileRegistry } from '../../auth/profile-registry'; import type { CLIProxyProvider } from '../types'; /** Providers where CLIProxy OAuth could create a cross-lane conflict with native Claude */ const CROSS_LANE_RISK_PROVIDERS: CLIProxyProvider[] = ['claude', 'agy', 'gemini', 'codex']; +/** A native Claude lane that overlaps with the new CLIProxy account email. */ +interface LaneOverlap { + /** The lane's masked email (for display). */ + maskedEmail: string; + /** Human label for the lane, e.g. "native Claude Code login" or `profile "work"`. */ + laneLabel: string; +} + /** - * Check whether the newly added CLIProxy account email matches the email of - * the currently active native Claude Code profile. + * Read the OAuth email stored in a native Claude lane's `.claude.json`. * - * Emits a warning to stderr if an overlap is detected. Silent on errors - * (CLI not found, not logged in, etc.) — the check is best-effort. + * Returns the lowercased/trimmed email, or null when the file is missing, + * unreadable, malformed, or has no logged-in account. Never throws — a corrupt + * profile is silently skipped (the guard is advisory). + */ +function readLaneEmail(claudeConfigDir: string): string | null { + try { + const claudeJsonPath = path.join(claudeConfigDir, '.claude.json'); + const raw = fs.readFileSync(claudeJsonPath, 'utf-8'); + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== 'object') return null; + const oauthAccount = (parsed as Record).oauthAccount; + if (!oauthAccount || typeof oauthAccount !== 'object') return null; + const email = (oauthAccount as Record).emailAddress; + if (typeof email !== 'string' || email.trim().length === 0) return null; + return email.toLowerCase().trim(); + } catch { + // Missing / unreadable / malformed lane config — skip silently. + return null; + } +} + +/** + * Enumerate CCS account profile lanes and collect any whose stored OAuth email + * matches the newly added CLIProxy account email. + * + * Pure file reads (one `.claude.json` per profile) — no CLI spawn. Best-effort: + * any failure to enumerate or read a lane is swallowed so the guard can never + * block or crash the add. + */ +function findAccountProfileOverlaps(normalizedEmail: string): LaneOverlap[] { + const overlaps: LaneOverlap[] = []; + try { + const registry = new ProfileRegistry(); + const profiles = registry.getAllProfilesMerged(); + const accountNames = Object.entries(profiles) + .filter(([, meta]) => meta.type === 'account') + .map(([name]) => name); + if (accountNames.length === 0) return overlaps; + + const instanceMgr = new InstanceManager(); + for (const name of accountNames) { + const instancePath = instanceMgr.getInstancePath(name); + const laneEmail = readLaneEmail(instancePath); + if (laneEmail && laneEmail === normalizedEmail) { + overlaps.push({ maskedEmail: maskEmail(laneEmail), laneLabel: `profile "${name}"` }); + } + } + } catch { + // Registry / instance-manager construction failed — skip lane enumeration. + } + return overlaps; +} + +/** + * Check whether the newly added CLIProxy account email matches the email of any + * native Claude Code lane: the ambient ~/.claude login or any isolated CCS + * account profile lane. + * + * Emits a warning to stderr for each overlapping lane. Silent on errors + * (CLI not found, not logged in, unreadable profile, etc.) — the check is + * best-effort and never blocks the add. * * @param provider - The CLIProxy provider being added * @param email - Email address of the account that was just registered @@ -40,21 +117,36 @@ export function checkCrossLaneEmailOverlap(provider: CLIProxyProvider, email: st if (!CROSS_LANE_RISK_PROVIDERS.includes(provider)) return; try { - const status = getClaudeAuthStatus(); - if (!status?.loggedIn || !status.email) return; - const normalized = email.toLowerCase().trim(); - const nativeNormalized = status.email.toLowerCase().trim(); + if (normalized.length === 0) return; - if (normalized !== nativeNormalized) return; + const overlaps: LaneOverlap[] = []; + + // 1. Ambient ~/.claude login (default lane). + const status = getClaudeAuthStatus(); + if (status?.loggedIn && status.email) { + const ambientNormalized = status.email.toLowerCase().trim(); + if (ambientNormalized === normalized) { + overlaps.push({ + maskedEmail: maskEmail(status.email), + laneLabel: 'native Claude Code login', + }); + } + } + + // 2. Isolated CCS account profile lanes (per-profile CLAUDE_CONFIG_DIR). + overlaps.push(...findAccountProfileOverlaps(normalized)); + + if (overlaps.length === 0) return; const masked = maskEmail(email); - const nativeMasked = maskEmail(status.email); console.error(''); console.error(warn(`Account safety: cross-lane email overlap detected for ${provider}`)); console.error(` CLIProxy account: ${masked} (${provider})`); - console.error(` Native Claude Code profile: ${nativeMasked} (logged in)`); + for (const overlap of overlaps) { + console.error(` Native Claude Code lane: ${overlap.maskedEmail} (${overlap.laneLabel})`); + } console.error( ' Same account active in both CLIProxy and native Claude lanes is a known ban risk.' ); diff --git a/src/cliproxy/accounts/drain-order.ts b/src/cliproxy/accounts/drain-order.ts index d63c350d..6f1e82ce 100644 --- a/src/cliproxy/accounts/drain-order.ts +++ b/src/cliproxy/accounts/drain-order.ts @@ -29,9 +29,30 @@ export const MIN_PRIORITY = 1; /** Management API path for patching auth file fields */ const AUTH_FILES_FIELDS_PATH = '/v0/management/auth-files/fields'; +/** Management API path for listing auth files (and their runtime status). */ +const AUTH_FILES_PATH = '/v0/management/auth-files'; + /** Timeout for management API calls in ms */ const MGMT_TIMEOUT_MS = 5000; +/** + * One in-proxy cooldown reading, keyed by auth file basename. + * + * Source of truth: GET /v0/management/auth-files. Verified field names from + * CLIProxyAPIPlus internal/api/handlers/management/auth_files.go buildAuthFileEntry: + * - "name" -> the token file basename + * - "unavailable" -> bool, set when a credential is cooling after a 429 + * - "next_retry_after" -> RFC3339 timestamp, present only when non-zero + * CLIProxyAPI (the base upstream) exposes the same fields via its own + * auth-files handler (sdk/cliproxy/auth/types.go: Unavailable / NextRetryAfter). + */ +export interface ProxyAuthCooldown { + /** Token file basename (matches AccountInfo.tokenFile). */ + tokenFile: string; + /** Epoch ms when the in-proxy cooldown is eligible to lift, if known. */ + cooldownUntil?: number; +} + /** * Tier rank for priority derivation. * Higher rank = higher priority = drained first. @@ -258,6 +279,29 @@ export async function writeAuthFilePriorityViaApi( throw new Error(`Priority must be >= ${MIN_PRIORITY}, got ${priority}`); } + return patchAuthFilePriority(tokenFile, priority); +} + +/** + * Remove the priority attribute via CLIProxy management API (proxy running). + * Sends PATCH /v0/management/auth-files/fields with priority:0, which the + * management layer treats as "delete the attribute" (verified upstream: + * CLIProxyAPIPlus syncAuthFilePriorityAttribute deletes the attribute when the + * incoming priority is 0 or absent, which then persists the auth file without a + * top-level priority field). + * + * Using the API path is mandatory while the proxy is running: a direct file + * write would be clobbered by the proxy's whole-file MarkResult persist. + * + * @param tokenFile File name (basename, e.g. "antigravity-foo.json") + * @returns true on success, false on failure + */ +export async function clearAuthFilePriorityViaApi(tokenFile: string): Promise { + return patchAuthFilePriority(tokenFile, 0); +} + +/** Shared PATCH-fields call for setting (>=1) or clearing (0) a file's priority. */ +async function patchAuthFilePriority(tokenFile: string, priority: number): Promise { const target = getProxyTarget(); const url = buildProxyUrl(target, AUTH_FILES_FIELDS_PATH); const headers = buildManagementHeaders(target, { 'Content-Type': 'application/json' }); @@ -283,6 +327,105 @@ export async function writeAuthFilePriorityViaApi( } } +/** + * Remove the priority field directly from an auth JSON file (proxy stopped). + * No-op (returns false) when the file has no priority field. Preserves all + * other fields; uses the same atomic temp-rename as writeAuthFilePriorityDirect. + * + * @returns true if a priority field was present and removed, false otherwise + */ +export function clearAuthFilePriorityDirect(tokenFile: string): boolean { + const authDir = getAuthDir(); + const filePath = path.join(authDir, tokenFile); + + if (!fs.existsSync(filePath)) { + throw new Error(`Auth file not found: ${tokenFile}`); + } + + const content = fs.readFileSync(filePath, 'utf-8'); + const data = JSON.parse(content) as Record; + + if (!('priority' in data)) { + return false; + } + + delete data.priority; + + const tempPath = `${filePath}.tmp.${process.pid}`; + fs.writeFileSync(tempPath, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 }); + fs.renameSync(tempPath, filePath); + return true; +} + +/** Result of clearing residual priorities across a set of auth files. */ +export interface ClearDrainOrderPrioritiesResult { + /** Token files where a priority field was present and removed. */ + cleared: string[]; + /** Token files that had no priority field (nothing to do). */ + alreadyClear: string[]; + /** Token files that failed, with the reason. */ + failed: Array<{ tokenFile: string; reason: string }>; + /** Whether the management API path was used (proxy was running). */ + usedManagementApi: boolean; +} + +/** + * Clear residual on-disk priority fields for the given auth files. + * + * Mirrors applyDrainOrder's dual write path: + * - Proxy running: PATCH priority:0 via the management API (the only safe path; + * a direct write is clobbered by the proxy's whole-file persist). + * - Proxy stopped: delete the field directly with an atomic temp-rename. + * + * Idempotent: files with no priority field are reported as alreadyClear, not + * failures. The running path always reports the file as cleared on a 200 (the + * API treats a missing-attribute delete as a successful no-op), so this is safe + * to run repeatedly. + * + * @param tokenFiles Auth file basenames to clear. + * @param proxyRunning Whether the proxy is currently running. + */ +export async function clearDrainOrderPriorities( + tokenFiles: string[], + proxyRunning: boolean +): Promise { + const result: ClearDrainOrderPrioritiesResult = { + cleared: [], + alreadyClear: [], + failed: [], + usedManagementApi: proxyRunning, + }; + + for (const tokenFile of tokenFiles) { + if (proxyRunning) { + // Skip the API round-trip when the file already has no priority on disk. + if (readAuthFilePriority(tokenFile) === undefined) { + result.alreadyClear.push(tokenFile); + continue; + } + const ok = await clearAuthFilePriorityViaApi(tokenFile); + if (ok) { + result.cleared.push(tokenFile); + } else { + result.failed.push({ tokenFile, reason: 'management API PATCH failed' }); + } + } else { + try { + const removed = clearAuthFilePriorityDirect(tokenFile); + if (removed) { + result.cleared.push(tokenFile); + } else { + result.alreadyClear.push(tokenFile); + } + } catch (err) { + result.failed.push({ tokenFile, reason: (err as Error).message }); + } + } + } + + return result; +} + /** * Result of a drain order apply operation. */ @@ -496,3 +639,76 @@ export function resolveEffectiveDrainOrder( return { mode, entries, hasDrift }; } + +/** Raw auth-file entry shape we parse out of the management listing. */ +interface RawManagementAuthFile { + name?: unknown; + unavailable?: unknown; + next_retry_after?: unknown; +} + +/** + * Fetch in-proxy cooldowns from the running CLIProxy. + * + * Pool routing turns cooling ON, so when a credential hits a 429 the proxy + * marks it Unavailable and rotates to a healthy one. That cooldown lives inside + * the proxy process (not in CCS's quota-paused.json), so the only way the quota + * Pool section can explain a session hop is to read it here. + * + * GET /v0/management/auth-files returns { files: [{ name, unavailable, + * next_retry_after, ... }] }. We classify any entry with unavailable=true as a + * cooling credential and parse next_retry_after (RFC3339) into epoch ms when + * present. + * + * Degrades silently to an empty array on any failure (proxy not running, + * endpoint missing on an older binary, malformed/unknown response shape, + * timeout): the caller falls back to its CCS-side cooldown view with no error + * spam in quota output. Latency is bounded by a single request and MGMT_TIMEOUT_MS. + * + * @returns One entry per auth file the proxy reports unavailable. + */ +export async function fetchProxyAuthCooldowns(): Promise { + const target = getProxyTarget(); + const url = buildProxyUrl(target, AUTH_FILES_PATH); + const headers = buildManagementHeaders(target); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), MGMT_TIMEOUT_MS); + + try { + const response = await fetch(url, { method: 'GET', headers, signal: controller.signal }); + if (!response.ok) { + return []; + } + + const data = (await response.json()) as { files?: unknown }; + if (!Array.isArray(data.files)) { + return []; + } + + const cooldowns: ProxyAuthCooldown[] = []; + for (const raw of data.files as RawManagementAuthFile[]) { + // Defensive parse: only entries the proxy explicitly flags unavailable. + if (!raw || raw.unavailable !== true) { + continue; + } + const name = typeof raw.name === 'string' ? raw.name : undefined; + if (!name) { + continue; + } + let cooldownUntil: number | undefined; + if (typeof raw.next_retry_after === 'string') { + const parsed = Date.parse(raw.next_retry_after); + if (Number.isFinite(parsed)) { + cooldownUntil = parsed; + } + } + cooldowns.push({ tokenFile: name, cooldownUntil }); + } + return cooldowns; + } catch { + return []; + } finally { + clearTimeout(timeoutId); + } +} diff --git a/src/cliproxy/accounts/pool-state.ts b/src/cliproxy/accounts/pool-state.ts index 6483880a..bbeec361 100644 --- a/src/cliproxy/accounts/pool-state.ts +++ b/src/cliproxy/accounts/pool-state.ts @@ -15,6 +15,10 @@ * with "it ran out of quota". * * Cooldown source precedence: + * - The in-proxy cooldown (read from GET /v0/management/auth-files) is the real + * hop mechanism when pool routing is ON: the proxy cools a credential on a 429 + * and rotates. It wins over everything else because it is the live routing + * truth that actually caused the session to move. * - The proxy/monitor process writes quota-paused.json (cross-process truth). * When pool routing's cooling flip is OFF (stock disable-cooling: true) there * is simply no cooldown data to show; this resolver reports that honestly. @@ -23,10 +27,18 @@ * both exist, because that is the routing truth across processes. */ -import { resolveEffectiveDrainOrder, type DrainOrderInput } from './drain-order'; +import { + resolveEffectiveDrainOrder, + fetchProxyAuthCooldowns, + type DrainOrderInput, + type ProxyAuthCooldown, +} from './drain-order'; import { getProviderAccounts } from './query'; import { readQuotaCooldownEntries } from './account-safety'; import { getCooldownUntil } from '../quota/quota-manager'; +import { getProxyTarget } from '../proxy/proxy-target-resolver'; +import { detectRunningProxy } from '../proxy/proxy-detector'; +import { resolveLifecyclePort } from '../config/port-manager'; import type { AccountInfo, AccountTier } from './types'; import type { CLIProxyProvider } from '../types'; @@ -54,9 +66,11 @@ export interface PoolAccountState { cooldownUntil?: number; /** * For state 'cooling': where the cooldown reading came from. - * 'persisted' = quota-paused.json (cross-process), 'memory' = in-process map. + * 'proxy' = live in-proxy 429 cooldown (GET /v0/management/auth-files), + * 'persisted' = quota-paused.json (cross-process), + * 'memory' = in-process quota-manager map. */ - cooldownSource?: 'persisted' | 'memory'; + cooldownSource?: 'proxy' | 'persisted' | 'memory'; /** ISO timestamp the account was paused (manual pause), when state is 'paused'. */ pausedAt?: string; } @@ -103,23 +117,34 @@ export interface ResolvePoolStateInput { accounts?: AccountInfo[]; /** Override for tests; defaults to Date.now(). */ now?: number; + /** + * Live in-proxy cooldowns (GET /v0/management/auth-files), keyed nowhere - + * passed as a flat list and indexed by tokenFile internally. When provided, + * an entry takes precedence over CCS-side cooldown views for the matching + * auth file. Defaults to none (CCS-side classification only). + */ + proxyCooldowns?: ProxyAuthCooldown[]; } /** * Classify a single account's pool state. * * Precedence: - * 1. A persisted quota cooldown whose pausedAt matches the account's pausedAt is + * 1. A live in-proxy cooldown (proxy reports the credential unavailable after a + * 429) -> 'cooling' source 'proxy'. This is the actual hop mechanism when + * pool routing is ON, so it wins over the CCS-side views below. + * 2. A persisted quota cooldown whose pausedAt matches the account's pausedAt is * a quota cooldown -> 'cooling' (cross-process truth wins). - * 2. A paused account without a matching cooldown record is a manual/safety pause + * 3. A paused account without a matching cooldown record is a manual/safety pause * -> 'paused'. - * 3. An unpaused account with an in-memory cooldown still in effect -> 'cooling'. - * 4. Otherwise -> 'available'. + * 4. An unpaused account with an in-memory cooldown still in effect -> 'cooling'. + * 5. Otherwise -> 'available'. */ function classifyAccount( provider: CLIProxyProvider, account: AccountInfo, cooldownByAccount: Map, + proxyCooldownByTokenFile: Map, now: number ): PoolAccountState { const base: PoolAccountState = { @@ -130,6 +155,22 @@ function classifyAccount( state: 'available', }; + // Live in-proxy cooldown wins: this is the credential the proxy actually + // rotated out on a 429. A missing/zero next_retry_after still means cooling; + // we just cannot show a reset time for it. + const proxyCooldown = proxyCooldownByTokenFile.get(account.tokenFile); + if ( + proxyCooldown && + (proxyCooldown.cooldownUntil === undefined || proxyCooldown.cooldownUntil > now) + ) { + return { + ...base, + state: 'cooling', + cooldownUntil: proxyCooldown.cooldownUntil, + cooldownSource: 'proxy', + }; + } + const persisted = cooldownByAccount.get(account.id); if (account.paused) { @@ -194,6 +235,10 @@ function resolveDrainOrder( /** * Resolve the full observable pool state for a provider. + * + * Synchronous: callers that want live in-proxy 429 cooldowns surfaced (the real + * hop mechanism when pool routing is ON) pass them in via input.proxyCooldowns, + * or use resolvePoolStateWithProxyCooldowns() which fetches them first. */ export function resolvePoolState(input: ResolvePoolStateInput): PoolState { const { provider, settings } = input; @@ -207,8 +252,14 @@ export function resolvePoolState(input: ResolvePoolStateInput): PoolState { cooldownByAccount.set(entry.accountId, { until: entry.until, pausedAt: entry.pausedAt }); } + // Index live in-proxy cooldowns by token file (the proxy reports by file name). + const proxyCooldownByTokenFile = new Map(); + for (const entry of input.proxyCooldowns ?? []) { + proxyCooldownByTokenFile.set(entry.tokenFile, entry); + } + const states = accounts.map((account) => - classifyAccount(provider, account, cooldownByAccount, now) + classifyAccount(provider, account, cooldownByAccount, proxyCooldownByTokenFile, now) ); // Drain order is computed over accounts that are in rotation (not paused). @@ -222,3 +273,44 @@ export function resolvePoolState(input: ResolvePoolStateInput): PoolState { return { provider, states, drainOrder, settings }; } + +/** + * Whether any account was classified 'cooling' from the live in-proxy source. + * Used by the renderer to decide whether the "in-proxy cooldowns not shown" + * honesty note still applies. + */ +export function hasProxySourcedCooling(pool: PoolState): boolean { + return pool.states.some((s) => s.state === 'cooling' && s.cooldownSource === 'proxy'); +} + +/** + * Resolve pool state and, when pool routing is ON and the local proxy is + * reachable, fold in live in-proxy 429 cooldowns (the actual session-hop + * mechanism). Remote targets and a stopped/older proxy degrade silently to the + * synchronous CCS-side view with no error output. + * + * Kept separate from resolvePoolState() so the pure, synchronous resolver stays + * test-friendly and free of network/process detection. + */ +export async function resolvePoolStateWithProxyCooldowns( + input: ResolvePoolStateInput +): Promise { + // Only the local proxy exposes a management API we can read here; remote + // drain/cooldown management is out of scope (v1), so skip the fetch. + const shouldFetch = input.settings.poolEnabled && !getProxyTarget().isRemote; + + let proxyCooldowns: ProxyAuthCooldown[] | undefined = input.proxyCooldowns; + if (proxyCooldowns === undefined && shouldFetch) { + try { + const proxyStatus = await detectRunningProxy(resolveLifecyclePort()); + if (proxyStatus.running && proxyStatus.verified) { + proxyCooldowns = await fetchProxyAuthCooldowns(); + } + } catch { + // Degrade silently: keep the CCS-side cooldown view. + proxyCooldowns = undefined; + } + } + + return resolvePoolState({ ...input, proxyCooldowns }); +} diff --git a/src/cliproxy/config/__tests__/claude-model-neutral.test.ts b/src/cliproxy/config/__tests__/claude-model-neutral.test.ts index 989b642f..2c002b5e 100644 --- a/src/cliproxy/config/__tests__/claude-model-neutral.test.ts +++ b/src/cliproxy/config/__tests__/claude-model-neutral.test.ts @@ -10,7 +10,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; -import { getClaudeEnvVars, ensureProviderSettings } from '../env-builder'; +import { getClaudeEnvVars, ensureProviderSettings, getRemoteEnvVars } from '../env-builder'; import { clearConfigCache } from '../base-config-loader'; const MODEL_KEYS = [ @@ -380,4 +380,174 @@ describe('claude provider model-neutral passthrough (Gap 1)', () => { expect(result.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6'); expect(result.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-sonnet-4-6'); }); + + // ── Upgrader --config ordering: migrate-first, then re-pin survives ─────────── + // Models the `ccs claude --config` flow on an UPGRADER: the executor runs + // ensureProviderSettings BEFORE configureProviderModel writes the user's pick. + // Step 1 (ensureProviderSettings) strips the old auto-pins and sets the marker; + // Step 2 (the user's --config write) lands a fresh pin; Step 3 (next plain + // launch) must NOT strip it because the marker is already set. + it('upgrader --config: ensureProviderSettings runs first, so a re-pin equal to a stale default survives the next launch', () => { + process.env.CCS_HOME = tempHome; + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + const settingsPath = path.join(ccsDir, 'claude.settings.json'); + const markerPath = path.join(ccsDir, 'cliproxy', '.claude-model-migrated'); + + // Pre-existing upgrader file with OLD auto-written default pins, no marker. + fs.writeFileSync( + settingsPath, + JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: 'claude-sonnet-4-5-20250929', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-5-20251101', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-20250929', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + }, + }), + 'utf-8' + ); + expect(fs.existsSync(markerPath)).toBe(false); + + // Step 1: executor runs ensureProviderSettings BEFORE the --config write. + // This strips the old auto-pins and sets the migration marker. + ensureProviderSettings('claude'); + expect(fs.existsSync(markerPath)).toBe(true); + const afterMigrate = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { + env: Record; + }; + for (const key of MODEL_KEYS) { + const value = afterMigrate.env[key]; + expect(!value || value.trim().length === 0).toBe(true); + } + + // Step 2: configureProviderModel writes the user's deliberate pick — and the + // pick happens to equal a current catalog default that is in the stale set. + const userPin = { + ANTHROPIC_MODEL: 'claude-sonnet-4-6', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-sonnet-4-6', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-sonnet-4-6', + }; + const current = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { + env: Record; + }; + fs.writeFileSync( + settingsPath, + JSON.stringify({ ...current, env: { ...current.env, ...userPin } }, null, 2) + '\n', + 'utf-8' + ); + + // Step 3: next plain `ccs claude` launch must NOT strip the just-written pin + // because the marker was already set in Step 1. + ensureProviderSettings('claude'); + const result = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { + env: Record; + }; + expect(result.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6'); + expect(result.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-sonnet-4-6'); + expect(result.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6'); + expect(result.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-sonnet-4-6'); + }); + + // ── Remote read path: stale-pin filter without file mutation ────────────────── + + it('getRemoteEnvVars (claude): drops stale default model pins from the settings file at read level', () => { + process.env.CCS_HOME = tempHome; + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + const settingsPath = path.join(ccsDir, 'claude.settings.json'); + const originalContent = + JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: 'claude-sonnet-4-6', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-7', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + }, + }) + '\n'; + fs.writeFileSync(settingsPath, originalContent, 'utf-8'); + + const env = getRemoteEnvVars('claude', { + host: 'example.com', + port: 8317, + protocol: 'http', + }); + + // Stale defaults must NOT leak into the remote env. + for (const key of MODEL_KEYS) { + expect(env[key]).toBeUndefined(); + } + // Transport keys are always set from the remote config. + expect(env.ANTHROPIC_BASE_URL).toContain('example.com'); + expect(env.ANTHROPIC_AUTH_TOKEN).toBeDefined(); + + // Read-level only: the settings file on disk is untouched. + expect(fs.readFileSync(settingsPath, 'utf-8')).toBe(originalContent); + }); + + it('getRemoteEnvVars (claude): preserves a user-custom model pin not in any stale set', () => { + process.env.CCS_HOME = tempHome; + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + const settingsPath = path.join(ccsDir, 'claude.settings.json'); + fs.writeFileSync( + settingsPath, + JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: 'claude-opus-4-8', // custom — not a historical default + }, + }), + 'utf-8' + ); + + const env = getRemoteEnvVars('claude', { + host: 'example.com', + port: 8317, + protocol: 'http', + }); + + // Custom pin survives the remote read path. + expect(env.ANTHROPIC_MODEL).toBe('claude-opus-4-8'); + }); + + it('getRemoteEnvVars (claude): Priority 1 explicit custom settings path is NOT stale-filtered', () => { + process.env.CCS_HOME = tempHome; + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + // An explicitly passed settings file is the user's deliberate choice and + // must be honored verbatim — even when it carries a value equal to a + // historical default. + const customPath = path.join(ccsDir, 'custom-claude.settings.json'); + fs.writeFileSync( + customPath, + JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: 'claude-sonnet-4-6', // equals a stale default, but explicit + }, + }), + 'utf-8' + ); + + const env = getRemoteEnvVars( + 'claude', + { host: 'example.com', port: 8317, protocol: 'http' }, + customPath + ); + + // Priority 1 untouched: the explicit pin survives. + expect(env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6'); + }); }); diff --git a/src/cliproxy/config/env-builder.ts b/src/cliproxy/config/env-builder.ts index 0b9d08fb..5d47dae0 100644 --- a/src/cliproxy/config/env-builder.ts +++ b/src/cliproxy/config/env-builder.ts @@ -590,6 +590,26 @@ function migrateClaudeStaleModelPins(env: Record): boolean { return mutated; } +/** + * Read-level equivalent of the stale-pin migration for paths that must not + * mutate the settings file (e.g. the remote env builder). Returns a copy of + * the env with any value equal to a historical default dropped per-key, while + * user-custom pins are preserved. Same per-key Sets as the local migration, so + * local / --config / remote read paths agree on which pins are stale. + * + * No marker is consulted or written: this is purely a read-time filter, so it + * is idempotent across launches without a one-shot guard. + */ +function filterClaudeStaleModelPins(env: Record): Record { + const result = { ...env }; + for (const [key, staleValues] of Object.entries(CLAUDE_STALE_MODEL_DEFAULTS)) { + if (staleValues.has(result[key])) { + delete result[key]; + } + } + return result; +} + /** * Copy bundled settings template to user directory if not exists * Called during installation/first run @@ -783,6 +803,17 @@ export function getRemoteEnvVars( migrateDeprecatedModelNames(settingsPath, provider, settings); migrateIFlowPlaceholderModel(settingsPath, provider, settings); userEnvVars = settings.env as Record; + // claude is model-neutral. The local launch path runs the one-time + // stale-pin migration (ensureProviderSettings), but the remote path + // never rewrites the file, so a remote-only user whose settings still + // carry an old auto-written default would stay pinned. Filter stale + // defaults at read level so values equal to a historical default are + // dropped while user-custom pins survive. Priority 1 (explicit custom + // settings path) is intentionally left untouched: an explicitly passed + // settings file is the user's deliberate choice. + if (provider === 'claude') { + userEnvVars = filterClaudeStaleModelPins(userEnvVars); + } } } catch { // Invalid JSON - fall through to base config diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index 9a4ee77a..7fc457aa 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -230,6 +230,17 @@ export async function execClaudeWithCLIProxy( console.error(` Use "ccs cliproxy edit ${variantName}" to modify composite variants`); process.exit(1); } else { + // Run the one-time stale-pin migration on the pre-existing settings file + // BEFORE writing the user's chosen pin. ensureProviderSettingsFile sets the + // migration marker, so the explicit --config pick survives the next launch + // even when it equals a historical default. Without this, the --config flow + // exits before the only ensureProviderSettingsFile call site (later in this + // function), so a later plain launch would strip the just-written pin. + // Skipped for custom-settings variants: those have no claude.settings + // migration and are written verbatim. + if (!cfg.customSettingsPath) { + ensureProviderSettingsFile(provider); + } await configureProviderModel(provider, true, cfg.customSettingsPath); process.exit(0); } diff --git a/src/cliproxy/routing/__tests__/routing-strategy.test.ts b/src/cliproxy/routing/__tests__/routing-strategy.test.ts index 959e63bd..3005620f 100644 --- a/src/cliproxy/routing/__tests__/routing-strategy.test.ts +++ b/src/cliproxy/routing/__tests__/routing-strategy.test.ts @@ -285,4 +285,84 @@ describe('cliproxy routing strategy service', () => { expect(state.message).toContain('not reachable'); }); }); + + // PR #1514 fix index 2: for a remote target, readCliproxyRoutingState must NOT + // present the local pool flag as if it described the remote proxy. It surfaces + // manageable:false + a message, mirroring the session-affinity remote handling. + it('marks remote pool routing as not manageable (local flag does not describe the remote proxy)', async () => { + await withScopedConfig(async () => { + routingTarget = { + host: 'remote.example.com', + port: 8080, + protocol: 'http', + isRemote: true, + }; + responseFactory = async () => + new Response(JSON.stringify({ strategy: 'round-robin' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + + // Enable the LOCAL pool flag — the remote proxy must still report not-manageable. + const { mutateUnifiedConfig } = await import('../../../config/unified-config-loader'); + mutateUnifiedConfig((config) => { + if (config.cliproxy) { + config.cliproxy.pool_routing = { enabled: true, max_retry_credentials: 3 }; + } + }); + + const mod = await loadRoutingModule(); + const state = await mod.readCliproxyRoutingState(); + + expect(state.target).toBe('remote'); + expect(state.poolRouting?.manageable).toBe(false); + expect(state.poolRouting?.message).toContain('remote proxy'); + }); + }); + + // PR #1514 fix index 14 (backend): when local pool routing is enabled, the apply + // result message must carry the pool-override note so API/dashboard consumers see + // the same caveat the CLI prints. + it('appends a pool-active override note to local strategy apply when pool routing is on', async () => { + await withScopedConfig(async () => { + const { mutateUnifiedConfig } = await import('../../../config/unified-config-loader'); + mutateUnifiedConfig((config) => { + if (config.cliproxy) { + config.cliproxy.pool_routing = { enabled: true, max_retry_credentials: 3 }; + } + }); + + const mod = await loadRoutingModule(); + const result = await mod.applyCliproxyRoutingStrategy('round-robin'); + + expect(result.message).toContain('Pool routing is active'); + expect(result.message).toContain('ccs cliproxy pool --disable'); + }); + }); + + it('appends a pool-active override note to local affinity apply when pool routing is on', async () => { + await withScopedConfig(async () => { + const { mutateUnifiedConfig } = await import('../../../config/unified-config-loader'); + mutateUnifiedConfig((config) => { + if (config.cliproxy) { + config.cliproxy.pool_routing = { enabled: true, max_retry_credentials: 3 }; + } + }); + + const mod = await loadRoutingModule(); + const result = await mod.applyCliproxySessionAffinitySettings({ enabled: true, ttl: '1h' }); + + expect(result.message).toContain('Pool routing is active'); + expect(result.message).toContain('ccs cliproxy pool --disable'); + }); + }); + + // Without pool routing, the apply message must NOT carry the override note. + it('does not append the pool-override note when pool routing is off', async () => { + await withScopedConfig(async () => { + const mod = await loadRoutingModule(); + const result = await mod.applyCliproxyRoutingStrategy('fill-first'); + expect(result.message).not.toContain('Pool routing is active'); + }); + }); }); diff --git a/src/cliproxy/routing/pool-onboarding-hint.ts b/src/cliproxy/routing/pool-onboarding-hint.ts index 88d5c71e..338c290c 100644 --- a/src/cliproxy/routing/pool-onboarding-hint.ts +++ b/src/cliproxy/routing/pool-onboarding-hint.ts @@ -166,7 +166,7 @@ export function maybeShowPoolOnboardingHint(profileCount?: number): OnboardingHi console.log( info( - `You have ${count} Claude profiles. 'ccs claude' pool auto-continues on limits (accounts share quota). Docs: ${POOL_DOCS_LINK}` + `You have ${count} Claude profiles. Pool routing can auto-continue 'ccs claude' when one account hits its limit. Enable: ccs cliproxy pool --enable Docs: ${POOL_DOCS_LINK}` ) ); diff --git a/src/cliproxy/routing/pool-opt-in-prompt.ts b/src/cliproxy/routing/pool-opt-in-prompt.ts index e4181a3a..c5783860 100644 --- a/src/cliproxy/routing/pool-opt-in-prompt.ts +++ b/src/cliproxy/routing/pool-opt-in-prompt.ts @@ -20,7 +20,11 @@ import { POOL_ROUTING_VERIFIED_PROVIDERS, POOL_MAX_RETRY_CREDENTIALS, } from './routing-strategy'; -import { loadOrCreateUnifiedConfig, mutateConfig } from '../../config/config-loader-facade'; +import { + loadOrCreateUnifiedConfig, + mutateConfig, + hasUnifiedConfig, +} from '../../config/config-loader-facade'; import { CLIPROXY_DEFAULT_PORT } from '../config/port-manager'; import { getConfigPathForPort } from '../config/path-resolver'; import { getAuthDir } from '../config/path-resolver'; @@ -63,6 +67,10 @@ export function isPoolPromptDismissed(): boolean { * Mark the pool routing prompt as permanently dismissed (user said no explicitly). */ export function dismissPoolPrompt(): void { + // Defense in depth: never write the unified config on a legacy install — that + // would create config.yaml and silently flip isUnifiedMode(). maybeOfferPoolRouting + // already gates the only caller, but this keeps the exported helper safe too. + if (!hasUnifiedConfig()) return; mutateConfig((cfg) => { if (!cfg.cliproxy) return; cfg.cliproxy.pool_routing = { @@ -134,6 +142,23 @@ export async function maybeOfferPoolRouting( return { prompted: false, enabled: false, skipped: true, skipReason: 'not-at-transition' }; } + // Legacy install guard: on a profiles.json/config.json-only install there is no + // unified config.yaml. Both accept (enablePoolRouting) and decline + // (dismissPoolPrompt) write the unified config, which would create config.yaml + // and silently flip isUnifiedMode() outside the deliberate `ccs migrate` flow, + // orphaning the user's config.json profiles. Skip entirely before any prompt + // or dismissal persistence. Mirrors the guards in create-command.ts and + // account-flow.ts and the hazard documented in pool-onboarding-hint.ts. + if (!hasUnifiedConfig()) { + console.log( + info( + '[i] Multiple accounts detected, but pool routing needs the unified config. ' + + "Run 'ccs migrate' first, then 'ccs cliproxy pool --enable'." + ) + ); + return { prompted: false, enabled: false, skipped: true, skipReason: 'legacy-config' }; + } + // Only for providers where pool routing is verified if (!POOL_ROUTING_VERIFIED_PROVIDERS.has(provider)) { return { @@ -178,6 +203,19 @@ export async function maybeOfferPoolRouting( return { prompted: false, enabled: false, skipped: true, skipReason: 'non-tty' }; } + // Automation bypass: InteractivePrompt.confirm auto-returns true when --yes/-y + // is in argv or CCS_YES=1 is set, regardless of the default:false. This is an + // INSTANCE-GLOBAL routing/cooling consent decision; a generic automation flag + // must never grant it. Skip WITHOUT printing the prompt and WITHOUT persisting + // dismissal, so a future interactive run still offers the opt-in. + if ( + process.env.CCS_YES === '1' || + process.argv.includes('--yes') || + process.argv.includes('-y') + ) { + return { prompted: false, enabled: false, skipped: true, skipReason: 'automation-bypass' }; + } + // Gather all providers with 2+ accounts for disclosure const multiAccountProviders = getMultiAccountProviders(); const providerList = @@ -226,6 +264,14 @@ export async function maybeOfferPoolRouting( const result = enablePoolRouting(port, { configPath, authDir }); console.log(''); + if (result.failed) { + // Config regeneration failed and the flag was rolled back: pool routing is + // NOT active. Surface the recovery copy and report enabled:false so callers + // (and quota/dashboard) do not claim pool ON. + console.log(warn(result.message)); + console.log(''); + return { prompted: true, enabled: false, skipped: false }; + } if (result.changed) { console.log(info(result.message)); } diff --git a/src/cliproxy/routing/routing-strategy.ts b/src/cliproxy/routing/routing-strategy.ts index b649c773..e5defa79 100644 --- a/src/cliproxy/routing/routing-strategy.ts +++ b/src/cliproxy/routing/routing-strategy.ts @@ -46,16 +46,43 @@ export const POOL_ROUTING_VERIFIED_PROVIDERS = new Set(['claude', 'agy']); */ export const POOL_ROUTING_MIN_VERSION = '6.9.45'; +/** + * Pool-active override warning text. When pool routing is enabled the generator + * forces fill-first/affinity/cooling and ignores the stored strategy/affinity, so + * an apply via API or dashboard will not take effect. The CLI prints this same + * text before applying; appending it to the apply result message lets dashboard + * and API consumers surface the same caveat (the CLI warns, the dashboard did not). + */ +function poolActiveOverrideNote(kind: 'strategy' | 'affinity'): string { + const what = kind === 'strategy' ? 'stored strategy' : 'stored affinity setting'; + return ( + `[!] Pool routing is active. The ${what} will not take effect\n` + + ` until pool routing is disabled: ccs cliproxy pool --disable` + ); +} + +/** Whether pool routing is enabled in the local unified config. */ +function isLocalPoolRoutingEnabled(): boolean { + return loadOrCreateUnifiedConfig().cliproxy?.pool_routing?.enabled === true; +} + export interface EnablePoolRoutingResult { /** Whether the pool routing state actually changed */ changed: boolean; /** Whether an existing explicit user routing setting was preserved */ preservedExplicitSetting: boolean; + /** + * True when the config could not be regenerated and the pool flag was rolled + * back. Pool routing is NOT active; the message carries recovery guidance. + */ + failed?: boolean; message: string; } export interface DisablePoolRoutingResult { changed: boolean; + /** True when the config could not be regenerated and the flag was rolled back. */ + failed?: boolean; message: string; } @@ -145,7 +172,24 @@ export function enablePoolRouting( ): EnablePoolRoutingResult { const config = loadOrCreateUnifiedConfig(); const already = config.cliproxy?.pool_routing?.enabled === true; + const configPath = options.configPath ?? getConfigPathForPort(port); + const authDir = options.authDir ?? getAuthDir(); + + // Already-enabled repair path: the flag may have been persisted by a prior call + // whose regenerateConfig threw (leaving config.yaml non-pool while the flag says + // enabled). Re-run regenerateConfig so `pool --enable` is an idempotent repair + // command rather than a no-op that can never fix a half-applied state. if (already) { + try { + regenerateConfig(port, { configPath, authDir }); + } catch (err) { + return { + changed: false, + preservedExplicitSetting: false, + failed: true, + message: `[X] Could not write CLIProxy config: ${(err as Error).message}.\n Pool routing is flagged enabled but the config was not regenerated.\n Fix the file permission and re-run: ccs cliproxy pool --enable`, + }; + } return { changed: false, preservedExplicitSetting: false, @@ -185,9 +229,26 @@ export function enablePoolRouting( }; }); - const configPath = options.configPath ?? getConfigPathForPort(port); - const authDir = options.authDir ?? getAuthDir(); - regenerateConfig(port, { configPath, authDir }); + // The flag is persisted before regenerateConfig. If regeneration throws, roll + // the flag back so status surfaces (pool --enable, quota, dashboard) do not + // report pool ON while config.yaml still runs non-pool routing. + try { + regenerateConfig(port, { configPath, authDir }); + } catch (err) { + mutateConfig((cfg) => { + if (!cfg.cliproxy) return; + cfg.cliproxy.pool_routing = { + ...cfg.cliproxy.pool_routing, + enabled: false, + }; + }); + return { + changed: false, + preservedExplicitSetting, + failed: true, + message: `[X] Could not write CLIProxy config: ${(err as Error).message}.\n Pool routing was not enabled; fix the file permission and re-run: ccs cliproxy pool --enable`, + }; + } return { changed: true, @@ -218,6 +279,8 @@ export function disablePoolRouting( ): DisablePoolRoutingResult { const config = loadOrCreateUnifiedConfig(); const wasEnabled = config.cliproxy?.pool_routing?.enabled === true; + const configPath = options.configPath ?? getConfigPathForPort(port); + const authDir = options.authDir ?? getAuthDir(); if (!wasEnabled) { return { changed: false, @@ -236,9 +299,19 @@ export function disablePoolRouting( }; }); - const configPath = options.configPath ?? getConfigPathForPort(port); - const authDir = options.authDir ?? getAuthDir(); - regenerateConfig(port, { configPath, authDir }); + // The flag is already cleared (matching user intent). If regeneration throws we + // keep enabled:false rather than rolling back to true — re-asserting pool ON + // would reintroduce the single-account blackout the disable path exists to + // prevent. Surface the failure so the user can fix permissions and re-run. + try { + regenerateConfig(port, { configPath, authDir }); + } catch (err) { + return { + changed: true, + failed: true, + message: `[X] Could not write CLIProxy config: ${(err as Error).message}.\n Pool routing is flagged disabled but the config was not regenerated.\n Fix the file permission and re-run: ccs cliproxy pool --disable`, + }; + } return { changed: true, @@ -262,6 +335,16 @@ const GO_DURATION_PATTERN = new RegExp(`^${GO_DURATION_SEGMENT}+$`); export interface CliproxyPoolRoutingState { enabled: boolean; maxRetryCredentials?: number; + /** + * Whether CCS can manage pool routing for this target. enablePoolRouting only + * writes LOCAL config files, so a remote proxy is never affected by the local + * pool flag. For remote targets this is false and `enabled` reflects the local + * config only (not the remote proxy's behaviour). Omitted (treated as true) + * for local targets. Mirrors CliproxySessionAffinityState.manageable. + */ + manageable?: boolean; + /** Explanation surfaced when manageable is false (remote target). */ + message?: string; } export interface CliproxyRoutingState { @@ -270,7 +353,11 @@ export interface CliproxyRoutingState { target: 'local' | 'remote'; reachable: boolean; message?: string; - /** Pool routing mode (proxy-wide). Present for both local and remote targets. */ + /** + * Pool routing mode. For local targets `enabled` reflects the active proxy + * config. For remote targets `manageable` is false and `enabled` reflects only + * the local config (the remote proxy is not affected by it). + */ poolRouting?: CliproxyPoolRoutingState; } @@ -417,12 +504,24 @@ export async function readCliproxyRoutingState(): Promise const poolRouting = getCliproxyPoolRoutingState(); if (target.isRemote) { + // Do NOT attach the local pool flag as if it described the remote proxy. + // enablePoolRouting only writes local config files; the remote proxy keeps + // its own routing/cooling. Surface manageable:false + a message so the + // dashboard renders "local only / not applied" instead of claiming the + // remote proxy is pool-managed. Mirrors readCliproxySessionAffinityState. return { strategy: await fetchLiveCliproxyRoutingStrategy(), source: 'live', target: 'remote', reachable: true, - poolRouting, + poolRouting: { + enabled: poolRouting.enabled, + maxRetryCredentials: poolRouting.maxRetryCredentials, + manageable: false, + message: + 'Pool routing is managed from the local config only and does not affect this remote proxy. ' + + 'Configure cooling and routing on the host running CLIProxy instead.', + }, }; } @@ -497,6 +596,11 @@ export async function applyCliproxyRoutingStrategy( }; } + // Pool routing overrides the stored strategy at config-generation time, so the + // apply will not take effect until pool routing is disabled. Append the same + // note the CLI prints so dashboard/API consumers see the override too. + const poolNote = isLocalPoolRoutingEnabled() ? `\n\n${poolActiveOverrideNote('strategy')}` : ''; + mutateConfig((config) => { if (config.cliproxy) { config.cliproxy.routing = { ...config.cliproxy.routing, strategy }; @@ -512,7 +616,7 @@ export async function applyCliproxyRoutingStrategy( target: 'local', reachable: true, applied: 'live-and-config', - message: 'Updated the running proxy and saved the local startup default.', + message: 'Updated the running proxy and saved the local startup default.' + poolNote, }; } catch { return { @@ -521,7 +625,8 @@ export async function applyCliproxyRoutingStrategy( target: 'local', reachable: false, applied: 'config-only', - message: 'Saved the local startup default. It will apply the next time CLIProxy starts.', + message: + 'Saved the local startup default. It will apply the next time CLIProxy starts.' + poolNote, }; } } @@ -552,6 +657,10 @@ export async function applyCliproxySessionAffinitySettings( current.ttl ?? DEFAULT_CLIPROXY_SESSION_AFFINITY_TTL; + // Pool routing overrides the stored session-affinity at config-generation time; + // append the same override note the CLI prints so dashboard/API consumers see it. + const poolNote = isLocalPoolRoutingEnabled() ? `\n\n${poolActiveOverrideNote('affinity')}` : ''; + mutateConfig((config) => { if (config.cliproxy) { config.cliproxy.routing = { @@ -572,9 +681,11 @@ export async function applyCliproxySessionAffinitySettings( reachable, manageable: true, applied: 'config-only', - message: reachable - ? 'Saved the local startup default. Running local CLIProxy may hot-reload the session-affinity setting, but CCS does not verify live selector state yet.' - : 'Saved the local startup default. It will apply the next time local CLIProxy starts.', + message: + (reachable + ? 'Saved the local startup default. Running local CLIProxy may hot-reload the session-affinity setting, but CCS does not verify live selector state yet.' + : 'Saved the local startup default. It will apply the next time local CLIProxy starts.') + + poolNote, }; } diff --git a/src/commands/cliproxy/__tests__/order-subcommand.test.ts b/src/commands/cliproxy/__tests__/order-subcommand.test.ts new file mode 100644 index 00000000..e85a00b9 --- /dev/null +++ b/src/commands/cliproxy/__tests__/order-subcommand.test.ts @@ -0,0 +1,159 @@ +/** + * Tests for handleOrderSubcommand presentation + reset behavior. + * + * Why these matter: + * - File-mode SHOW must render from the shared resolver (selector pick order + + * drift), not an alphabetical re-sort. Otherwise the displayed order is the + * inverse of what CLIProxy actually drains whenever residual on-disk + * priorities exist (e.g. left by a prior managed order), and the drift + * warning the manual/tier branch shows is silently dropped. + * - `--reset` must actually strip residual priorities from the auth files, not + * just delete the stored config. With the proxy STOPPED the field is removed + * by a direct atomic write; the running-proxy PATCH path is covered at the + * clearDrainOrderPriorities unit level (drain-order.test.ts). + */ +import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { handleOrderSubcommand } from '../order-subcommand'; + +describe('handleOrderSubcommand', () => { + let tempHome: string; + let originalCcsHome: string | undefined; + let logSpy: ReturnType; + let lines: string[]; + + function authDir(): string { + return path.join(tempHome, '.ccs', 'cliproxy', 'auth'); + } + + function writeAuthFile(fileName: string, fields: Record = {}): void { + fs.mkdirSync(authDir(), { recursive: true, mode: 0o700 }); + fs.writeFileSync( + path.join(authDir(), fileName), + JSON.stringify({ type: 'claude', ...fields }, null, 2), + { mode: 0o600 } + ); + } + + function readAuthFile(fileName: string): Record { + return JSON.parse(fs.readFileSync(path.join(authDir(), fileName), 'utf-8')) as Record< + string, + unknown + >; + } + + async function registerClaude(): Promise<{ + registerAccount: (provider: string, tokenFile: string, email: string) => unknown; + saveDrainOrderConfig: (provider: string, config: unknown) => boolean; + }> { + return import(`../../../cliproxy/accounts/registry?order-subcommand=${Date.now()}`); + } + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-order-subcommand-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + process.exitCode = 0; + lines = []; + logSpy = spyOn(console, 'log').mockImplementation((msg?: unknown) => { + if (typeof msg === 'string') lines.push(msg); + }); + }); + + afterEach(() => { + logSpy.mockRestore(); + process.exitCode = 0; + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + fs.rmSync(tempHome, { recursive: true, force: true }); + }); + + describe('file-mode show with residual priorities', () => { + it('renders selector pick order (priority desc) and flags drift instead of alphabetical order', async () => { + // Residual on-disk priorities, no stored config -> file mode + drift. + // claude-a sorts first alphabetically, but b has the higher priority, so + // the selector drains b first. The display must follow the selector. + writeAuthFile('claude-a.json', { email: 'a@x.com', priority: 1 }); + writeAuthFile('claude-b.json', { email: 'b@x.com', priority: 5 }); + + const { registerAccount } = await registerClaude(); + registerAccount('claude', 'claude-a.json', 'a@x.com'); + registerAccount('claude', 'claude-b.json', 'b@x.com'); + + await handleOrderSubcommand(['claude']); + + const output = lines.join('\n'); + // b@x.com (priority 5) must appear before a@x.com (priority 1). + const idxB = output.indexOf('b@x.com'); + const idxA = output.indexOf('a@x.com'); + expect(idxB).toBeGreaterThanOrEqual(0); + expect(idxA).toBeGreaterThan(idxB); + + // Drift surfaced (same as the manual/tier branch), and the mode label no + // longer falsely claims "no priority set" under residual priorities. + expect(output).toContain('Drift detected'); + expect(output).toContain('residual priorities present'); + expect(output).not.toContain('no priority set'); + // [priority: N] annotations preserved. + expect(output).toContain('[priority: 5]'); + expect(output).toContain('[priority: 1]'); + }); + + it('keeps the plain "no priority set" label and no drift when there are no residuals', async () => { + writeAuthFile('claude-a.json', { email: 'a@x.com' }); + writeAuthFile('claude-b.json', { email: 'b@x.com' }); + + const { registerAccount } = await registerClaude(); + registerAccount('claude', 'claude-a.json', 'a@x.com'); + registerAccount('claude', 'claude-b.json', 'b@x.com'); + + await handleOrderSubcommand(['claude']); + + const output = lines.join('\n'); + expect(output).toContain('no priority set'); + expect(output).not.toContain('Drift detected'); + }); + }); + + describe('--reset clears residual priorities (proxy stopped -> direct write)', () => { + it('removes the priority field from auth files and reports per-file results', async () => { + writeAuthFile('claude-a.json', { email: 'a@x.com', priority: 4 }); + writeAuthFile('claude-b.json', { email: 'b@x.com' }); // already clear + + const { registerAccount, saveDrainOrderConfig } = await registerClaude(); + registerAccount('claude', 'claude-a.json', 'a@x.com'); + registerAccount('claude', 'claude-b.json', 'b@x.com'); + saveDrainOrderConfig('claude', { mode: 'manual', orderedIds: ['a@x.com', 'b@x.com'] }); + + await handleOrderSubcommand(['claude', '--reset']); + + // Residual priority is actually gone from disk (not just config deleted). + expect('priority' in readAuthFile('claude-a.json')).toBe(false); + + const output = lines.join('\n'); + expect(output).toContain('reset to file order'); + // Honest per-file reporting, and it no longer claims residuals remain. + expect(output).toContain('Cleared residual priority from 1 auth file'); + expect(output).not.toContain('CLIProxy will continue using them'); + }); + + it('reports already-clear files and still resets when no priorities exist', async () => { + writeAuthFile('claude-a.json', { email: 'a@x.com' }); + + const { registerAccount } = await registerClaude(); + registerAccount('claude', 'claude-a.json', 'a@x.com'); + + await handleOrderSubcommand(['claude', '--reset']); + + const output = lines.join('\n'); + expect(output).toContain('reset to file order'); + expect(output).toContain('no priority set'); + }); + }); +}); diff --git a/src/commands/cliproxy/__tests__/pool-state-renderer.test.ts b/src/commands/cliproxy/__tests__/pool-state-renderer.test.ts index b8c13641..00b43aa0 100644 --- a/src/commands/cliproxy/__tests__/pool-state-renderer.test.ts +++ b/src/commands/cliproxy/__tests__/pool-state-renderer.test.ts @@ -3,7 +3,10 @@ * account states must be NAMED differently because they map to different client * failure modes), reset formatting, and drain-order mode labels. */ -import { describe, expect, it } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; import { describeAccountState, formatCooldownReset, modeLabel } from '../pool-state-renderer'; import type { PoolAccountState } from '../../../cliproxy/accounts/pool-state'; @@ -30,7 +33,10 @@ describe('describeAccountState', () => { expect(available.label).toBe('available'); expect(available.tone).toBe('available'); - expect(paused.label).toContain('paused'); + // The pause label must NOT claim "manual" - a pause can be automatic (ban + // detection, cross-provider isolation, expired-but-unrestored cooldown). + expect(paused.label).toBe('paused (manual or safety)'); + expect(paused.label).not.toBe('paused (manual)'); expect(paused.tone).toBe('paused'); expect(cooling.label).toContain('cooling until'); expect(cooling.tone).toBe('cooling'); @@ -78,3 +84,141 @@ describe('modeLabel', () => { expect(modeLabel('file')).toBe('file order'); }); }); + +// --------------------------------------------------------------------------- +// renderProviderPoolSection - integration (resume hint, file-mode drift copy, +// pool-on cooling honesty note). Uses an isolated CCS_HOME and captures console. +// --------------------------------------------------------------------------- + +describe('renderProviderPoolSection', () => { + let tempHome: string; + let originalCcsHome: string | undefined; + let logSpy: ReturnType; + let lines: string[]; + + function writeAuthFile(fileName: string, fields: Record = {}): void { + const authDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth'); + fs.mkdirSync(authDir, { recursive: true, mode: 0o700 }); + fs.writeFileSync( + path.join(authDir, fileName), + JSON.stringify({ type: 'claude', ...fields }, null, 2), + { mode: 0o600 } + ); + } + + const SETTINGS_OFF = { + poolEnabled: false, + strategy: 'round-robin', + sessionAffinityEnabled: false, + sessionAffinityTtl: '1h', + } as const; + + const SETTINGS_ON = { + poolEnabled: true, + strategy: 'fill-first', + sessionAffinityEnabled: true, + sessionAffinityTtl: '1h', + } as const; + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-pool-renderer-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + process.exitCode = 0; + lines = []; + logSpy = spyOn(console, 'log').mockImplementation((msg?: unknown) => { + if (typeof msg === 'string') lines.push(msg); + }); + }); + + afterEach(() => { + logSpy.mockRestore(); + process.exitCode = 0; + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + fs.rmSync(tempHome, { recursive: true, force: true }); + }); + + it('emits a resume hint pointing at the real command when an account is paused', async () => { + writeAuthFile('claude-a.json', { email: 'a@x.com' }); + writeAuthFile('claude-b.json', { email: 'b@x.com' }); + + const { registerAccount, pauseAccount } = await import( + `../../../cliproxy/accounts/registry?renderer-resume=${Date.now()}` + ); + registerAccount('claude', 'claude-a.json', 'a@x.com'); + registerAccount('claude', 'claude-b.json', 'b@x.com'); + pauseAccount('claude', 'a@x.com'); + + const { renderProviderPoolSection } = await import( + `../pool-state-renderer?renderer-resume=${Date.now()}` + ); + await renderProviderPoolSection('claude', SETTINGS_OFF, 1_000_000); + + const output = lines.join('\n'); + // The hint names the actual resume subcommand (ccs cliproxy resume ). + expect(output).toContain('Resume:'); + expect(output).toContain('ccs cliproxy resume '); + }); + + it('does NOT emit a resume hint when no account is paused', async () => { + writeAuthFile('claude-a.json', { email: 'a@x.com' }); + const { registerAccount } = await import( + `../../../cliproxy/accounts/registry?renderer-no-resume=${Date.now()}` + ); + registerAccount('claude', 'claude-a.json', 'a@x.com'); + + const { renderProviderPoolSection } = await import( + `../pool-state-renderer?renderer-no-resume=${Date.now()}` + ); + await renderProviderPoolSection('claude', SETTINGS_OFF, 1_000_000); + + expect(lines.join('\n')).not.toContain('Resume:'); + }); + + it('uses honest file-mode drift copy (no "stored order") when residual priorities exist', async () => { + // Residual on-disk priorities, but no stored drain config -> file mode drift. + writeAuthFile('claude-a.json', { email: 'a@x.com', priority: 1 }); + writeAuthFile('claude-b.json', { email: 'b@x.com', priority: 5 }); + + const { registerAccount } = await import( + `../../../cliproxy/accounts/registry?renderer-drift=${Date.now()}` + ); + registerAccount('claude', 'claude-a.json', 'a@x.com'); + registerAccount('claude', 'claude-b.json', 'b@x.com'); + + const { renderProviderPoolSection } = await import( + `../pool-state-renderer?renderer-drift=${Date.now()}` + ); + await renderProviderPoolSection('claude', SETTINGS_OFF, 1_000_000); + + const output = lines.join('\n'); + expect(output).toContain('Drift:'); + // File mode has no stored order, so the copy must not claim one nor tell the + // user to "re-apply with --set/--by-tier" (which re-adopts managed ordering). + expect(output).toContain('residual priorities'); + expect(output).toContain('--reset'); + expect(output).not.toContain('stored order does not match'); + expect(output).not.toContain('re-apply with --set/--by-tier'); + }); + + it('prints the in-proxy-cooldown honesty note when pool is ON but no proxy data is available', async () => { + writeAuthFile('claude-a.json', { email: 'a@x.com' }); + const { registerAccount } = await import( + `../../../cliproxy/accounts/registry?renderer-note=${Date.now()}` + ); + registerAccount('claude', 'claude-a.json', 'a@x.com'); + + const { renderProviderPoolSection } = await import( + `../pool-state-renderer?renderer-note=${Date.now()}` + ); + // No proxy is running in the test home, so the fetch degrades to no data and + // the honesty note must be printed instead of implying every account is fine. + await renderProviderPoolSection('claude', SETTINGS_ON, 1_000_000); + + expect(lines.join('\n')).toContain('live in-proxy 429 cooldowns are not shown here'); + }); +}); diff --git a/src/commands/cliproxy/__tests__/pool-subcommand.test.ts b/src/commands/cliproxy/__tests__/pool-subcommand.test.ts new file mode 100644 index 00000000..e8e39f78 --- /dev/null +++ b/src/commands/cliproxy/__tests__/pool-subcommand.test.ts @@ -0,0 +1,135 @@ +/** + * Tests for handlePoolSubcommand: remote-target refusal and local lifecycle-port + * resolution. + * + * Why these matter: + * - Remote refusal: enable/disable only mutate LOCAL config files. On a remote + * target the running proxy is never touched, so flipping the local flag and + * printing a hot-reload success message would lie. The command must refuse + * (exitCode 1) and leave the local pool flag untouched. + * - Port resolution: a user on a custom local.port (e.g. 9000) must have + * config-9000.yaml regenerated (the file the running proxy reads), not the + * default config.yaml. + */ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'; + +import { handlePoolSubcommand } from '../pool-subcommand'; +import { + invalidateConfigCache as invalidateSharedConfigCache, + mutateConfig, +} from '../../../config/config-loader-facade'; + +function createTestHome(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-pool-subcommand-test-')); + const ccsDir = path.join(dir, '.ccs'); + fs.mkdirSync(path.join(ccsDir, 'cliproxy', 'auth'), { recursive: true }); + fs.writeFileSync(path.join(ccsDir, 'config.yaml'), 'version: 1\n', 'utf8'); + return dir; +} + +describe('handlePoolSubcommand', () => { + let tempHome: string; + let originalCcsHome: string | undefined; + let logSpy: ReturnType; + let lines: string[]; + + beforeEach(() => { + tempHome = createTestHome(); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + invalidateSharedConfigCache(); + process.exitCode = 0; + lines = []; + logSpy = spyOn(console, 'log').mockImplementation((msg?: unknown) => { + if (typeof msg === 'string') lines.push(msg); + }); + }); + + afterEach(() => { + logSpy.mockRestore(); + process.exitCode = 0; + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + fs.rmSync(tempHome, { recursive: true, force: true }); + }); + + it('refuses --enable on a remote target, sets exitCode 1, and does not flip the local flag', async () => { + mutateConfig((cfg) => { + cfg.cliproxy_server = { + remote: { enabled: true, host: '192.168.1.50', protocol: 'http' }, + }; + }); + invalidateSharedConfigCache(); + + await handlePoolSubcommand(['--enable']); + + const output = lines.join('\n'); + expect(output).toContain('Remote proxy target detected'); + // Manual-config guidance is shown for the enable path + expect(output).toContain('disable-cooling: false'); + expect(output).toContain('strategy: fill-first'); + expect(process.exitCode).toBe(1); + + // Local pool flag must remain untouched (not enabled). + invalidateSharedConfigCache(); + const { loadOrCreateUnifiedConfig } = await import( + `../../../config/config-loader-facade?poolremote=${Date.now()}` + ); + const cfg = loadOrCreateUnifiedConfig(); + expect(cfg.cliproxy?.pool_routing?.enabled).not.toBe(true); + + // No local config.yaml should have been regenerated by the refused command. + const cliproxyConfig = path.join(tempHome, '.ccs', 'cliproxy', 'config.yaml'); + expect(fs.existsSync(cliproxyConfig)).toBe(false); + }); + + it('refuses --disable on a remote target without the enable-only manual snippet', async () => { + mutateConfig((cfg) => { + cfg.cliproxy_server = { + remote: { enabled: true, host: '192.168.1.50', protocol: 'http' }, + }; + }); + invalidateSharedConfigCache(); + + await handlePoolSubcommand(['--disable']); + + const output = lines.join('\n'); + expect(output).toContain('Remote proxy target detected'); + // The disable refusal must NOT print the enable manual snippet + expect(output).not.toContain('disable-cooling: false'); + expect(process.exitCode).toBe(1); + }); + + it('--enable on a local custom port regenerates config-.yaml, not config.yaml', async () => { + // Configure a custom local lifecycle port. resolveLifecyclePort() must pick + // it up so the regenerated file matches what the running proxy reads. + mutateConfig((cfg) => { + cfg.cliproxy_server = { local: { port: 9000 } }; + }); + invalidateSharedConfigCache(); + + await handlePoolSubcommand(['--enable']); + + const cliproxyDir = path.join(tempHome, '.ccs', 'cliproxy'); + const customConfig = path.join(cliproxyDir, 'config-9000.yaml'); + const defaultConfig = path.join(cliproxyDir, 'config.yaml'); + + // The custom-port config file must be the one regenerated. + expect(fs.existsSync(customConfig)).toBe(true); + // The default-port config.yaml must NOT be written by a custom-port enable. + expect(fs.existsSync(defaultConfig)).toBe(false); + + // And the regenerated file must carry the pool-on rails. + const content = fs.readFileSync(customConfig, 'utf-8'); + expect(content).toContain('disable-cooling: false'); + expect(content).toContain('strategy: fill-first'); + // Success path must not flag a failure exit code. + expect(process.exitCode).not.toBe(1); + }); +}); diff --git a/src/commands/cliproxy/order-subcommand.ts b/src/commands/cliproxy/order-subcommand.ts index 7ea1de5d..b2b50061 100644 --- a/src/commands/cliproxy/order-subcommand.ts +++ b/src/commands/cliproxy/order-subcommand.ts @@ -17,7 +17,7 @@ import { computeTierDrainOrder, applyDrainOrder, resolveEffectiveDrainOrder, - readAuthFilePriority, + clearDrainOrderPriorities, tieBreakKey, type DrainOrderEntry, type DrainOrderInput, @@ -122,9 +122,11 @@ async function handleOrderShow(provider: CLIProxyProvider): Promise { const effective = resolveEffectiveDrainOrder(provider, accounts); if (effective.mode === 'file') { - // File order: no priority writes; show stable file order. Reached when no - // config is stored, or when a stored manual order has only stale IDs. - printFileOrderView(provider, accounts); + // File order: no priority writes. Render from the resolver's output so the + // displayed order matches what the selector actually drains, honouring any + // residual on-disk priorities (e.g. left by `order --reset`). Reached when + // no config is stored, or when a stored manual order has only stale IDs. + printFileOrderView(provider, effective.entries, effective.hasDrift); return; } @@ -160,20 +162,25 @@ async function handleOrderShow(provider: CLIProxyProvider): Promise { console.log(''); } -function readFilePriorityNote(tokenFile: string): string { - try { - const p = readAuthFilePriority(tokenFile); - return p !== undefined ? dim(` [priority: ${p}]`) : ''; - } catch { - return ''; - } +function filePriorityNote(entry: DrainOrderEntry): string { + return entry.currentPriority !== undefined ? dim(` [priority: ${entry.currentPriority}]`) : ''; } /** - * Render the stable file-order view (no priority writes). Used when no drain - * order config is stored, or when a stored manual order has only stale IDs. + * Render the file-order view from the shared resolver's output. No priority + * writes; used when no drain order config is stored, or when a stored manual + * order has only stale IDs. + * + * Entries arrive already sorted in selector pick order (on-disk priority desc, + * tokenFile asc on tie), so the displayed order matches what CLIProxy actually + * drains even when residual priorities exist (e.g. left by `order --reset`). The + * mode label and drift warning are kept honest for that residual case. */ -function printFileOrderView(provider: CLIProxyProvider, accounts: DrainOrderInput[]): void { +function printFileOrderView( + provider: CLIProxyProvider, + entries: DrainOrderEntry[], + hasDrift: boolean +): void { if (!TIER_AWARE_PROVIDERS.has(provider)) { console.log( info( @@ -182,17 +189,27 @@ function printFileOrderView(provider: CLIProxyProvider, accounts: DrainOrderInpu ) ); } - console.log(` Mode: ${dim('file order (no priority set)')}`); + // Under drift, "no priority set" would be a lie - residual on-disk priorities + // are present and are what the selector follows. Label accordingly. + const label = hasDrift + ? 'file order (residual priorities present)' + : 'file order (no priority set)'; + console.log(` Mode: ${dim(label)}`); + if (hasDrift) { + console.log(''); + console.log( + warn( + `Drift detected: residual on-disk priorities steer the selector;\n` + + ` clear them with --reset (or re-auth the accounts) to return to plain file order.` + ) + ); + } console.log(''); - console.log(subheader('Active accounts (file order):')); - // Sort by tokenFile ascending to match the Go selector's stable order. - // Upstream sorts by Auth.ID byte order and only lowercases on Windows (see tieBreakKey). - const sortedByFile = accounts - .slice() - .sort((a, b) => (tieBreakKey(a.tokenFile) < tieBreakKey(b.tokenFile) ? -1 : 1)); - sortedByFile.forEach((a, idx) => { - const pri = readFilePriorityNote(a.tokenFile); - console.log(` ${String(idx + 1).padStart(3)} ${color(a.accountId, 'command')}${pri}`); + console.log(subheader('Active accounts (selector pick order):')); + // Entries are already in selector pick order from the resolver; render as-is. + entries.forEach((entry, idx) => { + const pri = filePriorityNote(entry); + console.log(` ${String(idx + 1).padStart(3)} ${color(entry.accountId, 'command')}${pri}`); }); console.log(''); console.log( @@ -463,21 +480,114 @@ async function handleOrderSet(provider: CLIProxyProvider, setArg: string): Promi } /** - * Reset drain order to file order (removes persisted config, no priority writes). + * Reset drain order to file order: remove the persisted config AND clear the + * residual priority field from the provider's auth files so the selector + * actually returns to plain file order. + * + * Clearing the field uses the same dual write path as --set/--by-tier: + * - Proxy running: PATCH priority:0 via the management API (the management layer + * treats 0 as delete). A direct file write here would be clobbered by the + * proxy's whole-file MarkResult persist, so the API path is mandatory. + * - Proxy stopped: delete the field directly with an atomic temp-rename. + * + * Remote targets are refused with guidance (same as --set/--by-tier), and an + * ambiguous proxy state (alive but not yet serving HTTP) is refused to avoid a + * clobbered write. */ async function handleOrderReset(provider: CLIProxyProvider): Promise { - const cleared = clearDrainOrderConfig(provider); - if (cleared) { - console.log(ok(`Drain order reset to file order for ${provider}.`)); + const configCleared = clearDrainOrderConfig(provider); + + // Collect the auth files that may carry residual priorities. Use + // getProviderAccounts() so file-copied fleets (not yet in accounts.json) are + // covered too; clearing is idempotent for files with no priority field. + const tokenFiles = getProviderAccounts(provider).map((a) => a.tokenFile); + + if (tokenFiles.length === 0) { + if (configCleared) { + console.log(ok(`Drain order reset to file order for ${provider}.`)); + } else { + console.log(info(`No drain order config found for ${provider}. Already in file order.`)); + } + console.log(''); + return; + } + + // Remote targets: refuse with guidance - clearing priorities needs management + // API access that is not wired for remote proxies in v1. + const proxyTarget = getProxyTarget(); + if (proxyTarget.isRemote) { + if (configCleared) { + console.log(ok(`Drain order config cleared for ${provider} (mode reset to file order).`)); + } else { + console.log(info(`No drain order config found for ${provider}.`)); + } console.log( - dim( - ' Note: existing priority values in auth files are not removed.\n' + - ' CLIProxy will continue using them until they are overwritten or files are re-created.' + warn( + `Remote proxy target detected. Residual auth-file priorities were NOT cleared;\n` + + ` drain order management for remote proxies is not supported in v1. Run this\n` + + ` command on the host running CLIProxy to clear them.` ) ); + console.log(''); + process.exitCode = 1; + return; + } + + const localPort = resolveLifecyclePort(); + const proxyStatus = await detectRunningProxy(localPort); + + // Ambiguous state: proxy alive but not yet serving HTTP. A direct write could + // be clobbered; refuse rather than risk a half-cleared state. + if (proxyStatus.running && !proxyStatus.verified) { + if (configCleared) { + console.log(ok(`Drain order config cleared for ${provider} (mode reset to file order).`)); + } + console.log( + fail( + 'Proxy state ambiguous - residual priorities not cleared. Retry in a moment or stop the proxy first.' + ) + ); + console.log(''); + process.exitCode = 1; + return; + } + + const proxyRunning = proxyStatus.running && proxyStatus.verified; + if (proxyRunning) { + console.log( + info('Proxy is running - clearing priorities via management API (avoids clobber race).') + ); + } else { + console.log(info('Proxy is stopped - clearing priorities directly in auth files.')); + } + + const result = await clearDrainOrderPriorities(tokenFiles, proxyRunning); + + if (configCleared || result.cleared.length > 0) { + console.log(ok(`Drain order reset to file order for ${provider}.`)); } else { console.log(info(`No drain order config found for ${provider}. Already in file order.`)); } + + if (result.cleared.length > 0) { + console.log(ok(`Cleared residual priority from ${result.cleared.length} auth file(s).`)); + } + if (result.alreadyClear.length > 0) { + console.log( + info(`${result.alreadyClear.length} auth file(s) had no priority set (nothing to clear).`) + ); + } + if (result.failed.length > 0) { + for (const f of result.failed) { + console.log(fail(`Failed to clear priority for ${f.tokenFile}: ${f.reason}`)); + } + console.log( + dim( + ' Residual priorities remain on the failed files above and will still steer the selector.' + ) + ); + process.exitCode = 1; + } console.log(''); } diff --git a/src/commands/cliproxy/pool-state-renderer.ts b/src/commands/cliproxy/pool-state-renderer.ts index 8c56c4c4..4a896c47 100644 --- a/src/commands/cliproxy/pool-state-renderer.ts +++ b/src/commands/cliproxy/pool-state-renderer.ts @@ -12,11 +12,13 @@ import { subheader, color, dim, warn } from '../../utils/ui'; import { - resolvePoolState, + resolvePoolStateWithProxyCooldowns, + hasProxySourcedCooling, POOL_TIER_AWARE_PROVIDERS, type PoolAccountState, type PoolRoutingSettings, type PoolDrainOrderMode, + type PoolState, } from '../../cliproxy/accounts/pool-state'; import { getConfiguredCliproxyRoutingStrategy, @@ -95,7 +97,11 @@ export function describeAccountState( return { label: `cooling until ${reset}${src}`, tone: 'cooling' }; } case 'paused': - return { label: 'paused (manual)', tone: 'paused' }; + // A pause can be manual (the user) OR automatic (ban detection, + // cross-provider isolation, an expired-but-not-restored quota cooldown). + // The classifier cannot tell which without a stored reason, so the label + // must not claim "manual". See pool-state.ts classifyAccount. + return { label: 'paused (manual or safety)', tone: 'paused' }; case 'available': default: return { label: 'available', tone: 'available' }; @@ -114,16 +120,83 @@ export function modeLabel(mode: PoolDrainOrderMode): string { } } +/** + * Render the drift warning. The honest copy depends on whether a stored order + * exists: in file mode there is no stored order (the user reset or never set + * one), so referencing a "stored order" or telling them to "re-apply" would be + * false. File-mode drift means residual on-disk priorities (e.g. left by a prior + * managed order) still steer the selector, and the fix is to clear them. + */ +function renderDrainOrderDrift(provider: CLIProxyProvider, pool: PoolState): void { + if (!pool.drainOrder.hasDrift) { + return; + } + + if (pool.drainOrder.mode === 'file') { + console.log( + ` ${warn( + `Drift: residual priorities from a previous managed order still steer the selector;\n` + + ` clear them with "ccs cliproxy accounts order ${provider} --reset" (or re-auth the accounts).` + )}` + ); + return; + } + + console.log( + ` ${warn( + `Drift: stored order does not match auth files; run "ccs cliproxy accounts order ${provider}" to inspect, then re-apply with --set/--by-tier.` + )}` + ); +} + +/** + * Render the honesty note about cooling visibility. + * + * - Pool OFF: cooling is disabled at the proxy (stock stability mode), so no + * account ever shows a quota cooldown here. State that plainly. + * - Pool ON but no live proxy reading available (proxy stopped/older binary, or + * the listing returned no cooling entries): the in-proxy 429 cooldowns that + * actually drive session hops are not visible, so say so rather than implying + * every account is healthy. + */ +function renderCoolingNote(pool: PoolState, settings: PoolRoutingSettings): void { + if (!settings.poolEnabled) { + const anyCooling = pool.states.some((s) => s.state === 'cooling'); + if (!anyCooling) { + console.log( + dim( + ' Note: cooling is off (stock stability mode); accounts never show a quota cooldown here.' + ) + ); + } + return; + } + + // Pool ON: only omit the caveat once we actually have live proxy-sourced + // cooling data to show. Otherwise the states reflect CCS-side checks only. + if (!hasProxySourcedCooling(pool)) { + console.log( + dim( + ' Note: live in-proxy 429 cooldowns are not shown here; states reflect CCS-side checks only.' + ) + ); + } +} + /** * Render the pool context section for one provider. Returns silently (renders * nothing) when the provider has no accounts, so quota output stays clean. + * + * Async because it folds in live in-proxy 429 cooldowns (the real session-hop + * mechanism when pool routing is ON) via a single bounded management API read; + * that read degrades silently when the proxy is stopped, remote, or older. */ -export function renderProviderPoolSection( +export async function renderProviderPoolSection( provider: CLIProxyProvider, settings: PoolRoutingSettings, now: number = Date.now() -): void { - const pool = resolvePoolState({ provider, settings, now }); +): Promise { + const pool = await resolvePoolStateWithProxyCooldowns({ provider, settings, now }); if (pool.states.length === 0) { return; @@ -155,16 +228,7 @@ export function renderProviderPoolSection( console.log(` Order: ${dim(orderMode)} ${dim('(no active accounts)')}`); } - // Drift: stored config order diverges from what is on disk. Re-auth drops the - // priority attribute and --reset leaves residuals, so warn instead of showing - // a "next account" that silently disagrees with the selector. - if (pool.drainOrder.hasDrift) { - console.log( - ` ${warn( - `Drift: stored order does not match auth files; run "ccs cliproxy accounts order ${provider}" to inspect, then re-apply with --set/--by-tier.` - )}` - ); - } + renderDrainOrderDrift(provider, pool); // Per-account state. Render in drain order first, then any accounts not in the // order (paused accounts are excluded from the order but still listed). @@ -188,16 +252,13 @@ export function renderProviderPoolSection( console.log(` - ${account.accountId}${defaultMark}: ${rendered}`); } - // Honest note when cooling is impossible to observe (cooling flip off). - if (!settings.poolEnabled) { - const anyCooling = pool.states.some((s) => s.state === 'cooling'); - if (!anyCooling) { - console.log( - dim( - ' Note: cooling is off (stock stability mode); accounts never show a quota cooldown here.' - ) - ); - } + renderCoolingNote(pool, settings); + + // Resume hint: a pause can be manual OR automatic (ban detection, + // cross-provider isolation, expired-but-unrestored quota cooldown). Point the + // user at the real resume command so they can recover whichever caused it. + if (pool.states.some((s) => s.state === 'paused')) { + console.log(` ${dim(`Resume: ccs cliproxy resume `)}`); } // Reset/management hints. Dim hint matches the order subcommand's "To update:" diff --git a/src/commands/cliproxy/pool-subcommand.ts b/src/commands/cliproxy/pool-subcommand.ts index c248066b..a2758bbe 100644 --- a/src/commands/cliproxy/pool-subcommand.ts +++ b/src/commands/cliproxy/pool-subcommand.ts @@ -8,25 +8,77 @@ */ import { initUI, header, ok, warn, info } from '../../utils/ui'; -import { enablePoolRouting, disablePoolRouting } from '../../cliproxy/routing/routing-strategy'; +import { + enablePoolRouting, + disablePoolRouting, + POOL_MAX_RETRY_CREDENTIALS, +} from '../../cliproxy/routing/routing-strategy'; import { loadOrCreateUnifiedConfig } from '../../config/config-loader-facade'; -import { CLIPROXY_DEFAULT_PORT } from '../../cliproxy/config/port-manager'; +import { resolveLifecyclePort } from '../../cliproxy/config/port-manager'; +import { getProxyTarget } from '../../cliproxy/proxy/proxy-target-resolver'; import { getConfigPathForPort, getAuthDir } from '../../cliproxy/config/path-resolver'; import { hasAnyFlag } from '../arg-extractor'; +/** + * Print the manual-config guidance for remote/Docker targets, then refuse. + * enablePoolRouting/disablePoolRouting only write local config files, so a + * remote proxy is never touched — refuse rather than lie about hot-reload. + * Mirrors the remote-refusal copy in order-subcommand and the manual snippet + * in pool-opt-in-prompt's printRemoteHint(). + */ +function printRemotePoolRefusal(enable: boolean): void { + console.log( + warn( + `Remote proxy target detected. Pool routing management for remote proxies is not\n` + + ` supported in v1. Run this command on the host running CLIProxy instead.` + ) + ); + console.log(''); + if (enable) { + console.log(' To enable pool routing manually, add to your CLIProxy config.yaml:'); + console.log(' disable-cooling: false'); + console.log(` max-retry-credentials: ${POOL_MAX_RETRY_CREDENTIALS}`); + console.log(' routing:'); + console.log(' strategy: fill-first'); + console.log(' session-affinity: true'); + console.log(' session-affinity-ttl: "1h"'); + console.log(''); + } +} + export async function handlePoolSubcommand(args: string[]): Promise { await initUI(); console.log(''); console.log(header('CLIProxy Pool Routing')); console.log(''); - const port = CLIPROXY_DEFAULT_PORT; + const wantsEnable = hasAnyFlag(args, ['--enable']); + const wantsDisable = hasAnyFlag(args, ['--disable']); + + // Resolve the real proxy target before any config write. Remote targets are + // refused (enable/disable only mutate local files; the remote proxy is never + // touched, so the "hot-reload" success message would be a lie). Local targets + // use the configured lifecycle port so the regenerated config-.yaml is + // the same file the running proxy reads. + if (wantsEnable || wantsDisable) { + const target = getProxyTarget(); + if (target.isRemote) { + printRemotePoolRefusal(wantsEnable); + process.exitCode = 1; + return; + } + } + + const port = resolveLifecyclePort(); const configPath = getConfigPathForPort(port); const authDir = getAuthDir(); - if (hasAnyFlag(args, ['--enable'])) { + if (wantsEnable) { const result = enablePoolRouting(port, { configPath, authDir }); - if (result.changed) { + if (result.failed) { + console.log(warn(result.message)); + process.exitCode = 1; + } else if (result.changed) { console.log(ok(result.message)); } else { console.log(info(result.message)); @@ -35,9 +87,12 @@ export async function handlePoolSubcommand(args: string[]): Promise { return; } - if (hasAnyFlag(args, ['--disable'])) { + if (wantsDisable) { const result = disablePoolRouting(port, { configPath, authDir }); - if (result.changed) { + if (result.failed) { + console.log(warn(result.message)); + process.exitCode = 1; + } else if (result.changed) { console.log(ok(result.message)); } else { console.log(info(result.message)); diff --git a/src/commands/cliproxy/quota-subcommand.ts b/src/commands/cliproxy/quota-subcommand.ts index dee53f07..624a16a2 100644 --- a/src/commands/cliproxy/quota-subcommand.ts +++ b/src/commands/cliproxy/quota-subcommand.ts @@ -907,7 +907,8 @@ export async function handleQuotaStatus( runtime.render(result); // Pool context: drain order + per-account state (available/cooling/paused). // QuotaSupportedProvider ids are all valid CLIProxyProvider values. - renderProviderPoolSection(provider as CLIProxyProvider, poolSettings); + // Async: folds in live in-proxy 429 cooldowns when pool routing is on. + await renderProviderPoolSection(provider as CLIProxyProvider, poolSettings); continue; } diff --git a/ui/src/components/cliproxy/routing-guidance-card.tsx b/ui/src/components/cliproxy/routing-guidance-card.tsx index c93bffcb..18db639b 100644 --- a/ui/src/components/cliproxy/routing-guidance-card.tsx +++ b/ui/src/components/cliproxy/routing-guidance-card.tsx @@ -48,6 +48,12 @@ export function RoutingGuidanceCard({ const currentStrategy = state?.strategy ?? 'round-robin'; const poolEnabled = state?.poolRouting?.enabled ?? false; const poolMaxRetry = state?.poolRouting?.maxRetryCredentials; + // Undefined manageable means local (managed); only false signals a remote + // proxy where the local pool flag may not reflect the running proxy. + const poolManageable = state?.poolRouting?.manageable ?? true; + const poolLocalOnly = !poolManageable; + const poolLocalOnlyMessage = + state?.poolRouting?.message ?? t('routingGuidance.poolRoutingLocalOnly'); const currentAffinityEnabled = sessionAffinityState?.enabled ?? false; const currentAffinityTtl = sessionAffinityState?.ttl ?? '1h'; const sessionAffinityManageable = sessionAffinityState?.manageable ?? true; @@ -174,26 +180,45 @@ export function RoutingGuidanceCard({ + {poolEnabled && poolManageable ? ( +
+ {t('routingGuidance.poolRoutingApplyWarning')} +
+ ) : null} +
{t('routingGuidance.poolRouting')}
- {poolEnabled && poolMaxRetry !== undefined - ? t('routingGuidance.poolMaxRetry', { count: poolMaxRetry }) - : t('routingGuidance.drainOrderHint')} + {poolLocalOnly + ? poolLocalOnlyMessage + : poolEnabled + ? t('routingGuidance.drainOrderHint') + : t('routingGuidance.poolRoutingOffHint')}
- {poolEnabled ? t('routingGuidance.poolRoutingOn') : t('routingGuidance.poolRoutingOff')} + {poolLocalOnly + ? t('routingGuidance.localOnly') + : poolEnabled + ? `${t('routingGuidance.poolRoutingOn')} · ${t('routingGuidance.poolMaxRetry', { + count: poolMaxRetry ?? 0, + })}` + : t('routingGuidance.poolRoutingOff')}
@@ -267,18 +292,22 @@ export function RoutingGuidanceCard({ {state ? {state.target} : null} {state ? ( {t('routingGuidance.poolRouting')}:{' '} - {poolEnabled - ? t('routingGuidance.poolRoutingOn') - : t('routingGuidance.poolRoutingOff')} - {poolEnabled && poolMaxRetry !== undefined + {poolLocalOnly + ? t('routingGuidance.localOnly') + : poolEnabled + ? t('routingGuidance.poolRoutingOn') + : t('routingGuidance.poolRoutingOff')} + {!poolLocalOnly && poolEnabled && poolMaxRetry !== undefined ? ` · ${t('routingGuidance.poolMaxRetry', { count: poolMaxRetry })}` : ''} @@ -339,6 +368,14 @@ export function RoutingGuidanceCard({ {isSaving ? 'Saving...' : `Use ${selected}`} + {poolEnabled && poolManageable ? ( +

+ {t('routingGuidance.poolRoutingApplyWarning')} +

+ ) : null}
diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 788a16de..f8b12736 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -512,6 +512,14 @@ export type RoutingStrategy = 'round-robin' | 'fill-first'; export interface CliproxyPoolRoutingState { enabled: boolean; maxRetryCredentials?: number; + /** + * Whether the pool flag reflects the actual proxy. For a remote proxy target + * the pool flag is local-only and may not be applied to the remote, so the + * backend sets this to false. Undefined is treated as manageable (local). + */ + manageable?: boolean; + /** Explanation surfaced when manageable is false (remote target). */ + message?: string; } export interface CliproxyRoutingState { diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index 80b32042..97d46f83 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -1924,6 +1924,10 @@ const resources = { 'Pool routing is on: CCS manages strategy (fill-first), session affinity, and cooling for the whole proxy.', poolRoutingOffHint: 'Pool routing is off. Enable it from the CLI (ccs cliproxy pool --enable) for fill-first drain with cooldown.', + poolRoutingApplyWarning: + 'Pool routing is on. Strategy and session affinity changes will not take effect until you disable it: ccs cliproxy pool --disable', + poolRoutingLocalOnly: + 'Pool routing is managed locally; this remote proxy may not reflect it.', poolMaxRetry: 'Max retry {{count}}', drainOrderHint: 'Per-account drain order and cooldown state: run ccs cliproxy quota or ccs cliproxy accounts order .', @@ -4559,6 +4563,9 @@ const resources = { '账号池路由已开启:CCS 为整个代理统一管理策略(优先填满)、会话粘性和冷却。', poolRoutingOffHint: '账号池路由已关闭。可在命令行启用(ccs cliproxy pool --enable)以使用带冷却的优先填满。', + poolRoutingApplyWarning: + '账号池路由已开启。在禁用之前,策略和会话粘性的更改不会生效:ccs cliproxy pool --disable', + poolRoutingLocalOnly: '账号池路由仅在本地管理;此远程代理可能不会反映该设置。', poolMaxRetry: '最大重试 {{count}}', drainOrderHint: '查看每个账号的排空顺序和冷却状态:运行 ccs cliproxy quota 或 ccs cliproxy accounts order 。', @@ -7257,6 +7264,10 @@ const resources = { 'Định tuyến nhóm đang bật: CCS quản lý chiến lược (fill-first), ghim phiên và làm nguội cho toàn bộ proxy.', poolRoutingOffHint: 'Định tuyến nhóm đang tắt. Bật từ CLI (ccs cliproxy pool --enable) để dùng fill-first kèm làm nguội.', + poolRoutingApplyWarning: + 'Định tuyến nhóm đang bật. Thay đổi chiến lược và ghim phiên sẽ không có hiệu lực cho đến khi bạn tắt nó: ccs cliproxy pool --disable', + poolRoutingLocalOnly: + 'Định tuyến nhóm chỉ được quản lý cục bộ; proxy từ xa này có thể không phản ánh điều đó.', poolMaxRetry: 'Thử lại tối đa {{count}}', drainOrderHint: 'Thứ tự rút và trạng thái làm nguội từng tài khoản: chạy ccs cliproxy quota hoặc ccs cliproxy accounts order .', @@ -10432,6 +10443,10 @@ const resources = { 'プールルーティングが有効です。CCS がプロキシ全体の戦略(fill-first)、セッション固定、クールダウンを管理します。', poolRoutingOffHint: 'プールルーティングは無効です。CLI(ccs cliproxy pool --enable)で有効にすると、クールダウン付きの fill-first を利用できます。', + poolRoutingApplyWarning: + 'プールルーティングが有効です。無効化するまで、戦略とセッション固定の変更は反映されません:ccs cliproxy pool --disable', + poolRoutingLocalOnly: + 'プールルーティングはローカルで管理されます。このリモートプロキシには反映されない場合があります。', poolMaxRetry: '最大リトライ {{count}}', drainOrderHint: 'アカウントごとのドレイン順序とクールダウン状態は、ccs cliproxy quota または ccs cliproxy accounts order を実行してください。', @@ -12704,6 +12719,10 @@ const resources = { '풀 라우팅이 켜져 있습니다. CCS가 프록시 전체의 전략(fill-first), 세션 어피니티, 쿨다운을 관리합니다.', poolRoutingOffHint: '풀 라우팅이 꺼져 있습니다. CLI(ccs cliproxy pool --enable)에서 켜면 쿨다운이 있는 fill-first를 사용할 수 있습니다.', + poolRoutingApplyWarning: + '풀 라우팅이 켜져 있습니다. 비활성화하기 전에는 전략과 세션 어피니티 변경이 적용되지 않습니다: ccs cliproxy pool --disable', + poolRoutingLocalOnly: + '풀 라우팅은 로컬에서 관리됩니다. 이 원격 프록시에는 반영되지 않을 수 있습니다.', poolMaxRetry: '최대 재시도 {{count}}', drainOrderHint: '계정별 드레인 순서와 쿨다운 상태는 ccs cliproxy quota 또는 ccs cliproxy accounts order 를 실행하세요.', diff --git a/ui/tests/unit/components/cliproxy/routing-guidance-card.test.tsx b/ui/tests/unit/components/cliproxy/routing-guidance-card.test.tsx index 6fc4e1f3..62a939d8 100644 --- a/ui/tests/unit/components/cliproxy/routing-guidance-card.test.tsx +++ b/ui/tests/unit/components/cliproxy/routing-guidance-card.test.tsx @@ -87,4 +87,109 @@ describe('RoutingGuidanceCard', () => { ).toBeInTheDocument(); expect(screen.getByRole('button', { name: /session affinity unavailable/i })).toBeDisabled(); }); + + it('compact: pool ON shows the drain-order pointer and an inline override warning', () => { + render( + undefined} + onApplyAffinity={() => undefined} + /> + ); + + // Drain-order CLI pointer is visible (not hidden behind a hover tooltip). + expect( + screen.getByText(/ccs cliproxy quota or ccs cliproxy accounts order/i) + ).toBeInTheDocument(); + // Max retry stays visible in the badge. + expect(screen.getByText(/On · Max retry 3/i)).toBeInTheDocument(); + // Static override warning is rendered before the user clicks anything. + expect( + screen.getByText(/will not take effect until you disable it: ccs cliproxy pool --disable/i) + ).toBeInTheDocument(); + }); + + it('compact: pool OFF surfaces the how-to-enable command as visible text', () => { + render( + undefined} + onApplyAffinity={() => undefined} + /> + ); + + // The enable command is visible, not buried in a hover-only tooltip. + expect(screen.getByText(/ccs cliproxy pool --enable/i)).toBeInTheDocument(); + // No override warning when pool is off. + expect(screen.queryByText(/ccs cliproxy pool --disable/i)).not.toBeInTheDocument(); + }); + + it('compact: remote pool (manageable false) shows a local-only note, not a confident On/Off', () => { + render( + undefined} + onApplyAffinity={() => undefined} + /> + ); + + expect( + screen.getByText('Pool routing is managed locally; this remote proxy may not reflect it.') + ).toBeInTheDocument(); + // No override warning for a remote proxy the local flag does not manage. + expect(screen.queryByText(/ccs cliproxy pool --disable/i)).not.toBeInTheDocument(); + }); });