mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 12:16:38 +00:00
Merge remote-tracking branch 'origin/dev' into kai/feat/520-cursor-daemon-cli
# Conflicts: # src/cursor/cursor-auth.ts # src/cursor/cursor-executor.ts # src/cursor/cursor-protobuf-decoder.ts # src/cursor/cursor-protobuf-encoder.ts # src/cursor/cursor-protobuf-schema.ts # src/cursor/cursor-protobuf.ts # src/cursor/cursor-translator.ts # src/cursor/types.ts
This commit is contained in:
commit
83894f43c6
39 files changed
+3580
-393
No files matched your search
@@ -33,6 +33,7 @@ pnpm-lock.yaml
|
||||
package-lock.json
|
||||
|
||||
.claude/active-plan
|
||||
.claude/agent-memory/
|
||||
|
||||
# Logs directory
|
||||
logs/
|
||||
|
||||
@@ -1,3 +1,26 @@
|
||||
## [7.43.0](https://github.com/kaitranntt/ccs/compare/v7.42.0...v7.43.0) (2026-02-11)
|
||||
|
||||
### Features
|
||||
|
||||
* **cliproxy:** add account safety guards to prevent Google account bans ([#516](https://github.com/kaitranntt/ccs/issues/516)) ([e055dac](https://github.com/kaitranntt/ccs/commit/e055dac1996bd3cd3c4e5ee0f11dad22d8d2a838)), closes [#509](https://github.com/kaitranntt/ccs/issues/509) [#512](https://github.com/kaitranntt/ccs/issues/512)
|
||||
* **cliproxy:** runtime quota monitoring during active sessions ([#529](https://github.com/kaitranntt/ccs/issues/529)) ([c6c94a0](https://github.com/kaitranntt/ccs/commit/c6c94a0c1e7bf82dd56295a76d833d7d52694718)), closes [#524](https://github.com/kaitranntt/ccs/issues/524)
|
||||
* **glm:** update default model to GLM-5 and fix all GLM pricing ([3e26dee](https://github.com/kaitranntt/ccs/commit/3e26dee0134fa576a57f216f232a0215084e74a3)), closes [#532](https://github.com/kaitranntt/ccs/issues/532)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **cliproxy:** add fork:true for Claude model aliases in config generator ([#523](https://github.com/kaitranntt/ccs/issues/523)) ([4065399](https://github.com/kaitranntt/ccs/commit/4065399d8aa46ccdb115081e461c5651d0afaa2e)), closes [#522](https://github.com/kaitranntt/ccs/issues/522)
|
||||
* **cliproxy:** address all review feedback (Low + informational) ([7d049d8](https://github.com/kaitranntt/ccs/commit/7d049d8f1e8655856a1a9636d21f6eb3992752a0))
|
||||
* **cliproxy:** mask email in ban detection and fix JSDoc default ([fcc605b](https://github.com/kaitranntt/ccs/commit/fcc605bc1f02af4da518d21c10f8c77b38a793ad))
|
||||
* **cliproxy:** migrate deprecated gemini-claude-* model names to upstream claude-* names ([#515](https://github.com/kaitranntt/ccs/issues/515)) ([6afbb72](https://github.com/kaitranntt/ccs/commit/6afbb72b472029358fc3d9b2fed488fd4779695b)), closes [#513](https://github.com/kaitranntt/ccs/issues/513)
|
||||
* **glm:** fix missed help text reference and glm-4.5-air pricing ([7d9c538](https://github.com/kaitranntt/ccs/commit/7d9c538248f93089ae6483af4ea1d01e555e2e20))
|
||||
* **hooks:** isolate image type check before error-prone processing ([#514](https://github.com/kaitranntt/ccs/issues/514)) ([19de427](https://github.com/kaitranntt/ccs/commit/19de42704f683a29134982dfb643e97c3123bf7c)), closes [#511](https://github.com/kaitranntt/ccs/issues/511)
|
||||
|
||||
## [7.42.0](https://github.com/kaitranntt/ccs/compare/v7.41.0...v7.42.0) (2026-02-11)
|
||||
|
||||
### Features
|
||||
|
||||
* account safety, quota monitoring, and stability fixes ([#530](https://github.com/kaitranntt/ccs/issues/530)) ([0518050](https://github.com/kaitranntt/ccs/commit/051805074eb80db839a4deb8ab1dcb89f29766de)), closes [#515](https://github.com/kaitranntt/ccs/issues/515) [#513](https://github.com/kaitranntt/ccs/issues/513) [#514](https://github.com/kaitranntt/ccs/issues/514) [#511](https://github.com/kaitranntt/ccs/issues/511) [#523](https://github.com/kaitranntt/ccs/issues/523) [#522](https://github.com/kaitranntt/ccs/issues/522) [#516](https://github.com/kaitranntt/ccs/issues/516) [#509](https://github.com/kaitranntt/ccs/issues/509) [#512](https://github.com/kaitranntt/ccs/issues/512) [#529](https://github.com/kaitranntt/ccs/issues/529) [#524](https://github.com/kaitranntt/ccs/issues/524)
|
||||
|
||||
## [7.41.0](https://github.com/kaitranntt/ccs/compare/v7.40.0...v7.41.0) (2026-02-11)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic",
|
||||
"ANTHROPIC_AUTH_TOKEN": "YOUR_GLM_API_KEY_HERE",
|
||||
"ANTHROPIC_MODEL": "glm-4.7",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "glm-4.7",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "glm-4.7",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "glm-4.7"
|
||||
"ANTHROPIC_MODEL": "glm-5",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "glm-5",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "glm-5",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "glm-5"
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,10 @@
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.z.ai/api/coding/paas/v4/chat/completions",
|
||||
"ANTHROPIC_AUTH_TOKEN": "YOUR_GLM_API_KEY_HERE",
|
||||
"ANTHROPIC_MODEL": "glm-4.7",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "glm-4.7",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "glm-4.7",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "glm-4.7",
|
||||
"ANTHROPIC_MODEL": "glm-5",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "glm-5",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "glm-5",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "glm-5",
|
||||
"ANTHROPIC_TEMPERATURE": "0.2",
|
||||
"ANTHROPIC_MAX_TOKENS": "65536",
|
||||
"MAX_THINKING_TOKENS": "32768",
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://ollama.com",
|
||||
"ANTHROPIC_AUTH_TOKEN": "YOUR_OLLAMA_CLOUD_API_KEY_HERE",
|
||||
"ANTHROPIC_MODEL": "glm-4.7:cloud",
|
||||
"ANTHROPIC_MODEL": "glm-5:cloud",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "qwen3-coder:480b",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "glm-4.7:cloud",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "glm-5:cloud",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "minimax-m2.1:cloud"
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kaitranntt/ccs",
|
||||
"version": "7.41.0-dev.1",
|
||||
"version": "7.43.0-dev.2",
|
||||
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
|
||||
"keywords": [
|
||||
"cli",
|
||||
|
||||
@@ -65,7 +65,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
||||
description: 'Claude via Z.AI',
|
||||
baseUrl: 'https://api.z.ai/api/anthropic',
|
||||
defaultProfileName: 'glm',
|
||||
defaultModel: 'glm-4.7',
|
||||
defaultModel: 'glm-5',
|
||||
apiKeyPlaceholder: 'ghp_...',
|
||||
apiKeyHint: 'Get your API key from Z.AI',
|
||||
category: 'alternative',
|
||||
@@ -77,7 +77,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
||||
description: 'GLM with Thinking mode support',
|
||||
baseUrl: 'https://api.z.ai/api/coding/paas/v4/chat/completions',
|
||||
defaultProfileName: 'glmt',
|
||||
defaultModel: 'glm-4.7',
|
||||
defaultModel: 'glm-5',
|
||||
apiKeyPlaceholder: 'ghp_...',
|
||||
apiKeyHint: 'Same API key as GLM',
|
||||
category: 'alternative',
|
||||
@@ -156,10 +156,10 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
||||
{
|
||||
id: 'ollama-cloud',
|
||||
name: 'Ollama Cloud',
|
||||
description: 'Ollama cloud models via direct API (glm-4.7:cloud, minimax-m2.1:cloud)',
|
||||
description: 'Ollama cloud models via direct API (glm-5:cloud, minimax-m2.1:cloud)',
|
||||
baseUrl: 'https://ollama.com',
|
||||
defaultProfileName: 'ollama-cloud',
|
||||
defaultModel: 'glm-4.7:cloud',
|
||||
defaultModel: 'glm-5:cloud',
|
||||
apiKeyPlaceholder: 'YOUR_OLLAMA_CLOUD_API_KEY',
|
||||
apiKeyHint: 'Get your API key at ollama.com',
|
||||
category: 'alternative',
|
||||
|
||||
+1
-1
@@ -162,7 +162,7 @@ async function execClaudeWithProxy(
|
||||
|
||||
// 4. Spawn Claude CLI with proxy URL
|
||||
// Use model from user's settings (not hardcoded) - fixes issue #358
|
||||
const configuredModel = envData['ANTHROPIC_MODEL'] || 'glm-4.7';
|
||||
const configuredModel = envData['ANTHROPIC_MODEL'] || 'glm-5';
|
||||
const envVars: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}`,
|
||||
ANTHROPIC_AUTH_TOKEN: apiKey,
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
/**
|
||||
* 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).
|
||||
// TOCTOU race is acceptable for a single-user CLI tool — self-heals on next launch.
|
||||
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 "${maskEmail(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;
|
||||
}
|
||||
|
||||
// --- Quota Exhaustion Handling ---
|
||||
|
||||
/**
|
||||
* Write boxed quota warning to stderr (20% threshold).
|
||||
* Uses process.stderr.write() to work alongside inherited stdio.
|
||||
* ASCII-only output (no emojis) per project constraints.
|
||||
*/
|
||||
export function writeQuotaWarning(accountId: string, quotaPercent: number): void {
|
||||
const masked = maskEmail(accountId);
|
||||
const lines = [
|
||||
`[!] Quota Low: ${masked} (${Math.round(quotaPercent)}% remaining)`,
|
||||
` Next session will use a different account if available`,
|
||||
];
|
||||
const maxLen = Math.max(...lines.map((l) => l.length));
|
||||
const border = '\u2550'.repeat(maxLen + 2);
|
||||
|
||||
process.stderr.write('\n');
|
||||
process.stderr.write(`\u2554${border}\u2557\n`);
|
||||
for (const line of lines) {
|
||||
process.stderr.write(`\u2551 ${line.padEnd(maxLen)} \u2551\n`);
|
||||
}
|
||||
process.stderr.write(`\u255A${border}\u255D\n`);
|
||||
process.stderr.write('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Write boxed quota exhaustion alert to stderr.
|
||||
* Called when quota falls below exhaustion_threshold — account will be cooled down.
|
||||
*/
|
||||
function writeQuotaExhausted(
|
||||
accountId: string,
|
||||
switchedTo: string | null,
|
||||
cooldownMinutes: number
|
||||
): void {
|
||||
const masked = maskEmail(accountId);
|
||||
const lines = [`[X] Quota Exhausted: ${masked}`, ` Cooldown: ${cooldownMinutes} minutes`];
|
||||
if (switchedTo) {
|
||||
lines.push(` Next session default: ${maskEmail(switchedTo)}`);
|
||||
} else {
|
||||
lines.push(` No alternative accounts available`);
|
||||
}
|
||||
|
||||
const maxLen = Math.max(...lines.map((l) => l.length));
|
||||
const border = '\u2550'.repeat(maxLen + 2);
|
||||
|
||||
process.stderr.write('\n');
|
||||
process.stderr.write(`\u2554${border}\u2557\n`);
|
||||
for (const line of lines) {
|
||||
process.stderr.write(`\u2551 ${line.padEnd(maxLen)} \u2551\n`);
|
||||
}
|
||||
process.stderr.write(`\u255A${border}\u255D\n`);
|
||||
process.stderr.write('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle quota exhaustion for an active session.
|
||||
* Applies cooldown to exhausted account, finds healthy alternative,
|
||||
* switches default, and alerts user via stderr.
|
||||
*
|
||||
* @returns switchedTo account ID or null if no alternatives
|
||||
*/
|
||||
export async function handleQuotaExhaustion(
|
||||
provider: CLIProxyProvider,
|
||||
accountId: string,
|
||||
cooldownMinutes: number
|
||||
): Promise<{ switchedTo: string | null; reason: string }> {
|
||||
// Dynamic imports to avoid circular dependencies
|
||||
const { applyCooldown, findHealthyAccount } = await import('./quota-manager');
|
||||
const { setDefaultAccount, touchAccount } = await import('./account-manager');
|
||||
|
||||
// Apply cooldown to exhausted account
|
||||
applyCooldown(provider, accountId, cooldownMinutes);
|
||||
|
||||
// Find healthy alternative
|
||||
const alternative = await findHealthyAccount(provider, [accountId]);
|
||||
|
||||
if (alternative) {
|
||||
setDefaultAccount(provider, alternative.id);
|
||||
touchAccount(provider, alternative.id);
|
||||
writeQuotaExhausted(accountId, alternative.id, cooldownMinutes);
|
||||
return {
|
||||
switchedTo: alternative.id,
|
||||
reason: `Quota exhausted, switched to ${maskEmail(alternative.id)}`,
|
||||
};
|
||||
}
|
||||
|
||||
// No alternatives — warn but continue (graceful degradation)
|
||||
writeQuotaExhausted(accountId, null, cooldownMinutes);
|
||||
return {
|
||||
switchedTo: null,
|
||||
reason: 'Quota exhausted, no alternatives available',
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,23 +26,24 @@ export const CCS_CONTROL_PANEL_SECRET = 'ccs';
|
||||
* v4: Added Kiro (AWS) and GitHub Copilot providers
|
||||
* v5: Added disable-cooling: true for stability
|
||||
* v6: Added oauth-model-alias with Opus 4.6 support
|
||||
* v7: Added fork:true for Claude model aliases (keep both upstream and alias names)
|
||||
*/
|
||||
export const CLIPROXY_CONFIG_VERSION = 6;
|
||||
export const CLIPROXY_CONFIG_VERSION = 7;
|
||||
|
||||
/**
|
||||
* Default Antigravity oauth-model-alias entries.
|
||||
* Maps user-facing model names to Antigravity internal model names.
|
||||
* Must stay in sync with CLIProxyAPIPlus defaultAntigravityAliases().
|
||||
*/
|
||||
const DEFAULT_ANTIGRAVITY_ALIASES: Array<{ name: string; alias: string }> = [
|
||||
const DEFAULT_ANTIGRAVITY_ALIASES: Array<{ name: string; alias: string; fork?: boolean }> = [
|
||||
{ name: 'rev19-uic3-1p', alias: 'gemini-2.5-computer-use-preview-10-2025' },
|
||||
{ name: 'gemini-3-pro-image', alias: 'gemini-3-pro-image-preview' },
|
||||
{ name: 'gemini-3-pro-high', alias: 'gemini-3-pro-preview' },
|
||||
{ name: 'gemini-3-flash', alias: 'gemini-3-flash-preview' },
|
||||
{ name: 'claude-sonnet-4-5', alias: 'gemini-claude-sonnet-4-5' },
|
||||
{ name: 'claude-sonnet-4-5-thinking', alias: 'gemini-claude-sonnet-4-5-thinking' },
|
||||
{ name: 'claude-opus-4-5-thinking', alias: 'gemini-claude-opus-4-5-thinking' },
|
||||
{ name: 'claude-opus-4-6-thinking', alias: 'gemini-claude-opus-4-6-thinking' },
|
||||
{ name: 'claude-sonnet-4-5', alias: 'gemini-claude-sonnet-4-5', fork: true },
|
||||
{ name: 'claude-sonnet-4-5-thinking', alias: 'gemini-claude-sonnet-4-5-thinking', fork: true },
|
||||
{ name: 'claude-opus-4-5-thinking', alias: 'gemini-claude-opus-4-5-thinking', fork: true },
|
||||
{ name: 'claude-opus-4-6-thinking', alias: 'gemini-claude-opus-4-6-thinking', fork: true },
|
||||
];
|
||||
|
||||
/** Provider display names (static metadata) */
|
||||
@@ -103,21 +104,44 @@ function generateOAuthModelAliasSection(existingAliases?: string): string {
|
||||
const existingNames = new Set(aliasEntries.map((a) => a.name));
|
||||
const lines = existingAliases.split('\n');
|
||||
let currentName = '';
|
||||
let currentAlias = '';
|
||||
let currentFork = false;
|
||||
for (const line of lines) {
|
||||
const nameMatch = line.match(/^\s+-\s*name:\s*(.+)/);
|
||||
const aliasMatch = line.match(/^\s+alias:\s*(.+)/);
|
||||
const forkMatch = line.match(/^\s+fork:\s*(.+)/);
|
||||
if (nameMatch) {
|
||||
// Flush previous entry if complete
|
||||
if (currentName && currentAlias && !existingNames.has(currentName)) {
|
||||
aliasEntries.push({
|
||||
name: currentName,
|
||||
alias: currentAlias,
|
||||
fork: currentFork || undefined,
|
||||
});
|
||||
existingNames.add(currentName);
|
||||
}
|
||||
currentName = nameMatch[1].trim();
|
||||
} else if (aliasMatch && currentName && !existingNames.has(currentName)) {
|
||||
aliasEntries.push({ name: currentName, alias: aliasMatch[1].trim() });
|
||||
existingNames.add(currentName);
|
||||
currentName = '';
|
||||
currentAlias = '';
|
||||
currentFork = false;
|
||||
} else if (aliasMatch) {
|
||||
currentAlias = aliasMatch[1].trim();
|
||||
} else if (forkMatch) {
|
||||
currentFork = forkMatch[1].trim().toLowerCase() === 'true';
|
||||
}
|
||||
}
|
||||
// Flush last entry
|
||||
if (currentName && currentAlias && !existingNames.has(currentName)) {
|
||||
aliasEntries.push({ name: currentName, alias: currentAlias, fork: currentFork || undefined });
|
||||
existingNames.add(currentName);
|
||||
}
|
||||
}
|
||||
|
||||
const entries = aliasEntries
|
||||
.map((a) => ` - name: ${a.name}\n alias: ${a.alias}`)
|
||||
.map((a) => {
|
||||
let entry = ` - name: ${a.name}\n alias: ${a.alias}`;
|
||||
if (a.fork) entry += '\n fork: true';
|
||||
return entry;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return `oauth-model-alias:\n antigravity:\n${entries}`;
|
||||
|
||||
@@ -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,21 @@ 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 {
|
||||
// 'exit' handlers must be synchronous — restoreAutoPausedAccounts uses sync fs APIs
|
||||
process.on('exit', () => {
|
||||
restoreAutoPausedAccounts(provider);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 4. First-run model configuration
|
||||
if (supportsModelConfig(provider) && !skipLocalAuth) {
|
||||
await configureProviderModel(provider, false, cfg.customSettingsPath);
|
||||
@@ -773,6 +794,15 @@ export async function execClaudeWithCLIProxy(
|
||||
});
|
||||
}
|
||||
|
||||
// 12b. Start runtime quota monitor (adaptive polling during session)
|
||||
if (!skipLocalAuth) {
|
||||
const { startQuotaMonitor } = await import('../quota-manager');
|
||||
const monitorAccount = getDefaultAccount(provider);
|
||||
if (monitorAccount) {
|
||||
startQuotaMonitor(provider, monitorAccount.id);
|
||||
}
|
||||
}
|
||||
|
||||
// 13. Setup cleanup handlers
|
||||
setupCleanupHandlers(
|
||||
claude,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { detectRunningProxy, waitForProxyHealthy, reclaimOrphanedProxy } from '../proxy-detector';
|
||||
import { withStartupLock } from '../startup-lock';
|
||||
import { killProcessOnPort } from '../../utils/platform-commands';
|
||||
import { stopQuotaMonitor } from '../quota-manager';
|
||||
|
||||
export interface ProxySessionResult {
|
||||
sessionId?: string;
|
||||
@@ -184,6 +185,7 @@ export function setupCleanupHandlers(
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
stopQuotaMonitor();
|
||||
log('Parent signal received, cleaning up');
|
||||
|
||||
if (
|
||||
@@ -214,6 +216,7 @@ export function setupCleanupHandlers(
|
||||
};
|
||||
|
||||
claude.on('exit', (code, signal) => {
|
||||
stopQuotaMonitor();
|
||||
log(`Claude exited: code=${code}, signal=${signal}`);
|
||||
|
||||
if (
|
||||
@@ -250,6 +253,7 @@ export function setupCleanupHandlers(
|
||||
});
|
||||
|
||||
claude.on('error', (error) => {
|
||||
stopQuotaMonitor();
|
||||
console.error(require('../../utils/ui').fail(`Claude CLI error: ${error}`));
|
||||
|
||||
if (
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
type AccountInfo,
|
||||
} from './account-manager';
|
||||
import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
|
||||
import type { RuntimeMonitorConfig } from '../config/unified-config-types';
|
||||
|
||||
// ============================================================================
|
||||
// QUOTA CACHE (30-second TTL)
|
||||
@@ -416,3 +417,137 @@ export async function getQuotaStatus(provider: CLIProxyProvider): Promise<{
|
||||
|
||||
return { accounts: results };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// RUNTIME QUOTA MONITOR (adaptive polling during active sessions)
|
||||
// ============================================================================
|
||||
|
||||
/** Active monitor timer (null = not running) */
|
||||
let monitorTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/** Tracks if warning was shown this session (avoid spam) */
|
||||
let hasWarnedThisSession = false;
|
||||
|
||||
/** Guards against in-flight poll callbacks running after stop */
|
||||
let monitorStopped = false;
|
||||
|
||||
/**
|
||||
* Schedule next quota poll with adaptive interval.
|
||||
* Uses setTimeout chain (not setInterval) for dynamic interval switching.
|
||||
*/
|
||||
function scheduleNextPoll(
|
||||
provider: CLIProxyProvider,
|
||||
accountId: string,
|
||||
monitorConfig: RuntimeMonitorConfig,
|
||||
intervalMs: number
|
||||
): void {
|
||||
monitorTimer = setTimeout(async () => {
|
||||
// Guard: skip if monitor was stopped while this callback was queued
|
||||
if (monitorStopped) return;
|
||||
|
||||
try {
|
||||
const quota = await fetchQuotaWithDedup(provider, accountId);
|
||||
if (monitorStopped) return; // Re-check after async fetch
|
||||
const avgQuota = calculateAverageQuota(quota) ?? 100;
|
||||
|
||||
if (avgQuota <= monitorConfig.exhaustion_threshold) {
|
||||
// EXHAUSTED: cooldown + switch default + stop monitoring.
|
||||
// NOTE: Monitor stops here intentionally. The current session continues
|
||||
// on the exhausted account (can't hot-swap mid-session). The switched
|
||||
// default only takes effect on next session start via preflightCheck().
|
||||
const { handleQuotaExhaustion } = await import('./account-safety');
|
||||
await handleQuotaExhaustion(provider, accountId, monitorConfig.cooldown_minutes);
|
||||
monitorTimer = null;
|
||||
return; // Stop polling
|
||||
}
|
||||
|
||||
if (avgQuota <= monitorConfig.warn_threshold) {
|
||||
// WARNING: switch to critical interval, warn once
|
||||
if (!hasWarnedThisSession) {
|
||||
const { writeQuotaWarning } = await import('./account-safety');
|
||||
writeQuotaWarning(accountId, avgQuota);
|
||||
hasWarnedThisSession = true;
|
||||
}
|
||||
scheduleNextPoll(
|
||||
provider,
|
||||
accountId,
|
||||
monitorConfig,
|
||||
monitorConfig.critical_interval_seconds * 1000
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// HEALTHY: keep normal interval
|
||||
scheduleNextPoll(
|
||||
provider,
|
||||
accountId,
|
||||
monitorConfig,
|
||||
monitorConfig.normal_interval_seconds * 1000
|
||||
);
|
||||
} catch {
|
||||
// API failure: silently reschedule at same interval
|
||||
scheduleNextPoll(provider, accountId, monitorConfig, intervalMs);
|
||||
}
|
||||
}, intervalMs);
|
||||
|
||||
// Prevent monitor from keeping Node.js process alive
|
||||
if (monitorTimer && typeof monitorTimer === 'object' && 'unref' in monitorTimer) {
|
||||
monitorTimer.unref();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start adaptive quota monitor for an active session.
|
||||
* Polls at normal_interval (300s) when healthy, switches to
|
||||
* critical_interval (60s) when quota hits warn_threshold (20%).
|
||||
* Auto-stops on exhaustion or when stopQuotaMonitor() is called.
|
||||
*
|
||||
* Only monitors 'agy' provider (only one with quota API).
|
||||
* No-op for other providers, manual mode, or if disabled in config.
|
||||
*/
|
||||
export function startQuotaMonitor(provider: CLIProxyProvider, accountId: string): void {
|
||||
// Only Antigravity supports quota
|
||||
if (provider !== 'agy') return;
|
||||
|
||||
// Prevent duplicate monitors
|
||||
if (monitorTimer) return;
|
||||
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
const quotaConfig = config.quota_management;
|
||||
|
||||
// Skip if config missing (shouldn't happen with defaults)
|
||||
if (!quotaConfig) return;
|
||||
|
||||
// Skip if manual mode or runtime monitor disabled
|
||||
if (quotaConfig.mode === 'manual') return;
|
||||
if (!quotaConfig.runtime_monitor?.enabled) return;
|
||||
|
||||
// Validate thresholds: warn must be > exhaustion to avoid immediate exhaustion on warning
|
||||
const monitorConfig = quotaConfig.runtime_monitor;
|
||||
if (monitorConfig.warn_threshold <= monitorConfig.exhaustion_threshold) {
|
||||
return; // Invalid config — skip monitoring silently (logged at config level)
|
||||
}
|
||||
|
||||
hasWarnedThisSession = false;
|
||||
monitorStopped = false;
|
||||
|
||||
// Start first poll at normal interval
|
||||
scheduleNextPoll(
|
||||
provider,
|
||||
accountId,
|
||||
quotaConfig.runtime_monitor,
|
||||
quotaConfig.runtime_monitor.normal_interval_seconds * 1000
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the runtime quota monitor. Safe to call multiple times.
|
||||
*/
|
||||
export function stopQuotaMonitor(): void {
|
||||
monitorStopped = true;
|
||||
if (monitorTimer) {
|
||||
clearTimeout(monitorTimer);
|
||||
monitorTimer = null;
|
||||
}
|
||||
hasWarnedThisSession = false;
|
||||
}
|
||||
@@ -463,7 +463,7 @@ async function showHelp(): Promise<void> {
|
||||
` ${color('ollama', 'command')} Ollama - Local open-source models (no API key)`
|
||||
);
|
||||
console.log(
|
||||
` ${color('ollama-cloud', 'command')} Ollama Cloud - glm-4.7:cloud, qwen3-coder:480b`
|
||||
` ${color('ollama-cloud', 'command')} Ollama Cloud - glm-5:cloud, qwen3-coder:480b`
|
||||
);
|
||||
console.log(` ${color('glm', 'command')} GLM - Claude via Z.AI`);
|
||||
console.log(` ${color('glmt', 'command')} GLMT - GLM with Thinking mode`);
|
||||
|
||||
@@ -129,7 +129,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
|
||||
[`Configure in ${dirDisplay}/*.settings.json`],
|
||||
[
|
||||
['ccs', 'Use default Claude account'],
|
||||
['ccs glm', 'GLM 4.6 (API key required)'],
|
||||
['ccs glm', 'GLM 5 (API key required)'],
|
||||
['ccs glmt', 'GLM with thinking mode'],
|
||||
['ccs kimi', 'Kimi for Coding (API key)'],
|
||||
['ccs ollama', 'Local Ollama (http://localhost:11434)'],
|
||||
@@ -227,7 +227,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
|
||||
// Delegation
|
||||
printSubSection('Delegation (inside Claude Code CLI)', [
|
||||
['/ccs "task"', 'Delegate task (auto-selects profile)'],
|
||||
['/ccs --glm "task"', 'Force GLM-4.6 for simple tasks'],
|
||||
['/ccs --glm "task"', 'Force GLM-5 for simple tasks'],
|
||||
['/ccs --kimi "task"', 'Force Kimi for long context'],
|
||||
['/ccs:continue "follow-up"', 'Continue last delegation session'],
|
||||
]);
|
||||
|
||||
@@ -341,6 +341,26 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
|
||||
partial.quota_management?.manual?.tier_lock ??
|
||||
DEFAULT_QUOTA_MANAGEMENT_CONFIG.manual.tier_lock,
|
||||
},
|
||||
runtime_monitor: {
|
||||
enabled:
|
||||
partial.quota_management?.runtime_monitor?.enabled ??
|
||||
DEFAULT_QUOTA_MANAGEMENT_CONFIG.runtime_monitor.enabled,
|
||||
normal_interval_seconds:
|
||||
partial.quota_management?.runtime_monitor?.normal_interval_seconds ??
|
||||
DEFAULT_QUOTA_MANAGEMENT_CONFIG.runtime_monitor.normal_interval_seconds,
|
||||
critical_interval_seconds:
|
||||
partial.quota_management?.runtime_monitor?.critical_interval_seconds ??
|
||||
DEFAULT_QUOTA_MANAGEMENT_CONFIG.runtime_monitor.critical_interval_seconds,
|
||||
warn_threshold:
|
||||
partial.quota_management?.runtime_monitor?.warn_threshold ??
|
||||
DEFAULT_QUOTA_MANAGEMENT_CONFIG.runtime_monitor.warn_threshold,
|
||||
exhaustion_threshold:
|
||||
partial.quota_management?.runtime_monitor?.exhaustion_threshold ??
|
||||
DEFAULT_QUOTA_MANAGEMENT_CONFIG.runtime_monitor.exhaustion_threshold,
|
||||
cooldown_minutes:
|
||||
partial.quota_management?.runtime_monitor?.cooldown_minutes ??
|
||||
DEFAULT_QUOTA_MANAGEMENT_CONFIG.runtime_monitor.cooldown_minutes,
|
||||
},
|
||||
},
|
||||
// Thinking config - auto/manual/off control for reasoning budget
|
||||
thinking: {
|
||||
|
||||
@@ -369,6 +369,25 @@ export interface AutoQuotaConfig {
|
||||
cooldown_minutes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime quota monitor configuration.
|
||||
* Controls adaptive polling during active sessions.
|
||||
*/
|
||||
export interface RuntimeMonitorConfig {
|
||||
/** Enable runtime monitoring during sessions (default: true) */
|
||||
enabled: boolean;
|
||||
/** Poll interval in seconds when quota > warn_threshold (default: 300) */
|
||||
normal_interval_seconds: number;
|
||||
/** Poll interval in seconds when quota <= warn_threshold (default: 60) */
|
||||
critical_interval_seconds: number;
|
||||
/** Quota percentage that triggers fast polling + warning (default: 20) */
|
||||
warn_threshold: number;
|
||||
/** Quota percentage that triggers cooldown + switch (default: 5) */
|
||||
exhaustion_threshold: number;
|
||||
/** Minutes to cooldown exhausted account (default: 5) */
|
||||
cooldown_minutes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual quota management configuration.
|
||||
* User-controlled overrides for account selection.
|
||||
@@ -401,6 +420,8 @@ export interface QuotaManagementConfig {
|
||||
auto: AutoQuotaConfig;
|
||||
/** Manual mode settings */
|
||||
manual: ManualQuotaConfig;
|
||||
/** Runtime monitor settings */
|
||||
runtime_monitor: RuntimeMonitorConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -422,6 +443,18 @@ export const DEFAULT_MANUAL_QUOTA_CONFIG: ManualQuotaConfig = {
|
||||
tier_lock: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Default runtime monitor configuration.
|
||||
*/
|
||||
export const DEFAULT_RUNTIME_MONITOR_CONFIG: RuntimeMonitorConfig = {
|
||||
enabled: true,
|
||||
normal_interval_seconds: 300,
|
||||
critical_interval_seconds: 60,
|
||||
warn_threshold: 20,
|
||||
exhaustion_threshold: 5,
|
||||
cooldown_minutes: 5,
|
||||
};
|
||||
|
||||
/**
|
||||
* Default quota management configuration.
|
||||
*/
|
||||
@@ -429,6 +462,7 @@ export const DEFAULT_QUOTA_MANAGEMENT_CONFIG: QuotaManagementConfig = {
|
||||
mode: 'hybrid',
|
||||
auto: { ...DEFAULT_AUTO_QUOTA_CONFIG },
|
||||
manual: { ...DEFAULT_MANUAL_QUOTA_CONFIG },
|
||||
runtime_monitor: { ...DEFAULT_RUNTIME_MONITOR_CONFIG },
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
|
||||
+91
-23
@@ -14,7 +14,7 @@
|
||||
* - storage.serviceMachineId: Machine ID for checksum
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import { execFileSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
@@ -52,12 +52,20 @@ export function getTokenStoragePath(): string {
|
||||
*/
|
||||
function queryStateDb(dbPath: string, key: string): string | null {
|
||||
try {
|
||||
const result = execSync(
|
||||
`sqlite3 "${dbPath}" "SELECT value FROM itemTable WHERE key='${key}'" 2>/dev/null`,
|
||||
{ encoding: 'utf8', timeout: 5000 }
|
||||
// Escape single quotes to prevent SQL injection
|
||||
const sanitizedKey = key.replace(/'/g, "''");
|
||||
const result = execFileSync(
|
||||
'sqlite3',
|
||||
[dbPath, `SELECT value FROM itemTable WHERE key='${sanitizedKey}'`],
|
||||
{ encoding: 'utf8', timeout: 5000, stdio: ['pipe', 'pipe', 'ignore'] }
|
||||
).trim();
|
||||
return result || null;
|
||||
} catch {
|
||||
} catch (err) {
|
||||
// Check if sqlite3 is not installed
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
// sqlite3 not found - could log this if needed
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -66,6 +74,15 @@ function queryStateDb(dbPath: string, key: string): string | null {
|
||||
* Auto-detect tokens from Cursor's SQLite database
|
||||
*/
|
||||
export function autoDetectTokens(): AutoDetectResult {
|
||||
// sqlite3 CLI is not bundled with Windows
|
||||
if (process.platform === 'win32') {
|
||||
return {
|
||||
found: false,
|
||||
error:
|
||||
'Auto-detection is not supported on Windows. Please import tokens manually using ccs cursor auth --manual.',
|
||||
};
|
||||
}
|
||||
|
||||
const dbPath = getTokenStoragePath();
|
||||
|
||||
// Check if database exists
|
||||
@@ -120,9 +137,9 @@ export function validateToken(accessToken: string, machineId: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Machine ID format validation (should be UUID-like)
|
||||
const uuidRegex = /^[a-f0-9-]{32,}$/i;
|
||||
if (!uuidRegex.test(machineId.replace(/-/g, ''))) {
|
||||
// Machine ID format validation (UUID without hyphens = exactly 32 hex chars)
|
||||
const hexRegex = /^[a-f0-9]{32}$/i;
|
||||
if (!hexRegex.test(machineId.replace(/-/g, ''))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -133,7 +150,9 @@ export function validateToken(accessToken: string, machineId: string): boolean {
|
||||
* Extract user info from token if possible
|
||||
* Cursor tokens may contain encoded user info as JWT
|
||||
*/
|
||||
export function extractUserInfo(accessToken: string): { email?: string; userId?: string } | null {
|
||||
export function extractUserInfo(
|
||||
accessToken: string
|
||||
): { email?: string; userId?: string; exp?: number } | null {
|
||||
try {
|
||||
// Try to decode as JWT
|
||||
const parts = accessToken.split('.');
|
||||
@@ -145,11 +164,21 @@ export function extractUserInfo(accessToken: string): { email?: string; userId?:
|
||||
}
|
||||
const decoded = JSON.parse(
|
||||
Buffer.from(payload.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString()
|
||||
);
|
||||
return {
|
||||
email: decoded.email || decoded.sub,
|
||||
userId: decoded.sub || decoded.user_id,
|
||||
};
|
||||
) as Record<string, unknown>;
|
||||
|
||||
const email = typeof decoded.email === 'string' ? decoded.email : undefined;
|
||||
const userId =
|
||||
typeof decoded.sub === 'string'
|
||||
? decoded.sub
|
||||
: typeof decoded.user_id === 'string'
|
||||
? decoded.user_id
|
||||
: undefined;
|
||||
const exp = typeof decoded.exp === 'number' ? decoded.exp : undefined;
|
||||
|
||||
// If all claims are undefined, treat as if JWT parsing failed
|
||||
if (!email && !userId && exp === undefined) return null;
|
||||
|
||||
return { email, userId, exp };
|
||||
}
|
||||
} catch {
|
||||
// Token is not a JWT, that's okay
|
||||
@@ -172,13 +201,16 @@ export function saveCredentials(credentials: CursorCredentials): void {
|
||||
const credPath = getCredentialsPath();
|
||||
const dir = path.dirname(credPath);
|
||||
|
||||
// Ensure directory exists
|
||||
// Ensure directory exists with restrictive permissions
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
|
||||
// Write credentials
|
||||
fs.writeFileSync(credPath, JSON.stringify(credentials, null, 2), 'utf8');
|
||||
// Write credentials with restrictive permissions
|
||||
fs.writeFileSync(credPath, JSON.stringify(credentials, null, 2), {
|
||||
encoding: 'utf8',
|
||||
mode: 0o600,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -204,6 +236,16 @@ export function loadCredentials(): CursorCredentials | null {
|
||||
'authMethod' in parsed &&
|
||||
'importedAt' in parsed
|
||||
) {
|
||||
// Type validation
|
||||
if (
|
||||
typeof parsed.accessToken !== 'string' ||
|
||||
typeof parsed.machineId !== 'string' ||
|
||||
typeof parsed.importedAt !== 'string' ||
|
||||
(parsed.authMethod !== 'auto-detect' && parsed.authMethod !== 'manual')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed as CursorCredentials;
|
||||
}
|
||||
|
||||
@@ -228,19 +270,45 @@ export function checkAuthStatus(): CursorAuthStatus {
|
||||
return { authenticated: false };
|
||||
}
|
||||
|
||||
// Calculate token age in hours
|
||||
// Try to get token expiry from JWT exp claim
|
||||
let tokenAge: number | undefined;
|
||||
try {
|
||||
const importedDate = new Date(credentials.importedAt);
|
||||
let expired = false;
|
||||
const userInfo = extractUserInfo(credentials.accessToken);
|
||||
|
||||
if (userInfo?.exp) {
|
||||
// Use JWT exp claim for expiry detection
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
expired = now >= userInfo.exp;
|
||||
}
|
||||
|
||||
// Always use importedAt for tokenAge (more reliable than reverse-engineering JWT lifetime)
|
||||
const TOKEN_EXPIRY_HOURS = 24;
|
||||
const importedDate = new Date(credentials.importedAt);
|
||||
if (!isNaN(importedDate.getTime())) {
|
||||
const now = new Date();
|
||||
tokenAge = Math.floor((now.getTime() - importedDate.getTime()) / (1000 * 60 * 60));
|
||||
} catch {
|
||||
// Invalid date format
|
||||
// Only set expired from importedAt if JWT exp was not available
|
||||
if (userInfo?.exp === undefined) {
|
||||
expired = tokenAge >= TOKEN_EXPIRY_HOURS;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: true,
|
||||
credentials,
|
||||
tokenAge,
|
||||
expired,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete credentials file
|
||||
*/
|
||||
export function deleteCredentials(): boolean {
|
||||
try {
|
||||
fs.unlinkSync(getCredentialsPath());
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+166
-149
@@ -8,24 +8,9 @@ import * as zlib from 'zlib';
|
||||
import type { IncomingHttpHeaders } from 'http';
|
||||
import { generateCursorBody, extractTextFromResponse } from './cursor-protobuf.js';
|
||||
import { buildCursorRequest } from './cursor-translator.js';
|
||||
import type { CursorMessage, CursorTool } from './cursor-protobuf-schema.js';
|
||||
import type { CursorTool, CursorCredentials } from './cursor-protobuf-schema.js';
|
||||
|
||||
/** Compression flags for response parsing */
|
||||
const COMPRESS_FLAG = {
|
||||
NONE: 0x00,
|
||||
GZIP: 0x01,
|
||||
GZIP_ALT: 0x02,
|
||||
GZIP_BOTH: 0x03,
|
||||
} as const;
|
||||
|
||||
/** Cursor credentials structure */
|
||||
interface CursorCredentials {
|
||||
accessToken: string;
|
||||
providerSpecificData?: {
|
||||
machineId?: string;
|
||||
ghostMode?: boolean;
|
||||
};
|
||||
}
|
||||
import { COMPRESS_FLAG } from './cursor-protobuf-schema.js';
|
||||
|
||||
/** Executor parameters */
|
||||
interface ExecutorParams {
|
||||
@@ -57,35 +42,24 @@ interface Http2Response {
|
||||
body: Buffer;
|
||||
}
|
||||
|
||||
/** Detect cloud environment */
|
||||
function isCloudEnv(): boolean {
|
||||
if (typeof caches !== 'undefined' && typeof caches === 'object') return true;
|
||||
try {
|
||||
// Check for EdgeRuntime without causing compilation error
|
||||
if (typeof (globalThis as { EdgeRuntime?: string }).EdgeRuntime !== 'undefined') return true;
|
||||
} catch {
|
||||
// Continue
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Lazy import http2 */
|
||||
let http2Module: typeof import('http2') | null = null;
|
||||
async function getHttp2() {
|
||||
if (http2Module) return http2Module;
|
||||
if (!isCloudEnv()) {
|
||||
try {
|
||||
http2Module = await import('http2');
|
||||
return http2Module;
|
||||
} catch {
|
||||
return null;
|
||||
try {
|
||||
http2Module = await import('http2');
|
||||
return http2Module;
|
||||
} catch (err) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error('[cursor] http2 module not available, falling back to fetch:', err);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decompress payload if needed
|
||||
* NOTE: Uses synchronous gzip for single-request CLI tool. Async not warranted for small payloads.
|
||||
*/
|
||||
function decompressPayload(payload: Buffer, flags: number): Buffer {
|
||||
// Check if payload is JSON error
|
||||
@@ -95,8 +69,10 @@ function decompressPayload(payload: Buffer, flags: number): Buffer {
|
||||
if (text.startsWith('{"error"')) {
|
||||
return payload;
|
||||
}
|
||||
} catch {
|
||||
// Continue
|
||||
} catch (err) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error('[cursor] JSON error detection failed:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,8 +83,11 @@ function decompressPayload(payload: Buffer, flags: number): Buffer {
|
||||
) {
|
||||
try {
|
||||
return zlib.gunzipSync(payload);
|
||||
} catch {
|
||||
return payload;
|
||||
} catch (err) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error('[cursor] gzip decompression failed:', err);
|
||||
}
|
||||
return Buffer.alloc(0);
|
||||
}
|
||||
}
|
||||
return payload;
|
||||
@@ -150,6 +129,8 @@ function createErrorResponse(jsonError: {
|
||||
export class CursorExecutor {
|
||||
private readonly baseUrl = 'https://api2.cursor.sh';
|
||||
private readonly chatPath = '/aiserver.v1.AiService/StreamChat';
|
||||
private readonly CURSOR_CLIENT_VERSION = '2.3.41';
|
||||
private readonly CURSOR_USER_AGENT = 'connect-es/1.6.1';
|
||||
|
||||
buildUrl(): string {
|
||||
return `${this.baseUrl}${this.chatPath}`;
|
||||
@@ -160,12 +141,14 @@ export class CursorExecutor {
|
||||
*/
|
||||
generateChecksum(machineId: string): string {
|
||||
const timestamp = Math.floor(Date.now() / 1000000);
|
||||
// JS bitwise shifts wrap modulo 32, so >>40 and >>32 give wrong results.
|
||||
// Use Math.trunc division for upper bytes that exceed 32-bit range.
|
||||
const byteArray = new Uint8Array([
|
||||
(timestamp >> 40) & 0xff,
|
||||
(timestamp >> 32) & 0xff,
|
||||
(timestamp >> 24) & 0xff,
|
||||
(timestamp >> 16) & 0xff,
|
||||
(timestamp >> 8) & 0xff,
|
||||
Math.trunc(timestamp / 2 ** 40) & 0xff,
|
||||
Math.trunc(timestamp / 2 ** 32) & 0xff,
|
||||
(timestamp >>> 24) & 0xff,
|
||||
(timestamp >>> 16) & 0xff,
|
||||
(timestamp >>> 8) & 0xff,
|
||||
timestamp & 0xff,
|
||||
]);
|
||||
|
||||
@@ -199,25 +182,30 @@ export class CursorExecutor {
|
||||
|
||||
buildHeaders(credentials: CursorCredentials): Record<string, string> {
|
||||
const accessToken = credentials.accessToken;
|
||||
const machineId = credentials.providerSpecificData?.machineId;
|
||||
const ghostMode = credentials.providerSpecificData?.ghostMode !== false;
|
||||
const machineId = credentials.machineId;
|
||||
const ghostMode = credentials.ghostMode !== false;
|
||||
|
||||
if (!machineId) {
|
||||
throw new Error('Machine ID is required for Cursor API');
|
||||
}
|
||||
|
||||
const cleanToken = accessToken.includes('::') ? accessToken.split('::')[1] : accessToken;
|
||||
const delimIdx = accessToken.indexOf('::');
|
||||
const cleanToken = delimIdx !== -1 ? accessToken.slice(delimIdx + 2) : accessToken;
|
||||
|
||||
if (!cleanToken) {
|
||||
throw new Error('Access token is empty after parsing');
|
||||
}
|
||||
|
||||
return {
|
||||
authorization: `Bearer ${cleanToken}`,
|
||||
'connect-accept-encoding': 'gzip',
|
||||
'connect-protocol-version': '1',
|
||||
'content-type': 'application/connect+proto',
|
||||
'user-agent': 'connect-es/1.6.1',
|
||||
'user-agent': this.CURSOR_USER_AGENT,
|
||||
'x-amzn-trace-id': `Root=${crypto.randomUUID()}`,
|
||||
'x-client-key': crypto.createHash('sha256').update(cleanToken).digest('hex'),
|
||||
'x-cursor-checksum': this.generateChecksum(machineId),
|
||||
'x-cursor-client-version': '2.3.41',
|
||||
'x-cursor-client-version': this.CURSOR_CLIENT_VERSION,
|
||||
'x-cursor-client-type': 'ide',
|
||||
'x-cursor-client-os':
|
||||
process.platform === 'win32'
|
||||
@@ -290,7 +278,10 @@ export class CursorExecutor {
|
||||
const chunks: Buffer[] = [];
|
||||
let responseHeaders: IncomingHttpHeaders = {};
|
||||
|
||||
client.on('error', reject);
|
||||
client.on('error', (err) => {
|
||||
client.close();
|
||||
reject(err);
|
||||
});
|
||||
|
||||
const req = client.request({
|
||||
':method': 'POST',
|
||||
@@ -309,7 +300,7 @@ export class CursorExecutor {
|
||||
req.on('end', () => {
|
||||
client.close();
|
||||
resolve({
|
||||
status: Number(responseHeaders[':status']),
|
||||
status: Number(responseHeaders[':status']) || 500,
|
||||
headers: responseHeaders,
|
||||
body: Buffer.concat(chunks),
|
||||
});
|
||||
@@ -320,11 +311,18 @@ export class CursorExecutor {
|
||||
});
|
||||
|
||||
if (signal) {
|
||||
signal.addEventListener('abort', () => {
|
||||
const onAbort = () => {
|
||||
req.close();
|
||||
client.close();
|
||||
reject(new Error('Request aborted'));
|
||||
});
|
||||
};
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
|
||||
const cleanup = () => {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
};
|
||||
req.on('end', cleanup);
|
||||
req.on('error', cleanup);
|
||||
}
|
||||
|
||||
req.write(body);
|
||||
@@ -368,7 +366,7 @@ export class CursorExecutor {
|
||||
}
|
||||
|
||||
const transformedResponse =
|
||||
stream !== false
|
||||
stream === true
|
||||
? this.transformProtobufToSSE(response.body, model, body)
|
||||
: this.transformProtobufToJSON(response.body, model, body);
|
||||
|
||||
@@ -391,11 +389,93 @@ export class CursorExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
transformProtobufToJSON(buffer: Buffer, model: string, body: ExecutorParams['body']): Response {
|
||||
/**
|
||||
* Parse protobuf buffer into frames and extract text/toolcalls.
|
||||
* Shared logic between JSON and SSE transformers.
|
||||
*/
|
||||
private *parseProtobufFrames(buffer: Buffer): Generator<
|
||||
| { type: 'error'; response: Response }
|
||||
| { type: 'text'; text: string }
|
||||
| {
|
||||
type: 'toolCall';
|
||||
toolCall: {
|
||||
id: string;
|
||||
type: string;
|
||||
function: { name: string; arguments: string };
|
||||
isLast: boolean;
|
||||
};
|
||||
}
|
||||
> {
|
||||
let offset = 0;
|
||||
|
||||
while (offset < buffer.length) {
|
||||
if (offset + 5 > buffer.length) break;
|
||||
|
||||
const flags = buffer[offset];
|
||||
const length = buffer.readUInt32BE(offset + 1);
|
||||
|
||||
if (offset + 5 + length > buffer.length) break;
|
||||
|
||||
let payload = buffer.slice(offset + 5, offset + 5 + length);
|
||||
offset += 5 + length;
|
||||
|
||||
payload = decompressPayload(payload, flags);
|
||||
|
||||
// Check for JSON error format
|
||||
try {
|
||||
const text = payload.toString('utf-8');
|
||||
if (text.startsWith('{') && text.includes('"error"')) {
|
||||
yield { type: 'error', response: createErrorResponse(JSON.parse(text)) };
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error('[cursor] parseProtobufFrames error parsing failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
const result = extractTextFromResponse(new Uint8Array(payload));
|
||||
|
||||
// Check for protobuf-decoded error
|
||||
if (result.error) {
|
||||
const errorLower = result.error.toLowerCase();
|
||||
const isRateLimit =
|
||||
errorLower.includes('rate limit') ||
|
||||
errorLower.includes('resource_exhausted') ||
|
||||
errorLower.includes('too many requests');
|
||||
yield {
|
||||
type: 'error',
|
||||
response: new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: result.error,
|
||||
type: isRateLimit ? 'rate_limit_error' : 'server_error',
|
||||
code: isRateLimit ? 'rate_limited' : 'cursor_error',
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: isRateLimit ? 429 : 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
),
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.toolCall) {
|
||||
yield { type: 'toolCall', toolCall: result.toolCall };
|
||||
}
|
||||
|
||||
if (result.text) {
|
||||
yield { type: 'text', text: result.text };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
transformProtobufToJSON(buffer: Buffer, model: string, _body: ExecutorParams['body']): Response {
|
||||
const responseId = `chatcmpl-cursor-${Date.now()}`;
|
||||
const created = Math.floor(Date.now() / 1000);
|
||||
|
||||
let offset = 0;
|
||||
let totalContent = '';
|
||||
const toolCalls: Array<{
|
||||
id: string;
|
||||
@@ -413,51 +493,17 @@ export class CursorExecutor {
|
||||
}
|
||||
>();
|
||||
|
||||
while (offset < buffer.length) {
|
||||
if (offset + 5 > buffer.length) break;
|
||||
|
||||
const flags = buffer[offset];
|
||||
const length = buffer.readUInt32BE(offset + 1);
|
||||
|
||||
if (offset + 5 + length > buffer.length) break;
|
||||
|
||||
let payload = buffer.slice(offset + 5, offset + 5 + length);
|
||||
offset += 5 + length;
|
||||
|
||||
payload = decompressPayload(payload, flags);
|
||||
|
||||
try {
|
||||
const text = payload.toString('utf-8');
|
||||
if (text.startsWith('{') && text.includes('"error"')) {
|
||||
return createErrorResponse(JSON.parse(text));
|
||||
}
|
||||
} catch {
|
||||
// Continue
|
||||
for (const frame of this.parseProtobufFrames(buffer)) {
|
||||
if (frame.type === 'error') {
|
||||
return frame.response;
|
||||
}
|
||||
|
||||
const result = extractTextFromResponse(new Uint8Array(payload));
|
||||
|
||||
if (result.error) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: result.error,
|
||||
type: 'rate_limit_error',
|
||||
code: 'rate_limited',
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 429,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (result.toolCall) {
|
||||
const tc = result.toolCall;
|
||||
if (frame.type === 'toolCall') {
|
||||
const tc = frame.toolCall;
|
||||
|
||||
if (toolCallsMap.has(tc.id)) {
|
||||
const existing = toolCallsMap.get(tc.id)!;
|
||||
const existing = toolCallsMap.get(tc.id);
|
||||
if (!existing) continue;
|
||||
existing.function.arguments += tc.function.arguments;
|
||||
existing.isLast = tc.isLast;
|
||||
} else {
|
||||
@@ -468,7 +514,8 @@ export class CursorExecutor {
|
||||
}
|
||||
|
||||
if (tc.isLast) {
|
||||
const finalToolCall = toolCallsMap.get(tc.id)!;
|
||||
const finalToolCall = toolCallsMap.get(tc.id);
|
||||
if (!finalToolCall) continue;
|
||||
toolCalls.push({
|
||||
id: finalToolCall.id,
|
||||
type: finalToolCall.type,
|
||||
@@ -480,12 +527,15 @@ export class CursorExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
if (result.text) totalContent += result.text;
|
||||
if (frame.type === 'text') {
|
||||
totalContent += frame.text;
|
||||
}
|
||||
}
|
||||
|
||||
// Finalize remaining tool calls
|
||||
for (const id of Array.from(toolCallsMap.keys())) {
|
||||
const tc = toolCallsMap.get(id)!;
|
||||
const tc = toolCallsMap.get(id);
|
||||
if (!tc) continue;
|
||||
if (!toolCalls.find((t) => t.id === id)) {
|
||||
toolCalls.push({
|
||||
id: tc.id,
|
||||
@@ -540,13 +590,15 @@ export class CursorExecutor {
|
||||
});
|
||||
}
|
||||
|
||||
transformProtobufToSSE(buffer: Buffer, model: string, body: ExecutorParams['body']): Response {
|
||||
transformProtobufToSSE(buffer: Buffer, model: string, _body: ExecutorParams['body']): Response {
|
||||
// TODO(#531): Implement true streaming — currently buffers entire response before transforming.
|
||||
// This should pipe HTTP/2 data events through a TransformStream for incremental SSE output.
|
||||
// See: https://github.com/kaitranntt/ccs/issues/531
|
||||
// NOTE: Chunk boundary splits may emit duplicate SSE messages if a frame spans multiple chunks.
|
||||
const responseId = `chatcmpl-cursor-${Date.now()}`;
|
||||
const created = Math.floor(Date.now() / 1000);
|
||||
|
||||
const chunks: string[] = [];
|
||||
let offset = 0;
|
||||
let totalContent = '';
|
||||
const toolCalls: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
@@ -564,48 +616,13 @@ export class CursorExecutor {
|
||||
}
|
||||
>();
|
||||
|
||||
while (offset < buffer.length) {
|
||||
if (offset + 5 > buffer.length) break;
|
||||
|
||||
const flags = buffer[offset];
|
||||
const length = buffer.readUInt32BE(offset + 1);
|
||||
|
||||
if (offset + 5 + length > buffer.length) break;
|
||||
|
||||
let payload = buffer.slice(offset + 5, offset + 5 + length);
|
||||
offset += 5 + length;
|
||||
|
||||
payload = decompressPayload(payload, flags);
|
||||
|
||||
try {
|
||||
const text = payload.toString('utf-8');
|
||||
if (text.startsWith('{') && text.includes('"error"')) {
|
||||
return createErrorResponse(JSON.parse(text));
|
||||
}
|
||||
} catch {
|
||||
// Continue
|
||||
for (const frame of this.parseProtobufFrames(buffer)) {
|
||||
if (frame.type === 'error') {
|
||||
return frame.response;
|
||||
}
|
||||
|
||||
const result = extractTextFromResponse(new Uint8Array(payload));
|
||||
|
||||
if (result.error) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: result.error,
|
||||
type: 'rate_limit_error',
|
||||
code: 'rate_limited',
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 429,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (result.toolCall) {
|
||||
const tc = result.toolCall;
|
||||
if (frame.type === 'toolCall') {
|
||||
const tc = frame.toolCall;
|
||||
|
||||
if (chunks.length === 0) {
|
||||
chunks.push(
|
||||
@@ -626,7 +643,8 @@ export class CursorExecutor {
|
||||
}
|
||||
|
||||
if (toolCallsMap.has(tc.id)) {
|
||||
const existing = toolCallsMap.get(tc.id)!;
|
||||
const existing = toolCallsMap.get(tc.id);
|
||||
if (!existing) continue;
|
||||
existing.function.arguments += tc.function.arguments;
|
||||
existing.isLast = tc.isLast;
|
||||
|
||||
@@ -694,8 +712,7 @@ export class CursorExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
if (result.text) {
|
||||
totalContent += result.text;
|
||||
if (frame.type === 'text') {
|
||||
chunks.push(
|
||||
`data: ${JSON.stringify({
|
||||
id: responseId,
|
||||
@@ -707,8 +724,8 @@ export class CursorExecutor {
|
||||
index: 0,
|
||||
delta:
|
||||
chunks.length === 0 && toolCalls.length === 0
|
||||
? { role: 'assistant', content: result.text }
|
||||
: { content: result.text },
|
||||
? { role: 'assistant', content: frame.text }
|
||||
: { content: frame.text },
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import * as zlib from 'zlib';
|
||||
import { WIRE_TYPE, FIELD, type WireType } from './cursor-protobuf-schema.js';
|
||||
import { WIRE_TYPE, FIELD, COMPRESS_FLAG, type WireType } from './cursor-protobuf-schema.js';
|
||||
|
||||
/**
|
||||
* Decode a varint from buffer
|
||||
@@ -14,8 +14,9 @@ export function decodeVarint(buffer: Uint8Array, offset: number): [number, numbe
|
||||
let result = 0;
|
||||
let shift = 0;
|
||||
let pos = offset;
|
||||
const maxBytes = 5;
|
||||
|
||||
while (pos < buffer.length) {
|
||||
while (pos < buffer.length && pos - offset < maxBytes) {
|
||||
const b = buffer[pos];
|
||||
result |= (b & 0x7f) << shift;
|
||||
pos++;
|
||||
@@ -23,7 +24,7 @@ export function decodeVarint(buffer: Uint8Array, offset: number): [number, numbe
|
||||
shift += 7;
|
||||
}
|
||||
|
||||
return [result, pos];
|
||||
return [result >>> 0, pos]; // Ensure unsigned
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,12 +50,21 @@ export function decodeField(
|
||||
[value, pos] = decodeVarint(buffer, pos);
|
||||
} else if (wireType === WIRE_TYPE.LEN) {
|
||||
const [length, pos2] = decodeVarint(buffer, pos);
|
||||
if (pos2 + length > buffer.length) {
|
||||
return [null, null, null, buffer.length];
|
||||
}
|
||||
value = buffer.slice(pos2, pos2 + length);
|
||||
pos = pos2 + length;
|
||||
} else if (wireType === WIRE_TYPE.FIXED64) {
|
||||
if (pos + 8 > buffer.length) {
|
||||
return [null, null, null, buffer.length];
|
||||
}
|
||||
value = buffer.slice(pos, pos + 8);
|
||||
pos += 8;
|
||||
} else if (wireType === WIRE_TYPE.FIXED32) {
|
||||
if (pos + 4 > buffer.length) {
|
||||
return [null, null, null, buffer.length];
|
||||
}
|
||||
value = buffer.slice(pos, pos + 4);
|
||||
pos += 4;
|
||||
} else {
|
||||
@@ -73,6 +83,7 @@ export function decodeMessage(
|
||||
const fields = new Map<number, Array<{ wireType: WireType; value: Uint8Array | number }>>();
|
||||
let pos = 0;
|
||||
|
||||
// NOTE: If two fields share the same field number but different wire types, later values overwrite earlier ones.
|
||||
while (pos < data.length) {
|
||||
const [fieldNum, wireType, value, newPos] = decodeField(data, pos);
|
||||
if (fieldNum === null || wireType === null || value === null) break;
|
||||
@@ -80,7 +91,10 @@ export function decodeMessage(
|
||||
if (!fields.has(fieldNum)) {
|
||||
fields.set(fieldNum, []);
|
||||
}
|
||||
fields.get(fieldNum)!.push({ wireType, value: value as Uint8Array | number });
|
||||
const fieldArray = fields.get(fieldNum);
|
||||
if (fieldArray) {
|
||||
fieldArray.push({ wireType, value: value as Uint8Array | number });
|
||||
}
|
||||
pos = newPos;
|
||||
}
|
||||
|
||||
@@ -107,10 +121,17 @@ export function parseConnectRPCFrame(buffer: Buffer): {
|
||||
let payload = buffer.slice(5, 5 + length);
|
||||
|
||||
// Decompress if gzip
|
||||
if (flags === 0x01 || flags === 0x02 || flags === 0x03) {
|
||||
if (
|
||||
flags === COMPRESS_FLAG.GZIP ||
|
||||
flags === COMPRESS_FLAG.GZIP_ALT ||
|
||||
flags === COMPRESS_FLAG.GZIP_BOTH
|
||||
) {
|
||||
try {
|
||||
payload = Buffer.from(zlib.gunzipSync(payload));
|
||||
} catch {
|
||||
} catch (err) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error('[cursor] parseConnectRPCFrame decompression failed:', err);
|
||||
}
|
||||
// Decompression failed, use raw payload
|
||||
}
|
||||
}
|
||||
@@ -140,48 +161,71 @@ function extractToolCall(toolCallData: Uint8Array): {
|
||||
|
||||
// Extract tool call ID
|
||||
if (toolCall.has(FIELD.TOOL_ID)) {
|
||||
const fullId = new TextDecoder().decode(toolCall.get(FIELD.TOOL_ID)![0].value as Uint8Array);
|
||||
toolCallId = fullId.split('\n')[0]; // Take first line
|
||||
const idField = toolCall.get(FIELD.TOOL_ID);
|
||||
if (idField && idField[0]) {
|
||||
const fullId = new TextDecoder().decode(idField[0].value as Uint8Array);
|
||||
toolCallId = fullId.split('\n')[0]; // Take first line
|
||||
}
|
||||
}
|
||||
|
||||
// Extract tool name
|
||||
if (toolCall.has(FIELD.TOOL_NAME)) {
|
||||
toolName = new TextDecoder().decode(toolCall.get(FIELD.TOOL_NAME)![0].value as Uint8Array);
|
||||
const nameField = toolCall.get(FIELD.TOOL_NAME);
|
||||
if (nameField && nameField[0]) {
|
||||
toolName = new TextDecoder().decode(nameField[0].value as Uint8Array);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract is_last flag
|
||||
if (toolCall.has(FIELD.TOOL_IS_LAST)) {
|
||||
isLast = (toolCall.get(FIELD.TOOL_IS_LAST)![0].value as number) !== 0;
|
||||
const lastField = toolCall.get(FIELD.TOOL_IS_LAST);
|
||||
if (lastField && lastField[0]) {
|
||||
isLast = (lastField[0].value as number) !== 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract MCP params - nested real tool info
|
||||
if (toolCall.has(FIELD.TOOL_MCP_PARAMS)) {
|
||||
try {
|
||||
const mcpParams = decodeMessage(toolCall.get(FIELD.TOOL_MCP_PARAMS)![0].value as Uint8Array);
|
||||
const mcpField = toolCall.get(FIELD.TOOL_MCP_PARAMS);
|
||||
if (!mcpField || !mcpField[0]) return null;
|
||||
|
||||
const mcpParams = decodeMessage(mcpField[0].value as Uint8Array);
|
||||
|
||||
if (mcpParams.has(FIELD.MCP_TOOLS_LIST)) {
|
||||
const tool = decodeMessage(mcpParams.get(FIELD.MCP_TOOLS_LIST)![0].value as Uint8Array);
|
||||
const toolsList = mcpParams.get(FIELD.MCP_TOOLS_LIST);
|
||||
if (!toolsList || !toolsList[0]) return null;
|
||||
|
||||
const tool = decodeMessage(toolsList[0].value as Uint8Array);
|
||||
|
||||
if (tool.has(FIELD.MCP_NESTED_NAME)) {
|
||||
toolName = new TextDecoder().decode(
|
||||
tool.get(FIELD.MCP_NESTED_NAME)![0].value as Uint8Array
|
||||
);
|
||||
const nestedName = tool.get(FIELD.MCP_NESTED_NAME);
|
||||
if (nestedName && nestedName[0]) {
|
||||
toolName = new TextDecoder().decode(nestedName[0].value as Uint8Array);
|
||||
}
|
||||
}
|
||||
|
||||
if (tool.has(FIELD.MCP_NESTED_PARAMS)) {
|
||||
rawArgs = new TextDecoder().decode(
|
||||
tool.get(FIELD.MCP_NESTED_PARAMS)![0].value as Uint8Array
|
||||
);
|
||||
const nestedParams = tool.get(FIELD.MCP_NESTED_PARAMS);
|
||||
if (nestedParams && nestedParams[0]) {
|
||||
rawArgs = new TextDecoder().decode(nestedParams[0].value as Uint8Array);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
} catch (err) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error('[cursor] extractToolCall MCP parsing failed:', err);
|
||||
}
|
||||
// MCP parse error, continue
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to raw_args
|
||||
if (!rawArgs && toolCall.has(FIELD.TOOL_RAW_ARGS)) {
|
||||
rawArgs = new TextDecoder().decode(toolCall.get(FIELD.TOOL_RAW_ARGS)![0].value as Uint8Array);
|
||||
const rawArgsField = toolCall.get(FIELD.TOOL_RAW_ARGS);
|
||||
if (rawArgsField && rawArgsField[0]) {
|
||||
rawArgs = new TextDecoder().decode(rawArgsField[0].value as Uint8Array);
|
||||
}
|
||||
}
|
||||
|
||||
if (toolCallId && toolName) {
|
||||
@@ -212,19 +256,29 @@ function extractTextAndThinking(responseData: Uint8Array): {
|
||||
|
||||
// Extract text
|
||||
if (nested.has(FIELD.RESPONSE_TEXT)) {
|
||||
text = new TextDecoder().decode(nested.get(FIELD.RESPONSE_TEXT)![0].value as Uint8Array);
|
||||
const textField = nested.get(FIELD.RESPONSE_TEXT);
|
||||
if (textField && textField[0]) {
|
||||
text = new TextDecoder().decode(textField[0].value as Uint8Array);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract thinking
|
||||
if (nested.has(FIELD.THINKING)) {
|
||||
try {
|
||||
const thinkingMsg = decodeMessage(nested.get(FIELD.THINKING)![0].value as Uint8Array);
|
||||
if (thinkingMsg.has(FIELD.THINKING_TEXT)) {
|
||||
thinking = new TextDecoder().decode(
|
||||
thinkingMsg.get(FIELD.THINKING_TEXT)![0].value as Uint8Array
|
||||
);
|
||||
const thinkingField = nested.get(FIELD.THINKING);
|
||||
if (thinkingField && thinkingField[0]) {
|
||||
const thinkingMsg = decodeMessage(thinkingField[0].value as Uint8Array);
|
||||
if (thinkingMsg.has(FIELD.THINKING_TEXT)) {
|
||||
const thinkingTextField = thinkingMsg.get(FIELD.THINKING_TEXT);
|
||||
if (thinkingTextField && thinkingTextField[0]) {
|
||||
thinking = new TextDecoder().decode(thinkingTextField[0].value as Uint8Array);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error('[cursor] extractTextAndThinking parsing failed:', err);
|
||||
}
|
||||
} catch {
|
||||
// Thinking parse error, continue
|
||||
}
|
||||
}
|
||||
@@ -251,25 +305,32 @@ export function extractTextFromResponse(payload: Uint8Array): {
|
||||
|
||||
// Field 1: ClientSideToolV2Call
|
||||
if (fields.has(FIELD.TOOL_CALL)) {
|
||||
const toolCall = extractToolCall(fields.get(FIELD.TOOL_CALL)![0].value as Uint8Array);
|
||||
if (toolCall) {
|
||||
return { text: null, error: null, toolCall, thinking: null };
|
||||
const toolCallField = fields.get(FIELD.TOOL_CALL);
|
||||
if (toolCallField && toolCallField[0]) {
|
||||
const toolCall = extractToolCall(toolCallField[0].value as Uint8Array);
|
||||
if (toolCall) {
|
||||
return { text: null, error: null, toolCall, thinking: null };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Field 2: StreamUnifiedChatResponse
|
||||
if (fields.has(FIELD.RESPONSE)) {
|
||||
const { text, thinking } = extractTextAndThinking(
|
||||
fields.get(FIELD.RESPONSE)![0].value as Uint8Array
|
||||
);
|
||||
const responseField = fields.get(FIELD.RESPONSE);
|
||||
if (responseField && responseField[0]) {
|
||||
const { text, thinking } = extractTextAndThinking(responseField[0].value as Uint8Array);
|
||||
|
||||
if (text || thinking) {
|
||||
return { text, error: null, toolCall: null, thinking };
|
||||
if (text || thinking) {
|
||||
return { text, error: null, toolCall: null, thinking };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { text: null, error: null, toolCall: null, thinking: null };
|
||||
} catch {
|
||||
} catch (err) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error('[cursor] extractTextFromResponse parsing failed:', err);
|
||||
}
|
||||
return { text: null, error: null, toolCall: null, thinking: null };
|
||||
}
|
||||
}
|
||||
@@ -3,23 +3,16 @@
|
||||
* Implements ConnectRPC protobuf wire format encoding
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'crypto';
|
||||
import * as zlib from 'zlib';
|
||||
import {
|
||||
WIRE_TYPE,
|
||||
ROLE,
|
||||
UNIFIED_MODE,
|
||||
THINKING_LEVEL,
|
||||
FIELD,
|
||||
COMPRESS_FLAG,
|
||||
UNIFIED_MODE,
|
||||
type WireType,
|
||||
type RoleType,
|
||||
type ThinkingLevelType,
|
||||
type CursorTool,
|
||||
type CursorToolResult,
|
||||
type CursorMessage,
|
||||
type FormattedMessage,
|
||||
type MessageId,
|
||||
} from './cursor-protobuf-schema.js';
|
||||
|
||||
/**
|
||||
@@ -70,7 +63,7 @@ export function encodeField(
|
||||
/**
|
||||
* Concatenate multiple Uint8Arrays
|
||||
*/
|
||||
function concatArrays(...arrays: Uint8Array[]): Uint8Array {
|
||||
export function concatArrays(...arrays: Uint8Array[]): Uint8Array {
|
||||
const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0);
|
||||
const result = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
|
||||
@@ -32,10 +32,10 @@ export const THINKING_LEVEL = {
|
||||
|
||||
/** Field numbers for all protobuf messages */
|
||||
export const FIELD = {
|
||||
// StreamUnifiedChatRequestWithTools (top level)
|
||||
// ===== StreamUnifiedChatRequestWithTools (top level) =====
|
||||
REQUEST: 1,
|
||||
|
||||
// StreamUnifiedChatRequest
|
||||
// ===== StreamUnifiedChatRequest =====
|
||||
MESSAGES: 1,
|
||||
UNKNOWN_2: 2,
|
||||
INSTRUCTION: 3,
|
||||
@@ -61,7 +61,7 @@ export const FIELD = {
|
||||
UNKNOWN_53: 53,
|
||||
UNIFIED_MODE_NAME: 54,
|
||||
|
||||
// ConversationMessage
|
||||
// ===== ConversationMessage =====
|
||||
MSG_CONTENT: 1,
|
||||
MSG_ROLE: 2,
|
||||
MSG_ID: 13,
|
||||
@@ -70,72 +70,72 @@ export const FIELD = {
|
||||
MSG_UNIFIED_MODE: 47,
|
||||
MSG_SUPPORTED_TOOLS: 51,
|
||||
|
||||
// ConversationMessage.ToolResult
|
||||
// ===== ConversationMessage.ToolResult =====
|
||||
TOOL_RESULT_CALL_ID: 1,
|
||||
TOOL_RESULT_NAME: 2,
|
||||
TOOL_RESULT_INDEX: 3,
|
||||
TOOL_RESULT_RAW_ARGS: 5,
|
||||
TOOL_RESULT_RESULT: 8,
|
||||
TOOL_RESULT_RESULT: 8, // Reserved for future tool result parsing
|
||||
|
||||
// Model
|
||||
// ===== Model =====
|
||||
MODEL_NAME: 1,
|
||||
MODEL_EMPTY: 4,
|
||||
|
||||
// Instruction
|
||||
// ===== Instruction =====
|
||||
INSTRUCTION_TEXT: 1,
|
||||
|
||||
// CursorSetting
|
||||
// ===== CursorSetting =====
|
||||
SETTING_PATH: 1,
|
||||
SETTING_UNKNOWN_3: 3,
|
||||
SETTING_UNKNOWN_6: 6,
|
||||
SETTING_UNKNOWN_8: 8,
|
||||
SETTING_UNKNOWN_9: 9,
|
||||
|
||||
// CursorSetting.Unknown6
|
||||
// ===== CursorSetting.Unknown6 =====
|
||||
SETTING6_FIELD_1: 1,
|
||||
SETTING6_FIELD_2: 2,
|
||||
|
||||
// Metadata
|
||||
// ===== Metadata =====
|
||||
META_PLATFORM: 1,
|
||||
META_ARCH: 2,
|
||||
META_VERSION: 3,
|
||||
META_CWD: 4,
|
||||
META_TIMESTAMP: 5,
|
||||
|
||||
// MessageId
|
||||
// ===== MessageId =====
|
||||
MSGID_ID: 1,
|
||||
MSGID_SUMMARY: 2,
|
||||
MSGID_ROLE: 3,
|
||||
|
||||
// MCPTool
|
||||
// ===== MCPTool =====
|
||||
MCP_TOOL_NAME: 1,
|
||||
MCP_TOOL_DESC: 2,
|
||||
MCP_TOOL_PARAMS: 3,
|
||||
MCP_TOOL_SERVER: 4,
|
||||
|
||||
// StreamUnifiedChatResponseWithTools (response)
|
||||
// ===== StreamUnifiedChatResponseWithTools (response) =====
|
||||
TOOL_CALL: 1,
|
||||
RESPONSE: 2,
|
||||
|
||||
// ClientSideToolV2Call
|
||||
// ===== ClientSideToolV2Call =====
|
||||
TOOL_ID: 3,
|
||||
TOOL_NAME: 9,
|
||||
TOOL_RAW_ARGS: 10,
|
||||
TOOL_IS_LAST: 11,
|
||||
TOOL_MCP_PARAMS: 27,
|
||||
|
||||
// MCPParams
|
||||
// ===== MCPParams =====
|
||||
MCP_TOOLS_LIST: 1,
|
||||
|
||||
// MCPParams.Tool (nested)
|
||||
// ===== MCPParams.Tool (nested) =====
|
||||
MCP_NESTED_NAME: 1,
|
||||
MCP_NESTED_PARAMS: 3,
|
||||
|
||||
// StreamUnifiedChatResponse
|
||||
// ===== StreamUnifiedChatResponse =====
|
||||
RESPONSE_TEXT: 1,
|
||||
THINKING: 25,
|
||||
|
||||
// Thinking
|
||||
// ===== Thinking =====
|
||||
THINKING_TEXT: 1,
|
||||
} as const;
|
||||
|
||||
@@ -146,6 +146,13 @@ export type UnifiedModeType = (typeof UNIFIED_MODE)[keyof typeof UNIFIED_MODE];
|
||||
export type ThinkingLevelType = (typeof THINKING_LEVEL)[keyof typeof THINKING_LEVEL];
|
||||
export type FieldNumber = (typeof FIELD)[keyof typeof FIELD];
|
||||
|
||||
/** Cursor credentials structure */
|
||||
export interface CursorCredentials {
|
||||
accessToken: string;
|
||||
machineId: string;
|
||||
ghostMode?: boolean;
|
||||
}
|
||||
|
||||
/** Cursor tool definition */
|
||||
export interface CursorTool {
|
||||
function?: {
|
||||
@@ -201,4 +208,6 @@ export interface MessageId {
|
||||
export const COMPRESS_FLAG = {
|
||||
NONE: 0x00,
|
||||
GZIP: 0x01,
|
||||
GZIP_ALT: 0x02,
|
||||
GZIP_BOTH: 0x03,
|
||||
} as const;
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
encodeMessageId,
|
||||
encodeMcpTool,
|
||||
wrapConnectRPCFrame,
|
||||
concatArrays,
|
||||
} from './cursor-protobuf-encoder.js';
|
||||
import {
|
||||
decodeVarint,
|
||||
@@ -45,6 +46,10 @@ export function encodeRequest(
|
||||
tools: CursorTool[] = [],
|
||||
reasoningEffort: string | null = null
|
||||
): Uint8Array {
|
||||
if (messages.length === 0) {
|
||||
throw new Error('Messages array must not be empty');
|
||||
}
|
||||
|
||||
const hasTools = tools?.length > 0;
|
||||
const isAgentic = hasTools;
|
||||
const formattedMessages: FormattedMessage[] = [];
|
||||
@@ -161,20 +166,6 @@ export function generateCursorBody(
|
||||
return framed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Concatenate multiple Uint8Arrays
|
||||
*/
|
||||
function concatArrays(...arrays: Uint8Array[]): Uint8Array {
|
||||
const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0);
|
||||
const result = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const arr of arrays) {
|
||||
result.set(arr, offset);
|
||||
offset += arr.length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Re-export all functions
|
||||
export {
|
||||
encodeVarint,
|
||||
|
||||
@@ -39,9 +39,17 @@ function convertMessages(messages: OpenAIMessage[]): CursorMessage[] {
|
||||
const msg = messages[i];
|
||||
|
||||
if (msg.role === 'system') {
|
||||
let content = '';
|
||||
if (typeof msg.content === 'string') {
|
||||
content = msg.content;
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
for (const part of msg.content) {
|
||||
if (part.type === 'text' && part.text) content += part.text;
|
||||
}
|
||||
}
|
||||
result.push({
|
||||
role: 'user',
|
||||
content: `[System Instructions]\n${msg.content}`,
|
||||
content: `[System Instructions]\n${content}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -113,6 +121,12 @@ function convertMessages(messages: OpenAIMessage[]): CursorMessage[] {
|
||||
|
||||
result.push(msgObj);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unknown role - skip with debug warning
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(`[cursor] Unknown message role: ${msg.role}, skipping`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,10 +138,10 @@ function convertMessages(messages: OpenAIMessage[]): CursorMessage[] {
|
||||
* Returns modified body with converted messages
|
||||
*/
|
||||
export function buildCursorRequest(
|
||||
model: string,
|
||||
_model: string,
|
||||
body: OpenAIRequestBody,
|
||||
stream: boolean,
|
||||
credentials: unknown
|
||||
_stream: boolean,
|
||||
_credentials: unknown
|
||||
): {
|
||||
messages: CursorMessage[];
|
||||
tools?: CursorTool[];
|
||||
|
||||
+16
-80
@@ -32,6 +32,22 @@ export interface CursorAuthStatus {
|
||||
credentials?: CursorCredentials;
|
||||
/** Hours since credentials were imported (if available) */
|
||||
tokenAge?: number;
|
||||
/** Whether token has expired (>24 hours old) */
|
||||
expired?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-detection result
|
||||
*/
|
||||
export interface AutoDetectResult {
|
||||
/** Whether tokens were found */
|
||||
found: boolean;
|
||||
/** Access token (if found) */
|
||||
accessToken?: string;
|
||||
/** Machine ID (if found) */
|
||||
machineId?: string;
|
||||
/** Error message (if detection failed) */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,83 +75,3 @@ export interface CursorModel {
|
||||
/** Whether this is the default model */
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Message role
|
||||
*/
|
||||
export type MessageRole = 'user' | 'assistant';
|
||||
|
||||
/**
|
||||
* Cursor message for protobuf
|
||||
*/
|
||||
export interface CursorMessage {
|
||||
/** Message role */
|
||||
role: MessageRole;
|
||||
/** Message content */
|
||||
content: string;
|
||||
/** Tool calls (if any) */
|
||||
tool_calls?: CursorToolCall[];
|
||||
/** Tool results (if any) */
|
||||
tool_results?: CursorToolResult[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cursor tool call
|
||||
*/
|
||||
export interface CursorToolCall {
|
||||
/** Unique ID for this tool call */
|
||||
id: string;
|
||||
/** Type of tool call */
|
||||
type: 'function';
|
||||
/** Function details */
|
||||
function: {
|
||||
/** Function name */
|
||||
name: string;
|
||||
/** JSON-encoded arguments */
|
||||
arguments: string;
|
||||
};
|
||||
/** Whether this is the last tool call in sequence */
|
||||
isLast?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cursor tool result
|
||||
*/
|
||||
export interface CursorToolResult {
|
||||
/** ID of the tool call this result is for */
|
||||
tool_call_id: string;
|
||||
/** Tool name */
|
||||
name: string;
|
||||
/** Result index */
|
||||
index: number;
|
||||
/** Raw arguments */
|
||||
raw_args: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result from protobuf extraction
|
||||
*/
|
||||
export interface ProtobufExtractResult {
|
||||
/** Extracted text content */
|
||||
text: string | null;
|
||||
/** Error message (if extraction failed) */
|
||||
error: string | null;
|
||||
/** Extracted tool call (if any) */
|
||||
toolCall: CursorToolCall | null;
|
||||
/** Thinking/reasoning content (if any) */
|
||||
thinking: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-detection result
|
||||
*/
|
||||
export interface AutoDetectResult {
|
||||
/** Whether tokens were found */
|
||||
found: boolean;
|
||||
/** Access token (if found) */
|
||||
accessToken?: string;
|
||||
/** Machine ID (if found) */
|
||||
machineId?: string;
|
||||
/** Error message (if detection failed) */
|
||||
error?: string;
|
||||
}
|
||||
@@ -113,7 +113,7 @@ export class GlmtTransformer {
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content,
|
||||
model: openaiResponse.model || 'glm-4.7',
|
||||
model: openaiResponse.model || 'glm-5',
|
||||
stop_reason: this.responseBuilder.mapStopReason(choice.finish_reason || 'stop'),
|
||||
usage: {
|
||||
input_tokens: openaiResponse.usage?.prompt_tokens || 0,
|
||||
@@ -131,7 +131,7 @@ export class GlmtTransformer {
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: '[Transformation Error] ' + err.message }],
|
||||
model: 'glm-4.7',
|
||||
model: 'glm-5',
|
||||
stop_reason: 'end_turn',
|
||||
usage: { input_tokens: 0, output_tokens: 0 },
|
||||
};
|
||||
|
||||
@@ -32,7 +32,7 @@ export class ResponseBuilder {
|
||||
type: 'message',
|
||||
role: accumulator.getRole(),
|
||||
content: [],
|
||||
model: accumulator.getModel() || 'glm-4.7',
|
||||
model: accumulator.getModel() || 'glm-5',
|
||||
stop_reason: null,
|
||||
usage: {
|
||||
input_tokens: accumulator.getInputTokens(),
|
||||
|
||||
@@ -337,7 +337,7 @@ export function getModelDisplayName(profile: string): string {
|
||||
const model = settings.env?.ANTHROPIC_MODEL;
|
||||
|
||||
if (model) {
|
||||
// Format: 'glm-4.7' -> 'GLM-4.7' (uppercase letters, preserve numbers)
|
||||
// Format: 'glm-5' -> 'GLM-5' (uppercase letters, preserve numbers)
|
||||
return model
|
||||
.split('-')
|
||||
.map((part) => part.toUpperCase())
|
||||
|
||||
@@ -410,37 +410,43 @@ const PRICING_REGISTRY: Record<string, ModelPricing> = {
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GLM Models (Zhipu AI / Z.AI) - Source: better-ccusage
|
||||
// GLM Models (Zhipu AI / Z.AI) - Source: OpenRouter verified pricing
|
||||
// ---------------------------------------------------------------------------
|
||||
'glm-4.7': {
|
||||
inputPerMillion: 0.6,
|
||||
outputPerMillion: 2.2,
|
||||
'glm-5': {
|
||||
inputPerMillion: 1.0,
|
||||
outputPerMillion: 3.2,
|
||||
cacheCreationPerMillion: 0.0,
|
||||
cacheReadPerMillion: 0.11,
|
||||
cacheReadPerMillion: 0.2,
|
||||
},
|
||||
'glm-4.7': {
|
||||
inputPerMillion: 0.4,
|
||||
outputPerMillion: 1.5,
|
||||
cacheCreationPerMillion: 0.0,
|
||||
cacheReadPerMillion: 0.2,
|
||||
},
|
||||
'glm-4.6': {
|
||||
inputPerMillion: 0.6,
|
||||
outputPerMillion: 2.2,
|
||||
inputPerMillion: 0.35,
|
||||
outputPerMillion: 1.5,
|
||||
cacheCreationPerMillion: 0.0,
|
||||
cacheReadPerMillion: 0.11,
|
||||
cacheReadPerMillion: 0.175,
|
||||
},
|
||||
'glm-4.6-cc-max': {
|
||||
inputPerMillion: 0.6,
|
||||
outputPerMillion: 2.2,
|
||||
inputPerMillion: 0.35,
|
||||
outputPerMillion: 1.5,
|
||||
cacheCreationPerMillion: 0.0,
|
||||
cacheReadPerMillion: 0.11,
|
||||
cacheReadPerMillion: 0.175,
|
||||
},
|
||||
'glm-4.5': {
|
||||
inputPerMillion: 0.6,
|
||||
outputPerMillion: 2.2,
|
||||
inputPerMillion: 0.35,
|
||||
outputPerMillion: 1.55,
|
||||
cacheCreationPerMillion: 0.0,
|
||||
cacheReadPerMillion: 0.11,
|
||||
cacheReadPerMillion: 0.175,
|
||||
},
|
||||
'glm-4.5-air': {
|
||||
inputPerMillion: 0.2,
|
||||
outputPerMillion: 1.1,
|
||||
inputPerMillion: 0.13,
|
||||
outputPerMillion: 0.85,
|
||||
cacheCreationPerMillion: 0.0,
|
||||
cacheReadPerMillion: 0.03,
|
||||
cacheReadPerMillion: 0.025,
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -35,10 +35,10 @@ module.exports = {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://api.z.ai/api/anthropic",
|
||||
ANTHROPIC_AUTH_TOKEN: "your_api_key_here",
|
||||
ANTHROPIC_MODEL: "glm-4.7",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-4.7",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "glm-4.7",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "glm-4.7"
|
||||
ANTHROPIC_MODEL: "glm-5",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-5",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "glm-5",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "glm-5"
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* Account Safety Quota Exhaustion Handler Tests
|
||||
*
|
||||
* Tests for handleQuotaExhaustion() and writeQuotaWarning():
|
||||
* - Cooldown application
|
||||
* - Account switching
|
||||
* - Fallback when no alternatives
|
||||
* - Warning output formatting
|
||||
* - Email masking
|
||||
*/
|
||||
|
||||
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 { handleQuotaExhaustion, writeQuotaWarning, maskEmail } from '../../../src/cliproxy/account-safety';
|
||||
|
||||
// Setup test isolation
|
||||
let tmpDir: string;
|
||||
let origCcsHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-test-exhaust-'));
|
||||
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 });
|
||||
});
|
||||
|
||||
// Helper: write accounts registry
|
||||
function writeRegistry(providers: Record<string, unknown>): void {
|
||||
const registryDir = path.join(tmpDir, '.ccs', 'cliproxy');
|
||||
fs.mkdirSync(registryDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(registryDir, 'accounts.json'),
|
||||
JSON.stringify({ version: 1, providers }, null, 2)
|
||||
);
|
||||
}
|
||||
|
||||
// Helper: write unified config
|
||||
function writeConfig(quotaConfig: unknown): void {
|
||||
const configDir = path.join(tmpDir, '.ccs', 'config');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(configDir, 'unified-config.json'),
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
quota_management: quotaConfig,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
describe('Quota Exhaustion Handlers', () => {
|
||||
describe('writeQuotaWarning', () => {
|
||||
it('should write to stderr with box format', async () => {
|
||||
const stderrWrites: string[] = [];
|
||||
const originalWrite = process.stderr.write;
|
||||
process.stderr.write = ((chunk: string) => {
|
||||
stderrWrites.push(chunk);
|
||||
return true;
|
||||
}) as any;
|
||||
|
||||
writeQuotaWarning('test@gmail.com', 20);
|
||||
|
||||
process.stderr.write = originalWrite;
|
||||
|
||||
// Verify output contains account
|
||||
const fullOutput = stderrWrites.join('');
|
||||
expect(fullOutput).toContain('tes');
|
||||
expect(fullOutput).toContain('20%');
|
||||
|
||||
// Verify box borders present
|
||||
expect(fullOutput).toContain('\u2554'); // Top-left corner
|
||||
expect(fullOutput).toContain('\u2557'); // Top-right corner
|
||||
expect(fullOutput).toContain('\u255A'); // Bottom-left corner
|
||||
expect(fullOutput).toContain('\u255D'); // Bottom-right corner
|
||||
expect(fullOutput).toContain('\u2551'); // Vertical bar
|
||||
});
|
||||
|
||||
it('should mask email showing only first 3 chars', async () => {
|
||||
const stderrWrites: string[] = [];
|
||||
const originalWrite = process.stderr.write;
|
||||
process.stderr.write = ((chunk: string) => {
|
||||
stderrWrites.push(chunk);
|
||||
return true;
|
||||
}) as any;
|
||||
|
||||
writeQuotaWarning('verylongemail@example.com', 15);
|
||||
|
||||
process.stderr.write = originalWrite;
|
||||
|
||||
const fullOutput = stderrWrites.join('');
|
||||
// Should show "ver***@example.com"
|
||||
expect(fullOutput).toContain('ver***@example.com');
|
||||
expect(fullOutput).not.toContain('verylongemail@example.com');
|
||||
});
|
||||
|
||||
it('should include threshold percentage', async () => {
|
||||
const stderrWrites: string[] = [];
|
||||
const originalWrite = process.stderr.write;
|
||||
process.stderr.write = ((chunk: string) => {
|
||||
stderrWrites.push(chunk);
|
||||
return true;
|
||||
}) as any;
|
||||
|
||||
writeQuotaWarning('test@gmail.com', 5);
|
||||
|
||||
process.stderr.write = originalWrite;
|
||||
|
||||
const fullOutput = stderrWrites.join('');
|
||||
expect(fullOutput).toContain('5%');
|
||||
});
|
||||
});
|
||||
|
||||
describe('maskEmail', () => {
|
||||
it('should mask standard email', () => {
|
||||
const result = maskEmail('user@example.com');
|
||||
expect(result).toBe('use***@example.com');
|
||||
});
|
||||
|
||||
it('should handle short local part', () => {
|
||||
const result = maskEmail('ab@example.com');
|
||||
expect(result).toBe('ab***@example.com');
|
||||
});
|
||||
|
||||
it('should handle single char local part', () => {
|
||||
const result = maskEmail('a@example.com');
|
||||
expect(result).toBe('a***@example.com');
|
||||
});
|
||||
|
||||
it('should return input if no @ sign', () => {
|
||||
const result = maskEmail('not-an-email');
|
||||
expect(result).toBe('not-an-email');
|
||||
});
|
||||
|
||||
it('should return input if empty string', () => {
|
||||
const result = maskEmail('');
|
||||
expect(result).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleQuotaExhaustion', () => {
|
||||
it('should apply cooldown to exhausted account', async () => {
|
||||
writeRegistry({
|
||||
agy: {
|
||||
default: 'exhausted@gmail.com',
|
||||
accounts: {
|
||||
'exhausted@gmail.com': {
|
||||
email: 'exhausted@gmail.com',
|
||||
tokenFile: 'agy-exhausted.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
writeConfig({
|
||||
mode: 'auto',
|
||||
auto: {
|
||||
tier_priority: ['ultra', 'pro'],
|
||||
exhaustion_threshold: 5,
|
||||
cooldown_minutes: 10,
|
||||
preflight_check: true,
|
||||
},
|
||||
runtime_monitor: {
|
||||
enabled: true,
|
||||
normal_interval_seconds: 300,
|
||||
critical_interval_seconds: 60,
|
||||
warn_threshold: 20,
|
||||
exhaustion_threshold: 5,
|
||||
cooldown_minutes: 10,
|
||||
},
|
||||
});
|
||||
|
||||
const { isOnCooldown } = await import('../../../src/cliproxy/quota-manager');
|
||||
|
||||
const result = await handleQuotaExhaustion('agy', 'exhausted@gmail.com', 10);
|
||||
|
||||
// Verify cooldown was applied (account now on cooldown)
|
||||
expect(isOnCooldown('agy', 'exhausted@gmail.com')).toBe(true);
|
||||
// Should return a result with reason
|
||||
expect(result.reason).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle no alternatives gracefully', async () => {
|
||||
writeRegistry({
|
||||
agy: {
|
||||
default: 'only@gmail.com',
|
||||
accounts: {
|
||||
'only@gmail.com': {
|
||||
email: 'only@gmail.com',
|
||||
tokenFile: 'agy-only.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
writeConfig({
|
||||
mode: 'auto',
|
||||
auto: {
|
||||
tier_priority: ['ultra', 'pro'],
|
||||
exhaustion_threshold: 5,
|
||||
cooldown_minutes: 10,
|
||||
preflight_check: true,
|
||||
},
|
||||
runtime_monitor: {
|
||||
enabled: true,
|
||||
normal_interval_seconds: 300,
|
||||
critical_interval_seconds: 60,
|
||||
warn_threshold: 20,
|
||||
exhaustion_threshold: 5,
|
||||
cooldown_minutes: 10,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await handleQuotaExhaustion('agy', 'only@gmail.com', 10);
|
||||
|
||||
// Should return gracefully with null switched
|
||||
expect(result.switchedTo).toBeNull();
|
||||
expect(result.reason).toContain('no alternatives');
|
||||
});
|
||||
|
||||
it('should write warning to stderr', async () => {
|
||||
writeRegistry({
|
||||
agy: {
|
||||
default: 'exhausted@gmail.com',
|
||||
accounts: {
|
||||
'exhausted@gmail.com': {
|
||||
email: 'exhausted@gmail.com',
|
||||
tokenFile: 'agy-exhausted.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
writeConfig({
|
||||
mode: 'auto',
|
||||
auto: {
|
||||
tier_priority: ['ultra', 'pro'],
|
||||
exhaustion_threshold: 5,
|
||||
cooldown_minutes: 10,
|
||||
preflight_check: true,
|
||||
},
|
||||
runtime_monitor: {
|
||||
enabled: true,
|
||||
normal_interval_seconds: 300,
|
||||
critical_interval_seconds: 60,
|
||||
warn_threshold: 20,
|
||||
exhaustion_threshold: 5,
|
||||
cooldown_minutes: 10,
|
||||
},
|
||||
});
|
||||
|
||||
const stderrWrites: string[] = [];
|
||||
const originalWrite = process.stderr.write;
|
||||
process.stderr.write = ((chunk: string) => {
|
||||
stderrWrites.push(chunk);
|
||||
return true;
|
||||
}) as any;
|
||||
|
||||
await handleQuotaExhaustion('agy', 'exhausted@gmail.com', 10);
|
||||
|
||||
process.stderr.write = originalWrite;
|
||||
|
||||
const fullOutput = stderrWrites.join('');
|
||||
// Should contain exhaustion indicator
|
||||
expect(fullOutput).toContain('[X]');
|
||||
});
|
||||
|
||||
it('should complete without throwing', async () => {
|
||||
writeRegistry({
|
||||
agy: {
|
||||
default: 'test@gmail.com',
|
||||
accounts: {
|
||||
'test@gmail.com': {
|
||||
email: 'test@gmail.com',
|
||||
tokenFile: 'agy-test.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
writeConfig({
|
||||
mode: 'auto',
|
||||
auto: {
|
||||
tier_priority: ['ultra', 'pro'],
|
||||
exhaustion_threshold: 5,
|
||||
cooldown_minutes: 5,
|
||||
preflight_check: true,
|
||||
},
|
||||
runtime_monitor: {
|
||||
enabled: true,
|
||||
normal_interval_seconds: 300,
|
||||
critical_interval_seconds: 60,
|
||||
warn_threshold: 20,
|
||||
exhaustion_threshold: 5,
|
||||
cooldown_minutes: 5,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await handleQuotaExhaustion('agy', 'test@gmail.com', 5);
|
||||
expect(result).toBeDefined();
|
||||
expect(result.switchedTo).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -552,4 +552,108 @@ auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth"
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('oauth-model-alias fork:true', () => {
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
let testDir;
|
||||
let originalCcsHome;
|
||||
let regenerateConfig;
|
||||
|
||||
beforeEach(() => {
|
||||
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-test-fork-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = testDir;
|
||||
|
||||
delete require.cache[require.resolve('../../../dist/cliproxy/config-generator')];
|
||||
delete require.cache[require.resolve('../../../dist/utils/config-manager')];
|
||||
const configGenerator = require('../../../dist/cliproxy/config-generator');
|
||||
regenerateConfig = configGenerator.regenerateConfig;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
if (testDir && fs.existsSync(testDir)) {
|
||||
fs.rmSync(testDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('generates fork:true for Claude model aliases', () => {
|
||||
regenerateConfig();
|
||||
|
||||
const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy');
|
||||
const config = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8');
|
||||
|
||||
// Claude aliases should have fork: true
|
||||
assert(config.includes('claude-sonnet-4-5'), 'Should include Claude sonnet model');
|
||||
assert(config.includes('fork: true'), 'Should include fork: true for Claude aliases');
|
||||
|
||||
// Verify fork: true appears after each Claude alias entry
|
||||
const lines = config.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].includes('alias: gemini-claude-')) {
|
||||
assert(
|
||||
lines[i + 1] && lines[i + 1].trim() === 'fork: true',
|
||||
`fork: true should follow Claude alias at line ${i}: ${lines[i]}`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('does not generate fork:true for non-Claude aliases', () => {
|
||||
regenerateConfig();
|
||||
|
||||
const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy');
|
||||
const config = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8');
|
||||
|
||||
// Gemini aliases should NOT have fork: true
|
||||
const lines = config.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].includes('alias: gemini-3-') || lines[i].includes('alias: gemini-2.5-')) {
|
||||
const nextLine = lines[i + 1] || '';
|
||||
assert(
|
||||
!nextLine.trim().startsWith('fork:'),
|
||||
`Gemini alias should not have fork: ${lines[i]}`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves user-added aliases with fork during regeneration', () => {
|
||||
const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy');
|
||||
fs.mkdirSync(cliproxyDir, { recursive: true });
|
||||
|
||||
const initialConfig = `# CLIProxyAPI config generated by CCS v6
|
||||
port: 8317
|
||||
api-keys:
|
||||
- "ccs-internal-managed"
|
||||
auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth"
|
||||
oauth-model-alias:
|
||||
antigravity:
|
||||
- name: custom-model
|
||||
alias: my-custom-alias
|
||||
fork: true
|
||||
`;
|
||||
fs.writeFileSync(path.join(cliproxyDir, 'config.yaml'), initialConfig);
|
||||
|
||||
regenerateConfig();
|
||||
|
||||
const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8');
|
||||
assert(newConfig.includes('custom-model'), 'Should preserve custom alias name');
|
||||
assert(newConfig.includes('my-custom-alias'), 'Should preserve custom alias');
|
||||
|
||||
// Check fork is preserved for user alias
|
||||
const lines = newConfig.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].includes('alias: my-custom-alias')) {
|
||||
assert(
|
||||
lines[i + 1] && lines[i + 1].trim() === 'fork: true',
|
||||
'Should preserve fork: true for user-added alias'
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Runtime Quota Monitor Unit Tests
|
||||
*
|
||||
* Tests the quota monitor lifecycle:
|
||||
* - startQuotaMonitor / stopQuotaMonitor behavior
|
||||
* - No-op conditions for non-agy, manual mode, disabled config
|
||||
* - Idempotent stopQuotaMonitor
|
||||
*/
|
||||
|
||||
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 { startQuotaMonitor, stopQuotaMonitor, clearQuotaCache } from '../../../src/cliproxy/quota-manager';
|
||||
|
||||
// Setup test isolation
|
||||
let tmpDir: string;
|
||||
let origCcsHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-test-monitor-'));
|
||||
origCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tmpDir;
|
||||
clearQuotaCache(); // Clean cache between tests
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stopQuotaMonitor(); // Clean up any active timers
|
||||
clearQuotaCache();
|
||||
if (origCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = origCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('Runtime Quota Monitor', () => {
|
||||
describe('startQuotaMonitor', () => {
|
||||
it('should accept non-agy provider without throwing', () => {
|
||||
// Non-agy providers should be silently ignored
|
||||
expect(() => {
|
||||
startQuotaMonitor('gemini', 'test@gmail.com');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should accept agy provider without throwing', () => {
|
||||
// Setup config
|
||||
const configDir = path.join(tmpDir, '.ccs', 'config');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(configDir, 'unified-config.json'),
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
quota_management: {
|
||||
mode: 'auto',
|
||||
runtime_monitor: {
|
||||
enabled: false, // Disabled to avoid actual polling
|
||||
normal_interval_seconds: 300,
|
||||
critical_interval_seconds: 60,
|
||||
warn_threshold: 20,
|
||||
exhaustion_threshold: 5,
|
||||
cooldown_minutes: 5,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(() => {
|
||||
startQuotaMonitor('agy', 'test@gmail.com');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should be no-op when config missing or no quota_management', () => {
|
||||
// No config file — should not throw
|
||||
expect(() => {
|
||||
startQuotaMonitor('agy', 'test@gmail.com');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle manual mode gracefully', () => {
|
||||
const configDir = path.join(tmpDir, '.ccs', 'config');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(configDir, 'unified-config.json'),
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
quota_management: {
|
||||
mode: 'manual',
|
||||
runtime_monitor: {
|
||||
enabled: true,
|
||||
normal_interval_seconds: 300,
|
||||
critical_interval_seconds: 60,
|
||||
warn_threshold: 20,
|
||||
exhaustion_threshold: 5,
|
||||
cooldown_minutes: 5,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(() => {
|
||||
startQuotaMonitor('agy', 'test@gmail.com');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle disabled monitor gracefully', () => {
|
||||
const configDir = path.join(tmpDir, '.ccs', 'config');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(configDir, 'unified-config.json'),
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
quota_management: {
|
||||
mode: 'auto',
|
||||
runtime_monitor: {
|
||||
enabled: false,
|
||||
normal_interval_seconds: 300,
|
||||
critical_interval_seconds: 60,
|
||||
warn_threshold: 20,
|
||||
exhaustion_threshold: 5,
|
||||
cooldown_minutes: 5,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(() => {
|
||||
startQuotaMonitor('agy', 'test@gmail.com');
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('stopQuotaMonitor', () => {
|
||||
it('should be idempotent', () => {
|
||||
expect(() => {
|
||||
stopQuotaMonitor();
|
||||
stopQuotaMonitor();
|
||||
stopQuotaMonitor();
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should complete safely when called without prior start', () => {
|
||||
// No prior startQuotaMonitor call
|
||||
expect(() => {
|
||||
stopQuotaMonitor();
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle multiple start/stop cycles', () => {
|
||||
const configDir = path.join(tmpDir, '.ccs', 'config');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(configDir, 'unified-config.json'),
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
quota_management: {
|
||||
mode: 'auto',
|
||||
runtime_monitor: {
|
||||
enabled: false,
|
||||
normal_interval_seconds: 300,
|
||||
critical_interval_seconds: 60,
|
||||
warn_threshold: 20,
|
||||
exhaustion_threshold: 5,
|
||||
cooldown_minutes: 5,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(() => {
|
||||
startQuotaMonitor('agy', 'test@gmail.com');
|
||||
stopQuotaMonitor();
|
||||
startQuotaMonitor('agy', 'test@gmail.com');
|
||||
stopQuotaMonitor();
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,448 @@
|
||||
/**
|
||||
* Unit tests for Cursor authentication module
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import type { CursorCredentials } from '../../../src/cursor/types';
|
||||
import {
|
||||
validateToken,
|
||||
extractUserInfo,
|
||||
saveCredentials,
|
||||
loadCredentials,
|
||||
checkAuthStatus,
|
||||
deleteCredentials,
|
||||
autoDetectTokens,
|
||||
} from '../../../src/cursor/cursor-auth';
|
||||
|
||||
// Test isolation
|
||||
let originalCcsHome: string | undefined;
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
// Save original CCS_HOME
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
|
||||
// Create temp directory for test isolation
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cursor-test-'));
|
||||
process.env.CCS_HOME = tempDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original CCS_HOME
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
|
||||
// Clean up temp directory
|
||||
if (fs.existsSync(tempDir)) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('validateToken', () => {
|
||||
it('should accept valid token and machineId', () => {
|
||||
const token = 'a'.repeat(50); // 50 chars minimum
|
||||
const machineId = 'a'.repeat(32); // 32 hex chars
|
||||
expect(validateToken(token, machineId)).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject 49-char token (too short)', () => {
|
||||
const token = 'a'.repeat(49); // Just under minimum
|
||||
const machineId = 'a'.repeat(32);
|
||||
expect(validateToken(token, machineId)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject 31-char hex UUID (too short)', () => {
|
||||
const token = 'a'.repeat(50);
|
||||
const machineId = 'a'.repeat(31); // Just under 32
|
||||
expect(validateToken(token, machineId)).toBe(false);
|
||||
});
|
||||
|
||||
it('should accept UUID with hyphens (strips them)', () => {
|
||||
const token = 'a'.repeat(50);
|
||||
const machineId = '12345678-1234-1234-1234-123456789abc'; // 36 chars with hyphens
|
||||
expect(validateToken(token, machineId)).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject empty token', () => {
|
||||
const machineId = 'a'.repeat(32);
|
||||
expect(validateToken('', machineId)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject empty machineId', () => {
|
||||
const token = 'a'.repeat(50);
|
||||
expect(validateToken(token, '')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject non-hex characters in machineId', () => {
|
||||
const token = 'a'.repeat(50);
|
||||
const machineId = 'g'.repeat(32); // 'g' is not valid hex
|
||||
expect(validateToken(token, machineId)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractUserInfo', () => {
|
||||
it('should extract email and sub from valid JWT', () => {
|
||||
// JWT: {"email":"user@example.com","sub":"12345","exp":1234567890}
|
||||
const payload = Buffer.from(
|
||||
JSON.stringify({ email: 'user@example.com', sub: '12345', exp: 1234567890 })
|
||||
).toString('base64');
|
||||
const token = `header.${payload}.signature`;
|
||||
|
||||
const result = extractUserInfo(token);
|
||||
expect(result).toEqual({
|
||||
email: 'user@example.com',
|
||||
userId: '12345',
|
||||
exp: 1234567890,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined email when only sub claim exists', () => {
|
||||
// JWT: {"sub":"uuid-12345","exp":1234567890}
|
||||
const payload = Buffer.from(JSON.stringify({ sub: 'uuid-12345', exp: 1234567890 })).toString(
|
||||
'base64'
|
||||
);
|
||||
const token = `header.${payload}.signature`;
|
||||
|
||||
const result = extractUserInfo(token);
|
||||
expect(result).toEqual({
|
||||
email: undefined,
|
||||
userId: 'uuid-12345',
|
||||
exp: 1234567890,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null for non-JWT token', () => {
|
||||
const token = 'a'.repeat(50); // Plain token
|
||||
const result = extractUserInfo(token);
|
||||
expect(result).toBe(null);
|
||||
});
|
||||
|
||||
it('should return null for malformed base64', () => {
|
||||
const token = 'header.!!!invalid-base64!!!.signature';
|
||||
const result = extractUserInfo(token);
|
||||
expect(result).toBe(null);
|
||||
});
|
||||
|
||||
it('should handle JWT with user_id instead of sub', () => {
|
||||
// JWT: {"email":"user@example.com","user_id":"67890"}
|
||||
const payload = Buffer.from(
|
||||
JSON.stringify({ email: 'user@example.com', user_id: '67890' })
|
||||
).toString('base64');
|
||||
const token = `header.${payload}.signature`;
|
||||
|
||||
const result = extractUserInfo(token);
|
||||
expect(result).toEqual({
|
||||
email: 'user@example.com',
|
||||
userId: '67890',
|
||||
exp: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null for JWT with no meaningful claims', () => {
|
||||
// JWT: {"iat":1234567890} (only issued-at, no email/sub/exp)
|
||||
const payload = Buffer.from(JSON.stringify({ iat: 1234567890 })).toString('base64');
|
||||
const token = `header.${payload}.signature`;
|
||||
|
||||
const result = extractUserInfo(token);
|
||||
expect(result).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveCredentials and loadCredentials', () => {
|
||||
it('should save and load credentials successfully', () => {
|
||||
const credentials: CursorCredentials = {
|
||||
accessToken: 'a'.repeat(50),
|
||||
machineId: 'b'.repeat(32),
|
||||
authMethod: 'auto-detect',
|
||||
importedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
saveCredentials(credentials);
|
||||
const loaded = loadCredentials();
|
||||
|
||||
expect(loaded).toEqual(credentials);
|
||||
});
|
||||
|
||||
it('should return null when no credentials file exists', () => {
|
||||
const loaded = loadCredentials();
|
||||
expect(loaded).toBe(null);
|
||||
});
|
||||
|
||||
it('should create directory with restrictive permissions', () => {
|
||||
const credentials: CursorCredentials = {
|
||||
accessToken: 'a'.repeat(50),
|
||||
machineId: 'b'.repeat(32),
|
||||
authMethod: 'manual',
|
||||
importedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
saveCredentials(credentials);
|
||||
|
||||
// CCS_HOME is set to tempDir, but getCcsDir() appends '.ccs' to it
|
||||
const credDir = path.join(tempDir, '.ccs', 'cursor');
|
||||
expect(fs.existsSync(credDir)).toBe(true);
|
||||
|
||||
// Check directory permissions (skip on Windows)
|
||||
if (process.platform !== 'win32') {
|
||||
const stats = fs.statSync(credDir);
|
||||
const mode = stats.mode & 0o777;
|
||||
expect(mode).toBe(0o700);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return null for invalid JSON in credentials file', () => {
|
||||
// CCS_HOME is set to tempDir, getCcsDir() returns path.join(tempDir, '.ccs')
|
||||
const credDir = path.join(tempDir, '.ccs', 'cursor');
|
||||
const credPath = path.join(credDir, 'credentials.json');
|
||||
fs.mkdirSync(credDir, { recursive: true });
|
||||
fs.writeFileSync(credPath, 'invalid json{{{');
|
||||
|
||||
const loaded = loadCredentials();
|
||||
expect(loaded).toBe(null);
|
||||
});
|
||||
|
||||
it('should return null for credentials missing required fields', () => {
|
||||
const credDir = path.join(tempDir, '.ccs', 'cursor');
|
||||
const credPath = path.join(credDir, 'credentials.json');
|
||||
fs.mkdirSync(credDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
credPath,
|
||||
JSON.stringify({
|
||||
accessToken: 'token',
|
||||
// Missing machineId, authMethod, importedAt
|
||||
})
|
||||
);
|
||||
|
||||
const loaded = loadCredentials();
|
||||
expect(loaded).toBe(null);
|
||||
});
|
||||
|
||||
it('should return null for credentials with wrong types', () => {
|
||||
const credDir = path.join(tempDir, '.ccs', 'cursor');
|
||||
const credPath = path.join(credDir, 'credentials.json');
|
||||
fs.mkdirSync(credDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
credPath,
|
||||
JSON.stringify({
|
||||
accessToken: 123, // Wrong type (number instead of string)
|
||||
machineId: 'abc',
|
||||
authMethod: 'auto-detect',
|
||||
importedAt: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
|
||||
const loaded = loadCredentials();
|
||||
expect(loaded).toBe(null);
|
||||
});
|
||||
|
||||
it('should return null for invalid authMethod', () => {
|
||||
const credDir = path.join(tempDir, '.ccs', 'cursor');
|
||||
const credPath = path.join(credDir, 'credentials.json');
|
||||
fs.mkdirSync(credDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
credPath,
|
||||
JSON.stringify({
|
||||
accessToken: 'token',
|
||||
machineId: 'abc',
|
||||
authMethod: 'invalid-method', // Invalid authMethod
|
||||
importedAt: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
|
||||
const loaded = loadCredentials();
|
||||
expect(loaded).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkAuthStatus', () => {
|
||||
it('should return not authenticated when no credentials exist', () => {
|
||||
const status = checkAuthStatus();
|
||||
expect(status.authenticated).toBe(false);
|
||||
expect(status.credentials).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return authenticated for valid credentials', () => {
|
||||
const credentials: CursorCredentials = {
|
||||
accessToken: 'a'.repeat(50),
|
||||
machineId: 'b'.repeat(32),
|
||||
authMethod: 'auto-detect',
|
||||
importedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
saveCredentials(credentials);
|
||||
const status = checkAuthStatus();
|
||||
|
||||
expect(status.authenticated).toBe(true);
|
||||
expect(status.credentials).toEqual(credentials);
|
||||
expect(status.expired).toBe(false);
|
||||
expect(status.tokenAge).toBeDefined();
|
||||
expect(status.tokenAge).toBeLessThan(1); // Just imported
|
||||
});
|
||||
|
||||
it('should detect expired credentials (importedAt > 24h ago)', () => {
|
||||
// Create credentials from 25 hours ago
|
||||
const past = new Date();
|
||||
past.setHours(past.getHours() - 25);
|
||||
|
||||
const credentials: CursorCredentials = {
|
||||
accessToken: 'a'.repeat(50),
|
||||
machineId: 'b'.repeat(32),
|
||||
authMethod: 'manual',
|
||||
importedAt: past.toISOString(),
|
||||
};
|
||||
|
||||
saveCredentials(credentials);
|
||||
const status = checkAuthStatus();
|
||||
|
||||
expect(status.authenticated).toBe(true);
|
||||
expect(status.expired).toBe(true);
|
||||
expect(status.tokenAge).toBeGreaterThanOrEqual(24);
|
||||
});
|
||||
|
||||
it('should return not authenticated for invalid token format', () => {
|
||||
const credentials: CursorCredentials = {
|
||||
accessToken: 'short', // Invalid (too short)
|
||||
machineId: 'b'.repeat(32),
|
||||
authMethod: 'manual',
|
||||
importedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
saveCredentials(credentials);
|
||||
const status = checkAuthStatus();
|
||||
|
||||
expect(status.authenticated).toBe(false);
|
||||
});
|
||||
|
||||
it('should use JWT exp claim when available', () => {
|
||||
// Create JWT token that expired 1 hour ago
|
||||
const expiredTime = Math.floor(Date.now() / 1000) - 3600;
|
||||
const payload = Buffer.from(
|
||||
JSON.stringify({ email: 'test@example.com', sub: '123', exp: expiredTime })
|
||||
).toString('base64');
|
||||
const jwtToken = `header.${payload}.signature`;
|
||||
|
||||
const credentials: CursorCredentials = {
|
||||
accessToken: jwtToken,
|
||||
machineId: 'b'.repeat(32),
|
||||
authMethod: 'auto-detect',
|
||||
importedAt: new Date().toISOString(), // Recent import
|
||||
};
|
||||
|
||||
saveCredentials(credentials);
|
||||
const status = checkAuthStatus();
|
||||
|
||||
expect(status.authenticated).toBe(true);
|
||||
expect(status.expired).toBe(true); // Should detect expiry from JWT exp
|
||||
});
|
||||
|
||||
it('should handle invalid importedAt date gracefully', () => {
|
||||
// Create credentials with valid format but garbage date value
|
||||
const credentials: CursorCredentials = {
|
||||
accessToken: 'a'.repeat(50),
|
||||
machineId: 'b'.repeat(32),
|
||||
authMethod: 'manual',
|
||||
importedAt: 'invalid-date-garbage-2026-99-99T99:99:99Z',
|
||||
};
|
||||
|
||||
saveCredentials(credentials);
|
||||
const status = checkAuthStatus();
|
||||
|
||||
// Should still authenticate if token format is valid
|
||||
expect(status.authenticated).toBe(true);
|
||||
// tokenAge should be undefined due to invalid date (NaN from getTime())
|
||||
expect(status.tokenAge).toBeUndefined();
|
||||
// expired should be false (defaults to false when date parsing fails)
|
||||
expect(status.expired).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteCredentials', () => {
|
||||
it('should delete existing credentials file and return true', () => {
|
||||
const credentials: CursorCredentials = {
|
||||
accessToken: 'a'.repeat(50),
|
||||
machineId: 'b'.repeat(32),
|
||||
authMethod: 'auto-detect',
|
||||
importedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
saveCredentials(credentials);
|
||||
expect(loadCredentials()).not.toBe(null);
|
||||
|
||||
const result = deleteCredentials();
|
||||
expect(result).toBe(true);
|
||||
expect(loadCredentials()).toBe(null);
|
||||
});
|
||||
|
||||
it('should return false when credentials file does not exist', () => {
|
||||
const result = deleteCredentials();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle multiple delete calls gracefully', () => {
|
||||
const credentials: CursorCredentials = {
|
||||
accessToken: 'a'.repeat(50),
|
||||
machineId: 'b'.repeat(32),
|
||||
authMethod: 'manual',
|
||||
importedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
saveCredentials(credentials);
|
||||
|
||||
// First delete should succeed
|
||||
expect(deleteCredentials()).toBe(true);
|
||||
|
||||
// Second delete should return false (already deleted)
|
||||
expect(deleteCredentials()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('autoDetectTokens', () => {
|
||||
it('should return not found for Windows platform', () => {
|
||||
// Save original platform
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
// Mock Windows platform
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'win32',
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const result = autoDetectTokens();
|
||||
|
||||
expect(result.found).toBe(false);
|
||||
expect(result.error).toContain('not supported on Windows');
|
||||
|
||||
// Restore original platform
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return not found when database file does not exist', () => {
|
||||
// Skip on Windows (already covered by previous test)
|
||||
if (process.platform === 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = autoDetectTokens();
|
||||
|
||||
// Should fail because Cursor database doesn't exist in test environment
|
||||
expect(result.found).toBe(false);
|
||||
expect(result.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have found property in return type', () => {
|
||||
const result = autoDetectTokens();
|
||||
|
||||
// Verify return type structure
|
||||
expect(result).toHaveProperty('found');
|
||||
expect(typeof result.found).toBe('boolean');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,657 @@
|
||||
/**
|
||||
* Cursor Protobuf Module Unit Tests
|
||||
* Tests encoder, decoder, translator, and executor components
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import {
|
||||
encodeVarint,
|
||||
encodeField,
|
||||
wrapConnectRPCFrame,
|
||||
concatArrays,
|
||||
} from '../../../src/cursor/cursor-protobuf-encoder';
|
||||
import {
|
||||
decodeVarint,
|
||||
decodeField,
|
||||
parseConnectRPCFrame,
|
||||
} from '../../../src/cursor/cursor-protobuf-decoder';
|
||||
import { buildCursorRequest } from '../../../src/cursor/cursor-translator';
|
||||
import { generateCursorBody } from '../../../src/cursor/cursor-protobuf';
|
||||
import { CursorExecutor } from '../../../src/cursor/cursor-executor';
|
||||
import { WIRE_TYPE, FIELD } from '../../../src/cursor/cursor-protobuf-schema';
|
||||
|
||||
describe('Protobuf Encoding/Decoding', () => {
|
||||
describe('encodeVarint / decodeVarint round-trip', () => {
|
||||
it('should encode and decode 0', () => {
|
||||
const encoded = encodeVarint(0);
|
||||
const [decoded, offset] = decodeVarint(encoded, 0);
|
||||
expect(decoded).toBe(0);
|
||||
expect(offset).toBe(1);
|
||||
});
|
||||
|
||||
it('should encode and decode 1', () => {
|
||||
const encoded = encodeVarint(1);
|
||||
const [decoded, offset] = decodeVarint(encoded, 0);
|
||||
expect(decoded).toBe(1);
|
||||
expect(offset).toBe(1);
|
||||
});
|
||||
|
||||
it('should encode and decode 127', () => {
|
||||
const encoded = encodeVarint(127);
|
||||
const [decoded, offset] = decodeVarint(encoded, 0);
|
||||
expect(decoded).toBe(127);
|
||||
expect(offset).toBe(1);
|
||||
});
|
||||
|
||||
it('should encode and decode 128', () => {
|
||||
const encoded = encodeVarint(128);
|
||||
const [decoded, offset] = decodeVarint(encoded, 0);
|
||||
expect(decoded).toBe(128);
|
||||
expect(offset).toBe(2);
|
||||
});
|
||||
|
||||
it('should encode and decode 16383', () => {
|
||||
const encoded = encodeVarint(16383);
|
||||
const [decoded, offset] = decodeVarint(encoded, 0);
|
||||
expect(decoded).toBe(16383);
|
||||
expect(offset).toBe(2);
|
||||
});
|
||||
|
||||
it('should encode and decode 0xFFFFFFFF', () => {
|
||||
const encoded = encodeVarint(0xffffffff);
|
||||
const [decoded, offset] = decodeVarint(encoded, 0);
|
||||
expect(decoded).toBe(0xffffffff);
|
||||
expect(offset).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('encodeField / decodeField round-trip', () => {
|
||||
it('should encode and decode VARINT field', () => {
|
||||
const fieldNum = 5;
|
||||
const value = 42;
|
||||
const encoded = encodeField(fieldNum, WIRE_TYPE.VARINT, value);
|
||||
|
||||
const [decodedFieldNum, wireType, decodedValue, offset] = decodeField(encoded, 0);
|
||||
expect(decodedFieldNum).toBe(fieldNum);
|
||||
expect(wireType).toBe(WIRE_TYPE.VARINT);
|
||||
expect(decodedValue).toBe(value);
|
||||
expect(offset).toBe(encoded.length);
|
||||
});
|
||||
|
||||
it('should encode and decode LEN field with string', () => {
|
||||
const fieldNum = 10;
|
||||
const value = 'Hello, World!';
|
||||
const encoded = encodeField(fieldNum, WIRE_TYPE.LEN, value);
|
||||
|
||||
const [decodedFieldNum, wireType, decodedValue, offset] = decodeField(encoded, 0);
|
||||
expect(decodedFieldNum).toBe(fieldNum);
|
||||
expect(wireType).toBe(WIRE_TYPE.LEN);
|
||||
expect(new TextDecoder().decode(decodedValue as Uint8Array)).toBe(value);
|
||||
expect(offset).toBe(encoded.length);
|
||||
});
|
||||
|
||||
it('should encode and decode LEN field with binary data', () => {
|
||||
const fieldNum = 15;
|
||||
const value = new Uint8Array([1, 2, 3, 4, 5]);
|
||||
const encoded = encodeField(fieldNum, WIRE_TYPE.LEN, value);
|
||||
|
||||
const [decodedFieldNum, wireType, decodedValue, offset] = decodeField(encoded, 0);
|
||||
expect(decodedFieldNum).toBe(fieldNum);
|
||||
expect(wireType).toBe(WIRE_TYPE.LEN);
|
||||
expect(decodedValue).toEqual(value);
|
||||
expect(offset).toBe(encoded.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('wrapConnectRPCFrame / parseConnectRPCFrame round-trip', () => {
|
||||
it('should wrap and parse uncompressed frame', () => {
|
||||
const payload = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
|
||||
const frame = wrapConnectRPCFrame(payload, false);
|
||||
|
||||
const parsed = parseConnectRPCFrame(Buffer.from(frame));
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed!.flags).toBe(0x00);
|
||||
expect(parsed!.length).toBe(payload.length);
|
||||
expect(parsed!.payload).toEqual(payload);
|
||||
expect(parsed!.consumed).toBe(5 + payload.length);
|
||||
});
|
||||
|
||||
it('should wrap and parse compressed frame', () => {
|
||||
const payload = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
|
||||
const frame = wrapConnectRPCFrame(payload, true);
|
||||
|
||||
const parsed = parseConnectRPCFrame(Buffer.from(frame));
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed!.flags).toBe(0x01); // GZIP flag
|
||||
expect(parsed!.payload).toEqual(payload); // Should be decompressed
|
||||
});
|
||||
|
||||
it('should handle incomplete frame', () => {
|
||||
const partial = new Uint8Array([0x00, 0x00, 0x00]); // Only 3 bytes
|
||||
const parsed = parseConnectRPCFrame(Buffer.from(partial));
|
||||
expect(parsed).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('concatArrays', () => {
|
||||
it('should concatenate multiple arrays', () => {
|
||||
const arr1 = new Uint8Array([1, 2, 3]);
|
||||
const arr2 = new Uint8Array([4, 5]);
|
||||
const arr3 = new Uint8Array([6, 7, 8, 9]);
|
||||
|
||||
const result = concatArrays(arr1, arr2, arr3);
|
||||
expect(result).toEqual(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9]));
|
||||
});
|
||||
|
||||
it('should handle empty arrays', () => {
|
||||
const arr1 = new Uint8Array([1, 2]);
|
||||
const arr2 = new Uint8Array([]);
|
||||
const arr3 = new Uint8Array([3, 4]);
|
||||
|
||||
const result = concatArrays(arr1, arr2, arr3);
|
||||
expect(result).toEqual(new Uint8Array([1, 2, 3, 4]));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Message Translation', () => {
|
||||
describe('buildCursorRequest', () => {
|
||||
it('should convert system message to user with prefix', () => {
|
||||
const result = buildCursorRequest(
|
||||
'gpt-4',
|
||||
{
|
||||
messages: [{ role: 'system', content: 'You are a helpful assistant.' }],
|
||||
},
|
||||
false,
|
||||
{}
|
||||
);
|
||||
|
||||
expect(result.messages).toHaveLength(1);
|
||||
expect(result.messages[0].role).toBe('user');
|
||||
expect(result.messages[0].content).toContain('[System Instructions]');
|
||||
expect(result.messages[0].content).toContain('You are a helpful assistant.');
|
||||
});
|
||||
|
||||
it('should keep user and assistant messages', () => {
|
||||
const result = buildCursorRequest(
|
||||
'gpt-4',
|
||||
{
|
||||
messages: [
|
||||
{ role: 'user', content: 'Hello' },
|
||||
{ role: 'assistant', content: 'Hi there!' },
|
||||
],
|
||||
},
|
||||
false,
|
||||
{}
|
||||
);
|
||||
|
||||
expect(result.messages).toHaveLength(2);
|
||||
expect(result.messages[0].role).toBe('user');
|
||||
expect(result.messages[0].content).toBe('Hello');
|
||||
expect(result.messages[1].role).toBe('assistant');
|
||||
expect(result.messages[1].content).toBe('Hi there!');
|
||||
});
|
||||
|
||||
it('should handle assistant messages with tool_calls', () => {
|
||||
const result = buildCursorRequest(
|
||||
'gpt-4',
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_123',
|
||||
type: 'function',
|
||||
function: { name: 'get_weather', arguments: '{"city":"NYC"}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
false,
|
||||
{}
|
||||
);
|
||||
|
||||
expect(result.messages).toHaveLength(1);
|
||||
expect(result.messages[0].role).toBe('assistant');
|
||||
expect(result.messages[0].tool_calls).toHaveLength(1);
|
||||
expect(result.messages[0].tool_calls![0].id).toBe('call_123');
|
||||
expect(result.messages[0].tool_calls![0].function.name).toBe('get_weather');
|
||||
});
|
||||
|
||||
it('should accumulate tool results', () => {
|
||||
const result = buildCursorRequest(
|
||||
'gpt-4',
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_123',
|
||||
type: 'function',
|
||||
function: { name: 'get_weather', arguments: '{"city":"NYC"}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
content: '{"temperature": 72}',
|
||||
name: 'get_weather',
|
||||
tool_call_id: 'call_123',
|
||||
},
|
||||
{ role: 'user', content: 'What is the weather?' },
|
||||
],
|
||||
},
|
||||
false,
|
||||
{}
|
||||
);
|
||||
|
||||
expect(result.messages).toHaveLength(2);
|
||||
// Tool result should be attached to next message
|
||||
expect(result.messages[1].tool_results).toBeDefined();
|
||||
expect(result.messages[1].tool_results).toHaveLength(1);
|
||||
expect(result.messages[1].tool_results![0].tool_call_id).toBe('call_123');
|
||||
});
|
||||
|
||||
it('should handle array content format', () => {
|
||||
const result = buildCursorRequest(
|
||||
'gpt-4',
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'Hello' },
|
||||
{ type: 'text', text: ' World' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
false,
|
||||
{}
|
||||
);
|
||||
|
||||
expect(result.messages).toHaveLength(1);
|
||||
expect(result.messages[0].content).toBe('Hello World');
|
||||
});
|
||||
|
||||
it('should handle system message with array content format', () => {
|
||||
const result = buildCursorRequest(
|
||||
'gpt-4',
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: [
|
||||
{ type: 'text', text: 'System instruction part 1' },
|
||||
{ type: 'text', text: ' part 2' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
false,
|
||||
{}
|
||||
);
|
||||
|
||||
expect(result.messages).toHaveLength(1);
|
||||
expect(result.messages[0].role).toBe('user');
|
||||
expect(result.messages[0].content).toBe('[System Instructions]\nSystem instruction part 1 part 2');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Request Encoding', () => {
|
||||
describe('generateCursorBody', () => {
|
||||
it('should encode basic text message', () => {
|
||||
const result = generateCursorBody([{ role: 'user', content: 'Hello' }], 'gpt-4', [], null);
|
||||
|
||||
expect(result).toBeInstanceOf(Uint8Array);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should encode message with tools', () => {
|
||||
const tools = [
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'get_weather',
|
||||
description: 'Get weather data',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
city: { type: 'string' },
|
||||
},
|
||||
required: ['city'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const result = generateCursorBody([{ role: 'user', content: 'What is the weather?' }], 'gpt-4', tools, null);
|
||||
|
||||
expect(result).toBeInstanceOf(Uint8Array);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle malformed frame gracefully', () => {
|
||||
const executor = new CursorExecutor();
|
||||
|
||||
// Incomplete frame header (only 3 bytes instead of 5)
|
||||
const incompleteFrame = Buffer.from([0x00, 0x00, 0x00]);
|
||||
|
||||
const result = executor.transformProtobufToJSON(incompleteFrame, 'gpt-4', {
|
||||
messages: [],
|
||||
});
|
||||
|
||||
// Should return valid response even with malformed input
|
||||
expect(result.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should handle truncated payload', () => {
|
||||
const executor = new CursorExecutor();
|
||||
|
||||
// Frame header says payload is 100 bytes but only 5 bytes follow
|
||||
const truncatedFrame = Buffer.from([0x00, 0x00, 0x00, 0x00, 0x64, 0x01, 0x02, 0x03, 0x04, 0x05]);
|
||||
|
||||
const result = executor.transformProtobufToJSON(truncatedFrame, 'gpt-4', {
|
||||
messages: [],
|
||||
});
|
||||
|
||||
// Should handle gracefully
|
||||
expect(result.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should handle multi-frame buffer', () => {
|
||||
const executor = new CursorExecutor();
|
||||
|
||||
// Create two simple frames
|
||||
const frame1 = wrapConnectRPCFrame(
|
||||
encodeField(FIELD.RESPONSE_TEXT, WIRE_TYPE.LEN, 'Frame 1'),
|
||||
false
|
||||
);
|
||||
const frame2 = wrapConnectRPCFrame(
|
||||
encodeField(FIELD.RESPONSE_TEXT, WIRE_TYPE.LEN, ' Frame 2'),
|
||||
false
|
||||
);
|
||||
|
||||
// Concatenate them
|
||||
const multiFrame = Buffer.concat([Buffer.from(frame1), Buffer.from(frame2)]);
|
||||
|
||||
const result = executor.transformProtobufToJSON(multiFrame, 'gpt-4', {
|
||||
messages: [],
|
||||
});
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('CursorExecutor', () => {
|
||||
const executor = new CursorExecutor();
|
||||
|
||||
describe('generateChecksum', () => {
|
||||
it('should generate valid checksum format', () => {
|
||||
const machineId = 'test-machine-id';
|
||||
const checksum = executor.generateChecksum(machineId);
|
||||
|
||||
// Should end with machine ID
|
||||
expect(checksum.endsWith(machineId)).toBe(true);
|
||||
|
||||
// Should have base64url-like prefix (8 chars from 6 bytes)
|
||||
const prefix = checksum.slice(0, -machineId.length);
|
||||
expect(prefix.length).toBe(8);
|
||||
expect(/^[A-Za-z0-9_-]+$/.test(prefix)).toBe(true);
|
||||
});
|
||||
|
||||
it('should generate valid checksums at different call times', async () => {
|
||||
const machineId = 'test-machine-id';
|
||||
const checksum1 = executor.generateChecksum(machineId);
|
||||
|
||||
// Wait to ensure timestamp may change (though timestamp granularity is ~16 min)
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
const checksum2 = executor.generateChecksum(machineId);
|
||||
|
||||
// Verify both checksums are valid (may be same due to timestamp granularity)
|
||||
expect(checksum1.endsWith(machineId)).toBe(true);
|
||||
expect(checksum2.endsWith(machineId)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildHeaders', () => {
|
||||
it('should generate all required headers', () => {
|
||||
const credentials = {
|
||||
accessToken: 'test-token',
|
||||
machineId: 'test-machine-id',
|
||||
};
|
||||
|
||||
const headers = executor.buildHeaders(credentials);
|
||||
|
||||
expect(headers).toHaveProperty('authorization');
|
||||
expect(headers.authorization).toContain('Bearer');
|
||||
expect(headers).toHaveProperty('connect-accept-encoding', 'gzip');
|
||||
expect(headers).toHaveProperty('connect-protocol-version', '1');
|
||||
expect(headers).toHaveProperty('content-type', 'application/connect+proto');
|
||||
expect(headers).toHaveProperty('user-agent', 'connect-es/1.6.1');
|
||||
expect(headers).toHaveProperty('x-cursor-checksum');
|
||||
expect(headers).toHaveProperty('x-cursor-client-version', '2.3.41');
|
||||
expect(headers).toHaveProperty('x-cursor-client-type', 'ide');
|
||||
expect(headers).toHaveProperty('x-ghost-mode', 'true');
|
||||
});
|
||||
|
||||
it('should handle token with :: delimiter', () => {
|
||||
const credentials = {
|
||||
accessToken: 'prefix::actual-token',
|
||||
machineId: 'test-machine-id',
|
||||
};
|
||||
|
||||
const headers = executor.buildHeaders(credentials);
|
||||
|
||||
expect(headers.authorization).toBe('Bearer actual-token');
|
||||
});
|
||||
|
||||
it('should respect ghostMode flag', () => {
|
||||
const credentialsGhost = {
|
||||
accessToken: 'test-token',
|
||||
machineId: 'test-machine-id',
|
||||
ghostMode: true,
|
||||
};
|
||||
|
||||
const credentialsNoGhost = {
|
||||
accessToken: 'test-token',
|
||||
machineId: 'test-machine-id',
|
||||
ghostMode: false,
|
||||
};
|
||||
|
||||
const headersGhost = executor.buildHeaders(credentialsGhost);
|
||||
const headersNoGhost = executor.buildHeaders(credentialsNoGhost);
|
||||
|
||||
expect(headersGhost['x-ghost-mode']).toBe('true');
|
||||
expect(headersNoGhost['x-ghost-mode']).toBe('false');
|
||||
});
|
||||
|
||||
it('should throw error if machineId missing', () => {
|
||||
const credentials = {
|
||||
accessToken: 'test-token',
|
||||
machineId: '',
|
||||
};
|
||||
|
||||
expect(() => executor.buildHeaders(credentials)).toThrow('Machine ID is required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildUrl', () => {
|
||||
it('should return correct API endpoint', () => {
|
||||
const url = executor.buildUrl();
|
||||
expect(url).toBe('https://api2.cursor.sh/aiserver.v1.AiService/StreamChat');
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformProtobufToJSON', () => {
|
||||
it('should handle basic text response', async () => {
|
||||
// Create minimal protobuf response with text
|
||||
const textContent = 'Hello, world!';
|
||||
const responseField = encodeField(FIELD.RESPONSE_TEXT, WIRE_TYPE.LEN, textContent);
|
||||
const responseMsg = encodeField(FIELD.RESPONSE, WIRE_TYPE.LEN, responseField);
|
||||
const frame = wrapConnectRPCFrame(responseMsg, false);
|
||||
|
||||
const result = executor.transformProtobufToJSON(Buffer.from(frame), 'gpt-4', {
|
||||
messages: [],
|
||||
});
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
const bodyText = await result.text();
|
||||
const body = JSON.parse(bodyText);
|
||||
expect(body.choices[0].message.content).toBe(textContent);
|
||||
expect(body.choices[0].finish_reason).toBe('stop');
|
||||
});
|
||||
|
||||
it('should handle JSON error response', async () => {
|
||||
const errorJson = JSON.stringify({
|
||||
error: {
|
||||
code: 'resource_exhausted',
|
||||
message: 'Rate limit exceeded',
|
||||
},
|
||||
});
|
||||
const frame = wrapConnectRPCFrame(new TextEncoder().encode(errorJson), false);
|
||||
|
||||
const result = executor.transformProtobufToJSON(Buffer.from(frame), 'gpt-4', {
|
||||
messages: [],
|
||||
});
|
||||
|
||||
expect(result.status).toBe(429);
|
||||
const bodyText = await result.text();
|
||||
const body = JSON.parse(bodyText);
|
||||
expect(body.error.type).toBe('rate_limit_error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformProtobufToSSE', () => {
|
||||
it('should output SSE format', async () => {
|
||||
// Create minimal protobuf response with text
|
||||
const textContent = 'Hello';
|
||||
const responseField = encodeField(FIELD.RESPONSE_TEXT, WIRE_TYPE.LEN, textContent);
|
||||
const responseMsg = encodeField(FIELD.RESPONSE, WIRE_TYPE.LEN, responseField);
|
||||
const frame = wrapConnectRPCFrame(responseMsg, false);
|
||||
|
||||
const result = executor.transformProtobufToSSE(Buffer.from(frame), 'gpt-4', {
|
||||
messages: [],
|
||||
});
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.headers.get('content-type')).toBe('text/event-stream');
|
||||
|
||||
const bodyText = await result.text();
|
||||
expect(bodyText).toContain('data: ');
|
||||
expect(bodyText).toContain('data: [DONE]');
|
||||
expect(bodyText).toContain(textContent);
|
||||
});
|
||||
|
||||
it('should handle JSON error response', async () => {
|
||||
const errorJson = JSON.stringify({
|
||||
error: {
|
||||
code: 'resource_exhausted',
|
||||
message: 'Rate limit exceeded',
|
||||
},
|
||||
});
|
||||
const frame = wrapConnectRPCFrame(new TextEncoder().encode(errorJson), false);
|
||||
|
||||
const result = executor.transformProtobufToSSE(Buffer.from(frame), 'gpt-4', {
|
||||
messages: [],
|
||||
});
|
||||
|
||||
expect(result.status).toBe(429);
|
||||
const bodyText = await result.text();
|
||||
const body = JSON.parse(bodyText);
|
||||
expect(body.error.type).toBe('rate_limit_error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('decompressPayload error handling', () => {
|
||||
it('should return empty buffer on decompression failure', () => {
|
||||
// Create invalid gzip data
|
||||
const invalidGzip = Buffer.from([0x1f, 0x8b, 0x08, 0x00, 0xff, 0xff]);
|
||||
const frame = new Uint8Array(5 + invalidGzip.length);
|
||||
frame[0] = 0x01; // GZIP flag
|
||||
frame[1] = 0;
|
||||
frame[2] = 0;
|
||||
frame[3] = 0;
|
||||
frame[4] = invalidGzip.length;
|
||||
frame.set(invalidGzip, 5);
|
||||
|
||||
const result = executor.transformProtobufToJSON(Buffer.from(frame), 'gpt-4', {
|
||||
messages: [],
|
||||
});
|
||||
|
||||
// Should handle gracefully and return valid response
|
||||
expect(result.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should return empty buffer on decompression failure', () => {
|
||||
const executor = new CursorExecutor();
|
||||
|
||||
// Invalid compressed payload (not actually gzipped)
|
||||
const invalidGzipPayload = new Uint8Array([1, 2, 3, 4, 5]);
|
||||
const flags = 0x01; // GZIP flag
|
||||
|
||||
// Wrap with ConnectRPC frame header (flags + length)
|
||||
const length = invalidGzipPayload.length;
|
||||
const frame = new Uint8Array(5 + length);
|
||||
frame[0] = flags;
|
||||
frame[1] = (length >> 24) & 0xff;
|
||||
frame[2] = (length >> 16) & 0xff;
|
||||
frame[3] = (length >> 8) & 0xff;
|
||||
frame[4] = length & 0xff;
|
||||
frame.set(invalidGzipPayload, 5);
|
||||
|
||||
const buffer = Buffer.from(frame);
|
||||
|
||||
// Should not crash - decompression failure returns empty buffer
|
||||
const result = executor.transformProtobufToJSON(buffer, 'test-model', {
|
||||
messages: [],
|
||||
stream: false,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should log unknown message roles in debug mode', () => {
|
||||
const originalDebug = process.env.CCS_DEBUG;
|
||||
process.env.CCS_DEBUG = '1';
|
||||
|
||||
const consoleSpy: string[] = [];
|
||||
const originalError = console.error;
|
||||
console.error = (...args: unknown[]) => {
|
||||
const msg = args.map((a) => String(a)).join(' ');
|
||||
consoleSpy.push(msg);
|
||||
};
|
||||
|
||||
try {
|
||||
const messages = [
|
||||
{
|
||||
role: 'unknown_role' as 'user', // Type assertion to bypass TS
|
||||
content: 'test',
|
||||
},
|
||||
];
|
||||
|
||||
// buildCursorRequest expects (model, body, stream, credentials)
|
||||
buildCursorRequest('test-model', { messages }, false, { machineId: '12345', accessToken: 'test' });
|
||||
|
||||
// Should have logged warning
|
||||
const hasWarning = consoleSpy.some((log) => log.includes('Unknown message role'));
|
||||
expect(hasWarning).toBe(true);
|
||||
} finally {
|
||||
console.error = originalError;
|
||||
process.env.CCS_DEBUG = originalDebug;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -69,7 +69,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
||||
defaultProfileName: 'glm',
|
||||
badge: 'Z.AI',
|
||||
icon: '/icons/zai.svg',
|
||||
defaultModel: 'glm-4.7',
|
||||
defaultModel: 'glm-5',
|
||||
requiresApiKey: true,
|
||||
apiKeyPlaceholder: 'ghp_...',
|
||||
apiKeyHint: 'Get your API key from Z.AI',
|
||||
@@ -83,7 +83,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
||||
defaultProfileName: 'glmt',
|
||||
badge: 'Thinking',
|
||||
icon: '/icons/zai.svg',
|
||||
defaultModel: 'glm-4.7',
|
||||
defaultModel: 'glm-5',
|
||||
requiresApiKey: true,
|
||||
apiKeyPlaceholder: 'ghp_...',
|
||||
apiKeyHint: 'Same API key as GLM',
|
||||
@@ -162,12 +162,12 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
||||
{
|
||||
id: 'ollama-cloud',
|
||||
name: 'Ollama Cloud',
|
||||
description: 'Ollama cloud models via direct API (glm-4.7:cloud, minimax-m2.1:cloud)',
|
||||
description: 'Ollama cloud models via direct API (glm-5:cloud, minimax-m2.1:cloud)',
|
||||
baseUrl: 'https://ollama.com',
|
||||
defaultProfileName: 'ollama-cloud',
|
||||
badge: 'Cloud',
|
||||
icon: '/icons/ollama.svg',
|
||||
defaultModel: 'glm-4.7:cloud',
|
||||
defaultModel: 'glm-5:cloud',
|
||||
requiresApiKey: true,
|
||||
apiKeyPlaceholder: 'YOUR_OLLAMA_CLOUD_API_KEY',
|
||||
apiKeyHint: 'Get your API key at ollama.com',
|
||||
|
||||
Reference in new issue
Block a user