diff --git a/src/cliproxy/account-safety.ts b/src/cliproxy/account-safety.ts new file mode 100644 index 00000000..522c248e --- /dev/null +++ b/src/cliproxy/account-safety.ts @@ -0,0 +1,376 @@ +/** + * Account Safety Guards + * + * Prevents Google account bans by: + * 1. Cross-provider isolation (auto-pause conflicting accounts at launch, restore on exit) + * 2. Ban/disable detection (auto-pauses affected accounts on error response) + * 3. Crash recovery (restores stale auto-pauses from dead sessions) + * + * Ref: https://github.com/kaitranntt/ccs/issues/509 + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { warn, info } from '../utils/ui'; +import { CLIProxyProvider } from './types'; +import { loadAccountsRegistry, pauseAccount, resumeAccount } from './accounts/registry'; +import { getCcsDir } from '../utils/config-manager'; + +/** Providers that use Google OAuth (ban risk when overlapping) */ +const GOOGLE_OAUTH_PROVIDERS: CLIProxyProvider[] = ['gemini', 'agy', 'codex']; + +// --- Auto-pause persistence (crash recovery) --- + +interface AutoPausedSession { + initiator: CLIProxyProvider; + pid: number; + pausedAt: string; + accounts: Array<{ provider: CLIProxyProvider; accountId: string }>; +} + +interface AutoPausedFile { + sessions: AutoPausedSession[]; +} + +function getAutoPausedPath(): string { + return path.join(getCcsDir(), 'cliproxy', 'auto-paused.json'); +} + +function loadAutoPaused(): AutoPausedFile { + try { + const filePath = getAutoPausedPath(); + if (fs.existsSync(filePath)) { + const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + if (Array.isArray(data.sessions)) return { sessions: data.sessions }; + } + } catch { + // Corrupted or malformed file — start fresh + } + return { sessions: [] }; +} + +function saveAutoPaused(data: AutoPausedFile): void { + const filePath = getAutoPausedPath(); + if (data.sessions.length === 0) { + try { + fs.unlinkSync(filePath); + } catch { + /* already gone */ + } + return; + } + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 }); +} + +/** + * Check if a process is alive. NOTE: PIDs can be recycled by the OS. + * If a stale PID is reused by an unrelated process, cleanup is deferred until that process exits. + * This is acceptable — next CCS launch will self-heal via cleanupStaleAutoPauses(). + */ +function isPidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +/** + * Detect same email registered under multiple Google OAuth providers. + * This is the primary cause of account bans — Google sees concurrent + * OAuth usage from different client IDs as suspicious activity. + * + * Returns map of email -> providers it appears in (only duplicates). + */ +export function detectCrossProviderDuplicates(): Map { + const registry = loadAccountsRegistry(); + + // Build email -> providers mapping (only Google OAuth providers) + const emailProviders = new Map(); + + for (const provider of GOOGLE_OAUTH_PROVIDERS) { + const providerAccounts = registry.providers[provider]; + if (!providerAccounts) continue; + + for (const [, account] of Object.entries(providerAccounts.accounts)) { + const email = account.email; + if (!email || account.paused) continue; + + const normalized = email.toLowerCase(); + const existing = emailProviders.get(normalized) ?? []; + existing.push(provider); + emailProviders.set(normalized, existing); + } + } + + // Filter to only duplicates (email in 2+ providers) + const duplicates = new Map(); + for (const [email, providers] of emailProviders) { + if (providers.length > 1) { + duplicates.set(email, providers); + } + } + + return duplicates; +} + +/** + * Check if a newly registered account creates a cross-provider conflict. + * Returns the conflicting providers, or null if no conflict. + */ +export function checkNewAccountConflict( + provider: CLIProxyProvider, + email: string | undefined +): CLIProxyProvider[] | null { + if (!email || !GOOGLE_OAUTH_PROVIDERS.includes(provider)) return null; + + const registry = loadAccountsRegistry(); + const normalized = email.toLowerCase(); + const conflicts: CLIProxyProvider[] = []; + + for (const other of GOOGLE_OAUTH_PROVIDERS) { + if (other === provider) continue; + + const providerAccounts = registry.providers[other]; + if (!providerAccounts) continue; + + for (const [, account] of Object.entries(providerAccounts.accounts)) { + if (account.email?.toLowerCase() === normalized && !account.paused) { + conflicts.push(other); + break; + } + } + } + + return conflicts.length > 0 ? conflicts : null; +} + +/** + * Display cross-provider duplicate warning at session launch. + * Returns true if warning was shown. + */ +export function warnCrossProviderDuplicates(provider: CLIProxyProvider): boolean { + if (!GOOGLE_OAUTH_PROVIDERS.includes(provider)) return false; + + const duplicates = detectCrossProviderDuplicates(); + if (duplicates.size === 0) return false; + + console.error(''); + console.error(warn('Account safety: cross-provider duplicate detected')); + console.error(' Same Google account across providers risks account bans (ref: #509).'); + console.error(''); + + for (const [email, providers] of duplicates) { + console.error(` ${maskEmail(email)} -> ${providers.join(', ')}`); + } + + console.error(''); + console.error(' Fix: pause duplicate with "ccs --pause "'); + console.error(' or use separate Google accounts per provider.'); + console.error(''); + + return true; +} + +/** + * Warn about a specific new account conflict during OAuth registration. + */ +export function warnNewAccountConflict( + email: string, + conflictingProviders: CLIProxyProvider[] +): void { + console.error(''); + console.error(warn('Account safety: this email is used by another provider')); + console.error( + ` ${maskEmail(email)} is also registered under: ${conflictingProviders.join(', ')}` + ); + console.error(' Concurrent usage may cause Google to ban your account.'); + console.error(' Consider pausing the duplicate or using a different account.'); + console.error(''); +} + +// --- Enforcement: auto-pause/restore --- + +/** + * Restore auto-paused accounts from crashed sessions (dead PIDs). + * Call at launch BEFORE enforceProviderIsolation(). + */ +export function cleanupStaleAutoPauses(): void { + const data = loadAutoPaused(); + if (data.sessions.length === 0) return; + + const alive: AutoPausedSession[] = []; + + for (const session of data.sessions) { + if (isPidAlive(session.pid)) { + alive.push(session); + continue; + } + // Dead PID — restore accounts + for (const { provider, accountId } of session.accounts) { + resumeAccount(provider, accountId); + } + console.error( + info( + `Restored ${session.accounts.length} auto-paused account(s) from crashed ${session.initiator} session` + ) + ); + } + + if (alive.length !== data.sessions.length) { + saveAutoPaused({ sessions: alive }); + } +} + +/** + * Enforce provider isolation by auto-pausing conflicting accounts in other providers. + * Records paused accounts for crash recovery and session exit restore. + * Returns number of accounts paused. + */ +export function enforceProviderIsolation(provider: CLIProxyProvider): number { + if (!GOOGLE_OAUTH_PROVIDERS.includes(provider)) return 0; + + // If another provider session is actively managing isolation, just warn + const data = loadAutoPaused(); + const otherActive = data.sessions.filter((s) => s.initiator !== provider && isPidAlive(s.pid)); + if (otherActive.length > 0) return 0; + + const registry = loadAccountsRegistry(); + const currentAccounts = registry.providers[provider]; + if (!currentAccounts) return 0; + + // Collect active emails for current provider + const myEmails = new Set(); + for (const [, account] of Object.entries(currentAccounts.accounts)) { + if (account.email && !account.paused) { + myEmails.add(account.email.toLowerCase()); + } + } + if (myEmails.size === 0) return 0; + + // Find conflicting accounts in other Google OAuth providers + const toPause: Array<{ provider: CLIProxyProvider; accountId: string }> = []; + + for (const other of GOOGLE_OAUTH_PROVIDERS) { + if (other === provider) continue; + const otherAccounts = registry.providers[other]; + if (!otherAccounts) continue; + + for (const [accountId, account] of Object.entries(otherAccounts.accounts)) { + if (account.email && !account.paused && myEmails.has(account.email.toLowerCase())) { + toPause.push({ provider: other, accountId }); + } + } + } + + if (toPause.length === 0) return 0; + + // Pause conflicting accounts + for (const { provider: p, accountId } of toPause) { + pauseAccount(p, accountId); + } + + // Record for crash recovery (re-read to reduce concurrent write race window) + const freshData = loadAutoPaused(); + freshData.sessions = freshData.sessions.filter((s) => s.initiator !== provider); + freshData.sessions.push({ + initiator: provider, + pid: process.pid, + pausedAt: new Date().toISOString(), + accounts: toPause, + }); + saveAutoPaused(freshData); + + console.error(''); + console.error(info(`Account safety: auto-paused ${toPause.length} conflicting account(s)`)); + for (const { provider: p, accountId } of toPause) { + const acct = registry.providers[p]?.accounts[accountId]; + const display = acct?.email ? maskEmail(acct.email) : accountId; + console.error(` ${display} (${p})`); + } + console.error(' Will restore on session exit.'); + console.error(''); + + return toPause.length; +} + +/** + * Restore accounts that were auto-paused by this session. + * Called on session exit (process 'exit' event). + * Skips accounts re-paused after enforcement (e.g., by ban handler). + */ +export function restoreAutoPausedAccounts(provider: CLIProxyProvider): void { + const data = loadAutoPaused(); + const mySession = data.sessions.find((s) => s.initiator === provider && s.pid === process.pid); + if (!mySession) return; + + const registry = loadAccountsRegistry(); + + for (const { provider: p, accountId } of mySession.accounts) { + // Don't restore if account was re-paused after enforcement (e.g., ban detected) + const account = registry.providers[p]?.accounts[accountId]; + if (account?.pausedAt && account.pausedAt > mySession.pausedAt) { + continue; + } + resumeAccount(p, accountId); + } + + data.sessions = data.sessions.filter((s) => !(s.initiator === provider && s.pid === process.pid)); + saveAutoPaused(data); +} + +// Error patterns that indicate Google has disabled/banned an account +const BAN_PATTERNS = [ + 'disabled in this account', + 'violation of terms of service', + 'account has been disabled', + 'account is disabled', + 'account has been suspended', + 'account has been banned', +]; + +/** + * Check if an error message indicates an account ban/disable. + */ +export function isBanResponse(errorMessage: string): boolean { + const lower = errorMessage.toLowerCase(); + return BAN_PATTERNS.some((pattern) => lower.includes(pattern)); +} + +/** + * Handle detected account ban by auto-pausing the affected account. + * Returns true if account was paused. + */ +export function handleBanDetection( + provider: CLIProxyProvider, + accountId: string, + errorMessage: string +): boolean { + if (!isBanResponse(errorMessage)) return false; + + console.error(''); + console.error(warn('Account safety: account appears disabled by Google')); + console.error(` Account "${accountId}" (${provider}) returned:`); + console.error(` "${truncate(errorMessage, 120)}"`); + console.error(''); + console.error(info('Auto-pausing this account to prevent further issues.')); + console.error(` Resume later: ccs ${provider} --resume ${accountId}`); + console.error(''); + + return pauseAccount(provider, accountId); +} + +/** Mask email for privacy in terminal output */ +export function maskEmail(email: string): string { + const [local, domain] = email.split('@'); + if (!local || !domain) return email; + return `${local.slice(0, 3)}***@${domain}`; +} + +/** Truncate string with ellipsis */ +function truncate(str: string, maxLen: number): string { + return str.length > maxLen ? str.slice(0, maxLen - 3) + '...' : str; +} diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index f8fe82b6..29e47e98 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -39,6 +39,7 @@ import { getProviderTokenDir, isAuthenticated, registerAccountFromToken } from ' import { executeOAuthProcess } from './oauth-process'; import { importKiroToken } from './kiro-import'; import { getProxyTarget, buildProxyUrl, buildManagementHeaders } from '../proxy-target-resolver'; +import { checkNewAccountConflict, warnNewAccountConflict } from '../account-safety'; /** * Prompt user to add another account @@ -379,7 +380,17 @@ async function handlePasteCallbackMode( } console.log(ok('Authentication successful!')); - return registerAccountFromToken(provider, tokenDir, nickname); + const account = registerAccountFromToken(provider, tokenDir, nickname); + + // Account safety: check for cross-provider conflicts + if (account?.email) { + const conflicts = checkNewAccountConflict(provider, account.email); + if (conflicts) { + warnNewAccountConflict(account.email, conflicts); + } + } + + return account; } catch (error) { if (verbose) { console.log(fail(`Error: ${(error as Error).message}`)); @@ -543,6 +554,14 @@ export async function triggerOAuth( console.log(' Or enable "Kiro: Use normal browser" in: ccs config'); } + // Account safety: check for cross-provider conflicts + if (account?.email) { + const conflicts = checkNewAccountConflict(provider, account.email); + if (conflicts) { + warnNewAccountConflict(account.email, conflicts); + } + } + return account; } diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index 807ca8b1..098a7356 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -63,6 +63,12 @@ import { handleQuotaCheck, } from './retry-handler'; import { checkOrJoinProxy, registerProxySession, setupCleanupHandlers } from './session-bridge'; +import { + warnCrossProviderDuplicates, + cleanupStaleAutoPauses, + enforceProviderIsolation, + restoreAutoPausedAccounts, +} from '../account-safety'; import { getWebSearchHookEnv } from '../../utils/websearch-manager'; /** Default executor configuration */ @@ -507,6 +513,20 @@ export async function execClaudeWithCLIProxy( await handleQuotaCheck(provider); } + // 3c. Account safety: enforce cross-provider isolation + if (!skipLocalAuth) { + cleanupStaleAutoPauses(); + const isolated = enforceProviderIsolation(provider); + if (isolated === 0) { + // No enforcement — still warn about duplicates for awareness + warnCrossProviderDuplicates(provider); + } else { + process.on('exit', () => { + restoreAutoPausedAccounts(provider); + }); + } + } + // 4. First-run model configuration if (supportsModelConfig(provider) && !skipLocalAuth) { await configureProviderModel(provider, false, cfg.customSettingsPath); diff --git a/src/cliproxy/executor/retry-handler.ts b/src/cliproxy/executor/retry-handler.ts index 83480f0a..d3bb5996 100644 --- a/src/cliproxy/executor/retry-handler.ts +++ b/src/cliproxy/executor/retry-handler.ts @@ -10,6 +10,7 @@ import { fail, warn, info } from '../../utils/ui'; import { CLIProxyProvider } from '../types'; +import { handleBanDetection } from '../account-safety'; /** * Check if error is network-related @@ -50,6 +51,15 @@ export async function handleTokenExpiration( const tokenResult = await ensureTokenValid(provider, verbose); if (!tokenResult.valid) { + // Check if this is an account ban/disable before generic error + if (tokenResult.error) { + const { getDefaultAccount } = await import('../account-manager'); + const account = getDefaultAccount(provider); + if (account) { + handleBanDetection(provider, account.id, tokenResult.error); + } + } + // Token expired and refresh failed - trigger re-auth console.error(warn('OAuth token expired and refresh failed')); if (tokenResult.error) { diff --git a/tests/unit/cliproxy/account-safety.test.ts b/tests/unit/cliproxy/account-safety.test.ts new file mode 100644 index 00000000..48c7234e --- /dev/null +++ b/tests/unit/cliproxy/account-safety.test.ts @@ -0,0 +1,621 @@ +/** + * Account Safety Guards Unit Tests + * + * Tests ban detection, email masking, cross-provider duplicate detection, + * enforcement lifecycle, and crash recovery. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + isBanResponse, + maskEmail, + detectCrossProviderDuplicates, + enforceProviderIsolation, + cleanupStaleAutoPauses, + restoreAutoPausedAccounts, + checkNewAccountConflict, + handleBanDetection, + warnCrossProviderDuplicates, +} from '../../../src/cliproxy/account-safety'; + +// --- Test isolation: use temp CCS_HOME --- + +let tmpDir: string; +let origCcsHome: string | undefined; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-test-safety-')); + origCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tmpDir; +}); + +afterEach(() => { + if (origCcsHome !== undefined) { + process.env.CCS_HOME = origCcsHome; + } else { + delete process.env.CCS_HOME; + } + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +// CCS_HOME appends .ccs — all paths go through getCcsDir() = CCS_HOME/.ccs +function ccsDir(): string { + return path.join(tmpDir, '.ccs'); +} + +// --- Helper: write accounts registry --- + +function writeRegistry(providers: Record): void { + const registryDir = path.join(ccsDir(), 'cliproxy'); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + path.join(registryDir, 'accounts.json'), + JSON.stringify({ version: 1, providers }, null, 2) + ); +} + +// --- Helper: write auto-paused file --- + +function writeAutoPaused(sessions: unknown[]): void { + const dir = path.join(ccsDir(), 'cliproxy'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'auto-paused.json'), JSON.stringify({ sessions }, null, 2)); +} + +function readAutoPaused(): { sessions: unknown[] } { + const filePath = path.join(ccsDir(), 'cliproxy', 'auto-paused.json'); + if (!fs.existsSync(filePath)) return { sessions: [] }; + return JSON.parse(fs.readFileSync(filePath, 'utf-8')); +} + +// --- Helper: write dummy token files --- + +function writeTokenFile(filename: string, paused = false): void { + const dir = paused + ? path.join(ccsDir(), 'cliproxy', 'auth-paused') + : path.join(ccsDir(), 'cliproxy', 'auth'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, filename), JSON.stringify({ type: 'test' })); +} + +// ======================================== +// isBanResponse +// ======================================== + +describe('isBanResponse', () => { + it('should detect "disabled in this account"', () => { + expect(isBanResponse('API access disabled in this account')).toBe(true); + }); + + it('should detect "violation of terms of service"', () => { + expect(isBanResponse('Your account was flagged for violation of terms of service')).toBe(true); + }); + + it('should detect "account has been suspended"', () => { + expect(isBanResponse('This account has been suspended by Google')).toBe(true); + }); + + it('should be case-insensitive', () => { + expect(isBanResponse('ACCOUNT HAS BEEN DISABLED')).toBe(true); + }); + + it('should return false for normal errors', () => { + expect(isBanResponse('Rate limit exceeded')).toBe(false); + expect(isBanResponse('Internal server error')).toBe(false); + expect(isBanResponse('Network timeout')).toBe(false); + }); + + it('should return false for empty string', () => { + expect(isBanResponse('')).toBe(false); + }); +}); + +// ======================================== +// maskEmail +// ======================================== + +describe('maskEmail', () => { + it('should mask standard email', () => { + expect(maskEmail('user@example.com')).toBe('use***@example.com'); + }); + + it('should handle short local part', () => { + expect(maskEmail('ab@example.com')).toBe('ab***@example.com'); + }); + + it('should handle single char local part', () => { + expect(maskEmail('a@example.com')).toBe('a***@example.com'); + }); + + it('should return input if no @ sign', () => { + expect(maskEmail('not-an-email')).toBe('not-an-email'); + }); + + it('should return input if empty string', () => { + expect(maskEmail('')).toBe(''); + }); +}); + +// ======================================== +// detectCrossProviderDuplicates +// ======================================== + +describe('detectCrossProviderDuplicates', () => { + it('should return empty map when no duplicates', () => { + writeRegistry({ + gemini: { + default: 'user1@gmail.com', + accounts: { + 'user1@gmail.com': { + email: 'user1@gmail.com', + tokenFile: 'gemini-user1.json', + }, + }, + }, + agy: { + default: 'user2@gmail.com', + accounts: { + 'user2@gmail.com': { + email: 'user2@gmail.com', + tokenFile: 'agy-user2.json', + }, + }, + }, + }); + + const dupes = detectCrossProviderDuplicates(); + expect(dupes.size).toBe(0); + }); + + it('should detect same email across providers', () => { + writeRegistry({ + gemini: { + default: 'shared@gmail.com', + accounts: { + 'shared@gmail.com': { + email: 'shared@gmail.com', + tokenFile: 'gemini-shared.json', + }, + }, + }, + agy: { + default: 'shared@gmail.com', + accounts: { + 'shared@gmail.com': { + email: 'shared@gmail.com', + tokenFile: 'agy-shared.json', + }, + }, + }, + }); + + const dupes = detectCrossProviderDuplicates(); + expect(dupes.size).toBe(1); + expect(dupes.get('shared@gmail.com')).toEqual(['gemini', 'agy']); + }); + + it('should skip paused accounts', () => { + writeRegistry({ + gemini: { + default: 'shared@gmail.com', + accounts: { + 'shared@gmail.com': { + email: 'shared@gmail.com', + tokenFile: 'gemini-shared.json', + paused: true, + }, + }, + }, + agy: { + default: 'shared@gmail.com', + accounts: { + 'shared@gmail.com': { + email: 'shared@gmail.com', + tokenFile: 'agy-shared.json', + }, + }, + }, + }); + + const dupes = detectCrossProviderDuplicates(); + expect(dupes.size).toBe(0); + }); + + it('should be case-insensitive on email', () => { + writeRegistry({ + gemini: { + default: 'User@Gmail.com', + accounts: { + 'User@Gmail.com': { + email: 'User@Gmail.com', + tokenFile: 'gemini-user.json', + }, + }, + }, + agy: { + default: 'user@gmail.com', + accounts: { + 'user@gmail.com': { + email: 'user@gmail.com', + tokenFile: 'agy-user.json', + }, + }, + }, + }); + + const dupes = detectCrossProviderDuplicates(); + expect(dupes.size).toBe(1); + }); +}); + +// ======================================== +// checkNewAccountConflict +// ======================================== + +describe('checkNewAccountConflict', () => { + it('should return null for non-Google provider', () => { + const result = checkNewAccountConflict('kiro' as never, 'user@gmail.com'); + expect(result).toBeNull(); + }); + + it('should return null when no conflict', () => { + writeRegistry({ + gemini: { + default: 'other@gmail.com', + accounts: { + 'other@gmail.com': { + email: 'other@gmail.com', + tokenFile: 'gemini-other.json', + }, + }, + }, + }); + + const result = checkNewAccountConflict('agy', 'new@gmail.com'); + expect(result).toBeNull(); + }); + + it('should return conflicting providers', () => { + writeRegistry({ + gemini: { + default: 'shared@gmail.com', + accounts: { + 'shared@gmail.com': { + email: 'shared@gmail.com', + tokenFile: 'gemini-shared.json', + }, + }, + }, + }); + + const result = checkNewAccountConflict('agy', 'shared@gmail.com'); + expect(result).toEqual(['gemini']); + }); + + it('should return null when email is undefined', () => { + const result = checkNewAccountConflict('agy', undefined); + expect(result).toBeNull(); + }); +}); + +// ======================================== +// cleanupStaleAutoPauses +// ======================================== + +describe('cleanupStaleAutoPauses', () => { + it('should do nothing when no sessions', () => { + // No auto-paused.json exists + cleanupStaleAutoPauses(); + // Should not throw + }); + + it('should remove sessions with dead PIDs', () => { + // Use PID 999999999 which is almost certainly dead + writeAutoPaused([ + { + initiator: 'gemini', + pid: 999999999, + pausedAt: new Date().toISOString(), + accounts: [{ provider: 'agy', accountId: 'test@gmail.com' }], + }, + ]); + + // Write registry with the paused account so resumeAccount can find it + writeRegistry({ + agy: { + default: 'test@gmail.com', + accounts: { + 'test@gmail.com': { + email: 'test@gmail.com', + tokenFile: 'agy-test.json', + paused: true, + pausedAt: new Date().toISOString(), + }, + }, + }, + }); + writeTokenFile('agy-test.json', true); + + cleanupStaleAutoPauses(); + + const data = readAutoPaused(); + expect(data.sessions.length).toBe(0); + }); + + it('should keep sessions with alive PIDs', () => { + const alivePid = process.pid; // Current process is alive + + writeAutoPaused([ + { + initiator: 'gemini', + pid: alivePid, + pausedAt: new Date().toISOString(), + accounts: [{ provider: 'agy', accountId: 'test@gmail.com' }], + }, + ]); + + cleanupStaleAutoPauses(); + + const data = readAutoPaused(); + expect(data.sessions.length).toBe(1); + }); +}); + +// ======================================== +// enforceProviderIsolation +// ======================================== + +describe('enforceProviderIsolation', () => { + it('should return 0 for non-Google provider', () => { + const result = enforceProviderIsolation('kiro' as never); + expect(result).toBe(0); + }); + + it('should return 0 when no conflicting accounts', () => { + writeRegistry({ + gemini: { + default: 'user1@gmail.com', + accounts: { + 'user1@gmail.com': { + email: 'user1@gmail.com', + tokenFile: 'gemini-user1.json', + }, + }, + }, + agy: { + default: 'user2@gmail.com', + accounts: { + 'user2@gmail.com': { + email: 'user2@gmail.com', + tokenFile: 'agy-user2.json', + }, + }, + }, + }); + writeTokenFile('gemini-user1.json'); + writeTokenFile('agy-user2.json'); + + const result = enforceProviderIsolation('gemini'); + expect(result).toBe(0); + }); + + it('should pause conflicting accounts and record session', () => { + writeRegistry({ + gemini: { + default: 'shared@gmail.com', + accounts: { + 'shared@gmail.com': { + email: 'shared@gmail.com', + tokenFile: 'gemini-shared.json', + }, + }, + }, + agy: { + default: 'shared@gmail.com', + accounts: { + 'shared@gmail.com': { + email: 'shared@gmail.com', + tokenFile: 'agy-shared.json', + }, + }, + }, + }); + writeTokenFile('gemini-shared.json'); + writeTokenFile('agy-shared.json'); + + const result = enforceProviderIsolation('gemini'); + expect(result).toBe(1); + + // Verify auto-paused.json was written + const data = readAutoPaused(); + expect(data.sessions.length).toBe(1); + expect(data.sessions[0].initiator).toBe('gemini'); + expect(data.sessions[0].pid).toBe(process.pid); + }); +}); + +// ======================================== +// restoreAutoPausedAccounts +// ======================================== + +describe('restoreAutoPausedAccounts', () => { + it('should do nothing when no session exists', () => { + restoreAutoPausedAccounts('gemini'); + // Should not throw + }); + + it('should skip accounts re-paused after enforcement', () => { + const enforcementTime = '2024-01-01T00:00:00.000Z'; + const laterTime = '2024-01-01T01:00:00.000Z'; + + writeAutoPaused([ + { + initiator: 'gemini', + pid: process.pid, + pausedAt: enforcementTime, + accounts: [{ provider: 'agy', accountId: 'banned@gmail.com' }], + }, + ]); + + writeRegistry({ + agy: { + default: 'banned@gmail.com', + accounts: { + 'banned@gmail.com': { + email: 'banned@gmail.com', + tokenFile: 'agy-banned.json', + paused: true, + pausedAt: laterTime, // Re-paused AFTER enforcement (e.g., ban) + }, + }, + }, + }); + writeTokenFile('agy-banned.json', true); + + restoreAutoPausedAccounts('gemini'); + + // Account should NOT be restored because it was re-paused later + const registry = JSON.parse( + fs.readFileSync(path.join(ccsDir(), 'cliproxy', 'accounts.json'), 'utf-8') + ); + expect(registry.providers.agy.accounts['banned@gmail.com'].paused).toBe(true); + }); +}); + +// ======================================== +// handleBanDetection +// ======================================== + +describe('handleBanDetection', () => { + it('should pause account when ban error detected', () => { + writeRegistry({ + gemini: { + default: 'user@gmail.com', + accounts: { + 'user@gmail.com': { + email: 'user@gmail.com', + tokenFile: 'gemini-user.json', + }, + }, + }, + }); + writeTokenFile('gemini-user.json'); + + const result = handleBanDetection( + 'gemini', + 'user@gmail.com', + 'API access disabled in this account' + ); + + expect(result).toBe(true); + + // Verify account was paused in registry + const registry = JSON.parse( + fs.readFileSync(path.join(ccsDir(), 'cliproxy', 'accounts.json'), 'utf-8') + ); + expect(registry.providers.gemini.accounts['user@gmail.com'].paused).toBe(true); + }); + + it('should return false for non-ban errors', () => { + writeRegistry({ + gemini: { + default: 'user@gmail.com', + accounts: { + 'user@gmail.com': { + email: 'user@gmail.com', + tokenFile: 'gemini-user.json', + }, + }, + }, + }); + writeTokenFile('gemini-user.json'); + + const result = handleBanDetection('gemini', 'user@gmail.com', 'Rate limit exceeded'); + + expect(result).toBe(false); + + // Verify account was NOT paused + const registry = JSON.parse( + fs.readFileSync(path.join(ccsDir(), 'cliproxy', 'accounts.json'), 'utf-8') + ); + expect(registry.providers.gemini.accounts['user@gmail.com'].paused).toBeUndefined(); + }); +}); + +// ======================================== +// warnCrossProviderDuplicates +// ======================================== + +describe('warnCrossProviderDuplicates', () => { + it('should return true when duplicates exist', () => { + writeRegistry({ + gemini: { + default: 'shared@gmail.com', + accounts: { + 'shared@gmail.com': { + email: 'shared@gmail.com', + tokenFile: 'gemini-shared.json', + }, + }, + }, + agy: { + default: 'shared@gmail.com', + accounts: { + 'shared@gmail.com': { + email: 'shared@gmail.com', + tokenFile: 'agy-shared.json', + }, + }, + }, + }); + + const result = warnCrossProviderDuplicates('gemini'); + expect(result).toBe(true); + }); + + it('should return false when no duplicates', () => { + writeRegistry({ + gemini: { + default: 'user1@gmail.com', + accounts: { + 'user1@gmail.com': { + email: 'user1@gmail.com', + tokenFile: 'gemini-user1.json', + }, + }, + }, + agy: { + default: 'user2@gmail.com', + accounts: { + 'user2@gmail.com': { + email: 'user2@gmail.com', + tokenFile: 'agy-user2.json', + }, + }, + }, + }); + + const result = warnCrossProviderDuplicates('gemini'); + expect(result).toBe(false); + }); + + it('should return false for non-Google providers', () => { + writeRegistry({ + kiro: { + default: 'user@example.com', + accounts: { + 'user@example.com': { + email: 'user@example.com', + tokenFile: 'kiro-user.json', + }, + }, + }, + }); + + const result = warnCrossProviderDuplicates('kiro' as never); + expect(result).toBe(false); + }); +});