mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 02:11:28 +00:00
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:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user