Merge pull request #338 from kaitranntt/kai/fix/pause-account-cliproxy-usage

fix(cliproxy): move token files when pausing/resuming accounts
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-01-15 13:06:50 -05:00
commit 126b244b36
3 files changed
+114 -8

No files matched your search

+90 -4
View File
@@ -6,6 +6,7 @@
*
* Account storage: ~/.ccs/cliproxy/accounts.json
* Token storage: ~/.ccs/cliproxy/auth/ (flat structure, CLIProxyAPI discovers by type field)
* Paused tokens: ~/.ccs/cliproxy/auth-paused/ (sibling dir, outside CLIProxyAPI scan path)
*/
import * as fs from 'fs';
@@ -137,6 +138,19 @@ export function getAccountsRegistryPath(): string {
return path.join(getCliproxyDir(), 'accounts.json');
}
/**
* Get path to paused tokens directory
* Paused tokens are moved here so CLIProxyAPI won't discover them
*
* Uses sibling directory (auth-paused/) instead of subdirectory (auth/paused/)
* because CLIProxyAPI's watcher uses filepath.Walk() which recursively scans
* all subdirectories of auth/. A sibling directory is completely outside
* CLIProxyAPI's scan path, preventing token refresh loops.
*/
export function getPausedDir(): string {
return path.join(getCliproxyDir(), 'auth-paused');
}
/**
* Load accounts registry
*/
@@ -178,10 +192,12 @@ export function saveAccountsRegistry(registry: AccountsRegistry): void {
/**
* Sync registry with actual token files
* Removes stale entries where token file no longer exists
* For paused accounts, checks both auth/ and paused/ directories
* Called automatically when loading accounts
*/
function syncRegistryWithTokenFiles(registry: AccountsRegistry): boolean {
const authDir = getAuthDir();
const pausedDir = getPausedDir();
let modified = false;
for (const [_providerName, providerAccounts] of Object.entries(registry.providers)) {
@@ -191,7 +207,14 @@ function syncRegistryWithTokenFiles(registry: AccountsRegistry): boolean {
for (const [accountId, meta] of Object.entries(providerAccounts.accounts)) {
const tokenPath = path.join(authDir, meta.tokenFile);
if (!fs.existsSync(tokenPath)) {
const pausedPath = path.join(pausedDir, meta.tokenFile);
// For paused accounts, check paused dir; for active accounts, check auth dir
const expectedPath = meta.paused ? pausedPath : tokenPath;
// Also accept if file exists in either location (handles edge cases)
const existsAnywhere = fs.existsSync(tokenPath) || fs.existsSync(pausedPath);
if (!fs.existsSync(expectedPath) && !existsAnywhere) {
staleIds.push(accountId);
}
}
@@ -406,6 +429,7 @@ export function setDefaultAccount(provider: CLIProxyProvider, accountId: string)
/**
* Pause an account (skip in quota rotation)
* Moves token file to paused/ subdir so CLIProxyAPI won't discover it
*/
export function pauseAccount(provider: CLIProxyProvider, accountId: string): boolean {
const registry = loadAccountsRegistry();
@@ -415,6 +439,32 @@ export function pauseAccount(provider: CLIProxyProvider, accountId: string): boo
return false;
}
const accountMeta = providerAccounts.accounts[accountId];
// Skip if already paused (idempotent)
if (accountMeta.paused) {
return true;
}
const authDir = getAuthDir();
const pausedDir = getPausedDir();
const tokenPath = path.join(authDir, accountMeta.tokenFile);
const pausedPath = path.join(pausedDir, accountMeta.tokenFile);
// Move token file to paused directory (if it exists in auth dir)
if (fs.existsSync(tokenPath)) {
try {
// Create paused directory if it doesn't exist
if (!fs.existsSync(pausedDir)) {
fs.mkdirSync(pausedDir, { recursive: true, mode: 0o700 });
}
fs.renameSync(tokenPath, pausedPath);
} catch {
// File operation failed, but continue with registry update
// syncRegistryWithTokenFiles() will handle recovery on next load
}
}
providerAccounts.accounts[accountId].paused = true;
providerAccounts.accounts[accountId].pausedAt = new Date().toISOString();
saveAccountsRegistry(registry);
@@ -423,6 +473,7 @@ export function pauseAccount(provider: CLIProxyProvider, accountId: string): boo
/**
* Resume a paused account
* Moves token file back from paused/ to auth/ so CLIProxyAPI can discover it
*/
export function resumeAccount(provider: CLIProxyProvider, accountId: string): boolean {
const registry = loadAccountsRegistry();
@@ -432,6 +483,28 @@ export function resumeAccount(provider: CLIProxyProvider, accountId: string): bo
return false;
}
const accountMeta = providerAccounts.accounts[accountId];
// Skip if already active (idempotent)
if (!accountMeta.paused) {
return true;
}
const authDir = getAuthDir();
const pausedDir = getPausedDir();
const tokenPath = path.join(authDir, accountMeta.tokenFile);
const pausedPath = path.join(pausedDir, accountMeta.tokenFile);
// Move token file back from paused directory (if it exists in paused dir)
if (fs.existsSync(pausedPath)) {
try {
fs.renameSync(pausedPath, tokenPath);
} catch {
// File operation failed, but continue with registry update
// syncRegistryWithTokenFiles() will handle recovery on next load
}
}
providerAccounts.accounts[accountId].paused = false;
providerAccounts.accounts[accountId].pausedAt = undefined;
saveAccountsRegistry(registry);
@@ -485,11 +558,12 @@ export function removeAccount(provider: CLIProxyProvider, accountId: string): bo
return false;
}
// Get token file to delete
// Get token file to delete (check both auth and paused directories)
const tokenFile = providerAccounts.accounts[accountId].tokenFile;
const tokenPath = path.join(getAuthDir(), tokenFile);
const pausedPath = path.join(getPausedDir(), tokenFile);
// Delete token file
// Delete token file from auth directory
if (fs.existsSync(tokenPath)) {
try {
fs.unlinkSync(tokenPath);
@@ -498,6 +572,15 @@ export function removeAccount(provider: CLIProxyProvider, accountId: string): bo
}
}
// Also delete from paused directory if it exists there
if (fs.existsSync(pausedPath)) {
try {
fs.unlinkSync(pausedPath);
} catch {
// Ignore deletion errors
}
}
// Remove from registry
delete providerAccounts.accounts[accountId];
@@ -558,6 +641,7 @@ export function touchAccount(provider: CLIProxyProvider, accountId: string): voi
/**
* Get token file path for an account
* Returns path in paused/ dir if account is paused, otherwise auth/
*/
export function getAccountTokenPath(provider: CLIProxyProvider, accountId?: string): string | null {
const account = accountId ? getAccount(provider, accountId) : getDefaultAccount(provider);
@@ -566,7 +650,9 @@ export function getAccountTokenPath(provider: CLIProxyProvider, accountId?: stri
return null;
}
return path.join(getAuthDir(), account.tokenFile);
// Return path from paused directory if account is paused
const baseDir = account.paused ? getPausedDir() : getAuthDir();
return path.join(baseDir, account.tokenFile);
}
/**
+13
View File
@@ -11,6 +11,7 @@ import { getAuthDir } from './config-generator';
import { CLIProxyProvider } from './types';
import {
getProviderAccounts,
isAccountPaused,
setAccountTier,
type AccountInfo,
type AccountTier,
@@ -553,6 +554,18 @@ export async function fetchAccountQuota(
};
}
// Check if account is paused (token moved to auth-paused/ directory)
if (isAccountPaused(provider, accountId)) {
const error = 'Account is paused';
if (verbose) console.error(`[i] ${error}`);
return {
success: false,
models: [],
lastUpdated: Date.now(),
error,
};
}
// Read auth data from auth file
const authData = readAuthData(provider, accountId);
if (!authData) {
@@ -10,7 +10,7 @@ import {
getMinClaudeQuota,
} from '@/lib/utils';
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
import { GripVertical, Loader2, Clock } from 'lucide-react';
import { GripVertical, Loader2, Clock, Pause } from 'lucide-react';
import { useAccountQuota } from '@/hooks/use-cliproxy-stats';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
@@ -202,9 +202,16 @@ export function AccountCard({
</Tooltip>
</TooltipProvider>
) : quota?.error ? (
<div className="text-[8px] text-muted-foreground/60 truncate" title={quota.error}>
{quota.error.length > 20 ? `${quota.error.slice(0, 18)}...` : quota.error}
</div>
quota.error === 'Account is paused' ? (
<div className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400">
<Pause className="w-3 h-3" />
<span className="text-[9px] font-medium uppercase tracking-wide">Paused</span>
</div>
) : (
<div className="text-[8px] text-muted-foreground/60 truncate" title={quota.error}>
{quota.error.length > 20 ? `${quota.error.slice(0, 18)}...` : quota.error}
</div>
)
) : null}
</div>
)}