feat(cliproxy): add account safety guards to prevent Google account bans (#516)

* feat(cliproxy): add account safety guards to prevent Google account bans

Implements cross-provider isolation to prevent Google from flagging
concurrent OAuth usage across different client IDs (ref: #509, #512).

Three pillars:
1. Auto-pause enforcement at session launch — conflicting accounts in
   other Google OAuth providers are paused so CLIProxyAPI can't use them,
   restored on session exit with crash recovery via auto-paused.json
2. Ban/disable detection — error responses matching Google ban patterns
   auto-pause the affected account to prevent further damage
3. Cross-provider conflict warnings during OAuth registration

Key design decisions:
- PID-based session tracking for crash recovery (dead PID = restore)
- Timestamp comparison prevents restoring ban-paused accounts on exit
- Schema validation on auto-paused.json prevents corrupted state
- Falls back to warn-only when another session is managing isolation

* fix(cliproxy): address code review feedback (attempt 1/5)

- Re-read auto-paused.json before write in enforceProviderIsolation to
  reduce concurrent write race window
- Use actual email from registry for display instead of raw accountId
- Export maskEmail for testability
- Add 27 unit tests covering ban detection, email masking,
  cross-provider duplicate detection, enforcement lifecycle,
  crash recovery, and timestamp-guarded restore

* fix(cliproxy): address remaining review feedback (attempt 2/5)

- Add handleBanDetection test verifying account pause on ban error
- Add warnCrossProviderDuplicates tests (true/false/non-Google)
- Document PID reuse limitation in isPidAlive JSDoc comment
This commit is contained in:
Kai (Tam Nhu) Tran
2026-02-11 19:21:31 +07:00
committed by GitHub
parent cfa207e0b6
commit e055dac199
5 changed files with 1047 additions and 1 deletions
+376
View File
@@ -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<string, CLIProxyProvider[]> {
const registry = loadAccountsRegistry();
// Build email -> providers mapping (only Google OAuth providers)
const emailProviders = new Map<string, CLIProxyProvider[]>();
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<string, CLIProxyProvider[]>();
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 <provider> --pause <account>"');
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<string>();
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;
}
+20 -1
View File
@@ -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;
}
+20
View File
@@ -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);
+10
View File
@@ -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) {
+621
View File
@@ -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<string, unknown>): 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);
});
});