chore: merge dev into cursor-config branch

Brings in merged PRs #525 (protobuf) and #526 (auth) fixes.
Resolves stale cursor file copies by taking dev versions.
This commit is contained in:
Tam Nhu Tran committed 2026-02-12 03:54:15 +07:00
commit 55bc53152c
39 files changed
+4786 -1719

No files matched your search

+1
View File
@@ -33,6 +33,7 @@ pnpm-lock.yaml
package-lock.json
.claude/active-plan
.claude/agent-memory/
# Logs directory
logs/
+23
View File
@@ -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
+4 -4
View File
@@ -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"
}
}
+4 -4
View File
@@ -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 -2
View File
@@ -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
View File
@@ -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",
+4 -4
View File
@@ -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
View File
@@ -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,
+471
View File
@@ -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',
};
}
+20 -1
View File
@@ -39,6 +39,7 @@ import { getProviderTokenDir, isAuthenticated, registerAccountFromToken } from '
import { executeOAuthProcess } from './oauth-process';
import { importKiroToken } from './kiro-import';
import { getProxyTarget, buildProxyUrl, buildManagementHeaders } from '../proxy-target-resolver';
import { checkNewAccountConflict, warnNewAccountConflict } from '../account-safety';
/**
* Prompt user to add another account
@@ -379,7 +380,17 @@ async function handlePasteCallbackMode(
}
console.log(ok('Authentication successful!'));
return registerAccountFromToken(provider, tokenDir, nickname);
const account = registerAccountFromToken(provider, tokenDir, nickname);
// Account safety: check for cross-provider conflicts
if (account?.email) {
const conflicts = checkNewAccountConflict(provider, account.email);
if (conflicts) {
warnNewAccountConflict(account.email, conflicts);
}
}
return account;
} catch (error) {
if (verbose) {
console.log(fail(`Error: ${(error as Error).message}`));
@@ -543,6 +554,14 @@ export async function triggerOAuth(
console.log(' Or enable "Kiro: Use normal browser" in: ccs config');
}
// Account safety: check for cross-provider conflicts
if (account?.email) {
const conflicts = checkNewAccountConflict(provider, account.email);
if (conflicts) {
warnNewAccountConflict(account.email, conflicts);
}
}
return account;
}
+35 -11
View File
@@ -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}`;
+30
View File
@@ -63,6 +63,12 @@ import {
handleQuotaCheck,
} from './retry-handler';
import { checkOrJoinProxy, registerProxySession, setupCleanupHandlers } from './session-bridge';
import {
warnCrossProviderDuplicates,
cleanupStaleAutoPauses,
enforceProviderIsolation,
restoreAutoPausedAccounts,
} from '../account-safety';
import { getWebSearchHookEnv } from '../../utils/websearch-manager';
/** Default executor configuration */
@@ -507,6 +513,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
View File
@@ -10,6 +10,7 @@
import { fail, warn, info } from '../../utils/ui';
import { CLIProxyProvider } from '../types';
import { handleBanDetection } from '../account-safety';
/**
* Check if error is network-related
@@ -50,6 +51,15 @@ export async function handleTokenExpiration(
const tokenResult = await ensureTokenValid(provider, verbose);
if (!tokenResult.valid) {
// Check if this is an account ban/disable before generic error
if (tokenResult.error) {
const { getDefaultAccount } = await import('../account-manager');
const account = getDefaultAccount(provider);
if (account) {
handleBanDetection(provider, account.id, tokenResult.error);
}
}
// Token expired and refresh failed - trigger re-auth
console.error(warn('OAuth token expired and refresh failed'));
if (tokenResult.error) {
+4
View File
@@ -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 (
+135
View File
@@ -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;
}
+1 -1
View File
@@ -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`);
+2 -2
View File
@@ -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)'],
@@ -246,7 +246,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'],
]);
+20
View File
@@ -349,6 +349,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: {
+34
View File
@@ -382,6 +382,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.
@@ -414,6 +433,8 @@ export interface QuotaManagementConfig {
auto: AutoQuotaConfig;
/** Manual mode settings */
manual: ManualQuotaConfig;
/** Runtime monitor settings */
runtime_monitor: RuntimeMonitorConfig;
}
/**
@@ -435,6 +456,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.
*/
@@ -442,6 +475,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
View File
@@ -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;
}
}
File diff suppressed because it is too large. Load diff
+252 -217
View File
@@ -3,34 +3,28 @@
* Implements ConnectRPC protobuf wire format decoding
*/
import * as zlib from "zlib";
import {
WIRE_TYPE,
FIELD,
type WireType,
} from "./cursor-protobuf-schema.js";
import * as zlib from 'zlib';
import { WIRE_TYPE, FIELD, COMPRESS_FLAG, type WireType } from './cursor-protobuf-schema.js';
/**
* Decode a varint from buffer
* Returns [value, newOffset]
*/
export function decodeVarint(
buffer: Uint8Array,
offset: number
): [number, number] {
let result = 0;
let shift = 0;
let pos = offset;
export function decodeVarint(buffer: Uint8Array, offset: number): [number, number] {
let result = 0;
let shift = 0;
let pos = offset;
const maxBytes = 5;
while (pos < buffer.length) {
const b = buffer[pos];
result |= (b & 0x7f) << shift;
pos++;
if (!(b & 0x80)) break;
shift += 7;
}
while (pos < buffer.length && pos - offset < maxBytes) {
const b = buffer[pos];
result |= (b & 0x7f) << shift;
pos++;
if (!(b & 0x80)) break;
shift += 7;
}
return [result, pos];
return [result >>> 0, pos]; // Ensure unsigned
}
/**
@@ -38,63 +32,73 @@ export function decodeVarint(
* Returns [fieldNum, wireType, value, newOffset]
*/
export function decodeField(
buffer: Uint8Array,
offset: number
buffer: Uint8Array,
offset: number
): [number | null, WireType | null, Uint8Array | number | null, number] {
if (offset >= buffer.length) {
return [null, null, null, offset];
}
if (offset >= buffer.length) {
return [null, null, null, offset];
}
const [tag, pos1] = decodeVarint(buffer, offset);
const fieldNum = tag >> 3;
const wireType = (tag & 0x07) as WireType;
const [tag, pos1] = decodeVarint(buffer, offset);
const fieldNum = tag >> 3;
const wireType = (tag & 0x07) as WireType;
let value: Uint8Array | number | null;
let pos = pos1;
let value: Uint8Array | number | null;
let pos = pos1;
if (wireType === WIRE_TYPE.VARINT) {
[value, pos] = decodeVarint(buffer, pos);
} else if (wireType === WIRE_TYPE.LEN) {
const [length, pos2] = decodeVarint(buffer, pos);
value = buffer.slice(pos2, pos2 + length);
pos = pos2 + length;
} else if (wireType === WIRE_TYPE.FIXED64) {
value = buffer.slice(pos, pos + 8);
pos += 8;
} else if (wireType === WIRE_TYPE.FIXED32) {
value = buffer.slice(pos, pos + 4);
pos += 4;
} else {
value = null;
}
if (wireType === WIRE_TYPE.VARINT) {
[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 {
value = null;
}
return [fieldNum, wireType, value, pos];
return [fieldNum, wireType, value, pos];
}
/**
* Decode a protobuf message into a map of fields
*/
export function decodeMessage(
data: Uint8Array
data: Uint8Array
): Map<number, Array<{ wireType: WireType; value: Uint8Array | number }>> {
const fields = new Map<
number,
Array<{ wireType: WireType; value: Uint8Array | number }>
>();
let pos = 0;
const fields = new Map<number, Array<{ wireType: WireType; value: Uint8Array | number }>>();
let pos = 0;
while (pos < data.length) {
const [fieldNum, wireType, value, newPos] = decodeField(data, pos);
if (fieldNum === null || wireType === null || value === null) break;
// 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;
if (!fields.has(fieldNum)) {
fields.set(fieldNum, []);
}
fields.get(fieldNum)!.push({ wireType, value: value as Uint8Array | number });
pos = newPos;
}
if (!fields.has(fieldNum)) {
fields.set(fieldNum, []);
}
const fieldArray = fields.get(fieldNum);
if (fieldArray) {
fieldArray.push({ wireType, value: value as Uint8Array | number });
}
pos = newPos;
}
return fields;
return fields;
}
/**
@@ -102,200 +106,231 @@ export function decodeMessage(
* Returns frame data or null if incomplete
*/
export function parseConnectRPCFrame(buffer: Buffer): {
flags: number;
length: number;
payload: Uint8Array;
consumed: number;
flags: number;
length: number;
payload: Uint8Array;
consumed: number;
} | null {
if (buffer.length < 5) return null;
if (buffer.length < 5) return null;
const flags = buffer[0];
const length =
(buffer[1] << 24) | (buffer[2] << 16) | (buffer[3] << 8) | buffer[4];
const flags = buffer[0];
const length = (buffer[1] << 24) | (buffer[2] << 16) | (buffer[3] << 8) | buffer[4];
if (buffer.length < 5 + length) return null;
if (buffer.length < 5 + length) return null;
let payload = buffer.slice(5, 5 + length);
let payload = buffer.slice(5, 5 + length);
// Decompress if gzip
if (flags === 0x01 || flags === 0x02 || flags === 0x03) {
try {
payload = Buffer.from(zlib.gunzipSync(payload));
} catch {
// Decompression failed, use raw payload
}
}
// Decompress if gzip
if (
flags === COMPRESS_FLAG.GZIP ||
flags === COMPRESS_FLAG.GZIP_ALT ||
flags === COMPRESS_FLAG.GZIP_BOTH
) {
try {
payload = Buffer.from(zlib.gunzipSync(payload));
} catch (err) {
if (process.env.CCS_DEBUG) {
console.error('[cursor] parseConnectRPCFrame decompression failed:', err);
}
// Decompression failed, use raw payload
}
}
return {
flags,
length,
payload: new Uint8Array(payload),
consumed: 5 + length,
};
return {
flags,
length,
payload: new Uint8Array(payload),
consumed: 5 + length,
};
}
/**
* Extract tool call from protobuf data
*/
function extractToolCall(toolCallData: Uint8Array): {
id: string;
type: string;
function: { name: string; arguments: string };
isLast: boolean;
id: string;
type: string;
function: { name: string; arguments: string };
isLast: boolean;
} | null {
const toolCall = decodeMessage(toolCallData);
let toolCallId = "";
let toolName = "";
let rawArgs = "";
let isLast = false;
const toolCall = decodeMessage(toolCallData);
let toolCallId = '';
let toolName = '';
let rawArgs = '';
let isLast = false;
// 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
}
// Extract tool call ID
if (toolCall.has(FIELD.TOOL_ID)) {
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
);
}
// Extract tool name
if (toolCall.has(FIELD.TOOL_NAME)) {
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;
}
// Extract is_last flag
if (toolCall.has(FIELD.TOOL_IS_LAST)) {
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
);
// Extract MCP params - nested real tool info
if (toolCall.has(FIELD.TOOL_MCP_PARAMS)) {
try {
const mcpField = toolCall.get(FIELD.TOOL_MCP_PARAMS);
if (!mcpField || !mcpField[0]) return null;
if (mcpParams.has(FIELD.MCP_TOOLS_LIST)) {
const tool = decodeMessage(
mcpParams.get(FIELD.MCP_TOOLS_LIST)![0].value as Uint8Array
);
const mcpParams = decodeMessage(mcpField[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
);
}
if (mcpParams.has(FIELD.MCP_TOOLS_LIST)) {
const toolsList = mcpParams.get(FIELD.MCP_TOOLS_LIST);
if (!toolsList || !toolsList[0]) return null;
if (tool.has(FIELD.MCP_NESTED_PARAMS)) {
rawArgs = new TextDecoder().decode(
tool.get(FIELD.MCP_NESTED_PARAMS)![0].value as Uint8Array
);
}
}
} catch {
// MCP parse error, continue
}
}
const tool = decodeMessage(toolsList[0].value as Uint8Array);
// 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
);
}
if (tool.has(FIELD.MCP_NESTED_NAME)) {
const nestedName = tool.get(FIELD.MCP_NESTED_NAME);
if (nestedName && nestedName[0]) {
toolName = new TextDecoder().decode(nestedName[0].value as Uint8Array);
}
}
if (toolCallId && toolName) {
return {
id: toolCallId,
type: "function",
function: {
name: toolName,
arguments: rawArgs || "{}",
},
isLast,
};
}
if (tool.has(FIELD.MCP_NESTED_PARAMS)) {
const nestedParams = tool.get(FIELD.MCP_NESTED_PARAMS);
if (nestedParams && nestedParams[0]) {
rawArgs = new TextDecoder().decode(nestedParams[0].value as Uint8Array);
}
}
}
} catch (err) {
if (process.env.CCS_DEBUG) {
console.error('[cursor] extractToolCall MCP parsing failed:', err);
}
// MCP parse error, continue
}
}
return null;
// Fallback to raw_args
if (!rawArgs && toolCall.has(FIELD.TOOL_RAW_ARGS)) {
const rawArgsField = toolCall.get(FIELD.TOOL_RAW_ARGS);
if (rawArgsField && rawArgsField[0]) {
rawArgs = new TextDecoder().decode(rawArgsField[0].value as Uint8Array);
}
}
if (toolCallId && toolName) {
return {
id: toolCallId,
type: 'function',
function: {
name: toolName,
arguments: rawArgs || '{}',
},
isLast,
};
}
return null;
}
/**
* Extract text and thinking from response data
*/
function extractTextAndThinking(
responseData: Uint8Array
): { text: string | null; thinking: string | null } {
const nested = decodeMessage(responseData);
let text: string | null = null;
let thinking: string | null = null;
function extractTextAndThinking(responseData: Uint8Array): {
text: string | null;
thinking: string | null;
} {
const nested = decodeMessage(responseData);
let text: string | null = null;
let thinking: string | null = null;
// Extract text
if (nested.has(FIELD.RESPONSE_TEXT)) {
text = new TextDecoder().decode(
nested.get(FIELD.RESPONSE_TEXT)![0].value as Uint8Array
);
}
// Extract text
if (nested.has(FIELD.RESPONSE_TEXT)) {
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
);
}
} catch {
// Thinking parse error, continue
}
}
// Extract thinking
if (nested.has(FIELD.THINKING)) {
try {
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);
}
// Thinking parse error, continue
}
}
return { text, thinking };
return { text, thinking };
}
/**
* Extract text and tool calls from response payload
*/
export function extractTextFromResponse(payload: Uint8Array): {
text: string | null;
error: string | null;
toolCall: {
id: string;
type: string;
function: { name: string; arguments: string };
isLast: boolean;
} | null;
thinking: string | null;
text: string | null;
error: string | null;
toolCall: {
id: string;
type: string;
function: { name: string; arguments: string };
isLast: boolean;
} | null;
thinking: string | null;
} {
try {
const fields = decodeMessage(payload);
try {
const fields = decodeMessage(payload);
// 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 };
}
}
// Field 1: ClientSideToolV2Call
if (fields.has(FIELD.TOOL_CALL)) {
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
);
// Field 2: StreamUnifiedChatResponse
if (fields.has(FIELD.RESPONSE)) {
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 {
return { text: null, error: null, toolCall: null, thinking: null };
}
return { text: null, error: null, toolCall: null, thinking: null };
} catch (err) {
if (process.env.CCS_DEBUG) {
console.error('[cursor] extractTextFromResponse parsing failed:', err);
}
return { text: null, error: null, toolCall: null, thinking: null };
}
}
+137 -175
View File
@@ -3,260 +3,222 @@
* Implements ConnectRPC protobuf wire format encoding
*/
import { randomUUID } from "crypto";
import * as zlib from "zlib";
import * as zlib from 'zlib';
import {
WIRE_TYPE,
ROLE,
UNIFIED_MODE,
THINKING_LEVEL,
FIELD,
COMPRESS_FLAG,
type WireType,
type RoleType,
type ThinkingLevelType,
type CursorTool,
type CursorToolResult,
type CursorMessage,
type FormattedMessage,
type MessageId,
} from "./cursor-protobuf-schema.js";
WIRE_TYPE,
FIELD,
COMPRESS_FLAG,
UNIFIED_MODE,
type WireType,
type RoleType,
type CursorTool,
type CursorToolResult,
} from './cursor-protobuf-schema.js';
/**
* Encode a varint (variable-length integer)
*/
export function encodeVarint(value: number): Uint8Array {
const bytes: number[] = [];
let val = value >>> 0; // Ensure unsigned
while (val >= 0x80) {
bytes.push((val & 0x7f) | 0x80);
val >>>= 7;
}
bytes.push(val & 0x7f);
return new Uint8Array(bytes);
const bytes: number[] = [];
let val = value >>> 0; // Ensure unsigned
while (val >= 0x80) {
bytes.push((val & 0x7f) | 0x80);
val >>>= 7;
}
bytes.push(val & 0x7f);
return new Uint8Array(bytes);
}
/**
* Encode a protobuf field (tag + value)
*/
export function encodeField(
fieldNum: number,
wireType: WireType,
value: number | string | Uint8Array
fieldNum: number,
wireType: WireType,
value: number | string | Uint8Array
): Uint8Array {
const tag = (fieldNum << 3) | wireType;
const tagBytes = encodeVarint(tag);
const tag = (fieldNum << 3) | wireType;
const tagBytes = encodeVarint(tag);
if (wireType === WIRE_TYPE.VARINT) {
const valueBytes = encodeVarint(value as number);
return concatArrays(tagBytes, valueBytes);
}
if (wireType === WIRE_TYPE.VARINT) {
const valueBytes = encodeVarint(value as number);
return concatArrays(tagBytes, valueBytes);
}
if (wireType === WIRE_TYPE.LEN) {
const dataBytes =
typeof value === "string"
? new TextEncoder().encode(value)
: value instanceof Uint8Array
? value
: new Uint8Array(0);
if (wireType === WIRE_TYPE.LEN) {
const dataBytes =
typeof value === 'string'
? new TextEncoder().encode(value)
: value instanceof Uint8Array
? value
: new Uint8Array(0);
const lengthBytes = encodeVarint(dataBytes.length);
return concatArrays(tagBytes, lengthBytes, dataBytes);
}
const lengthBytes = encodeVarint(dataBytes.length);
return concatArrays(tagBytes, lengthBytes, dataBytes);
}
return new Uint8Array(0);
return new Uint8Array(0);
}
/**
* 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;
export 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;
}
/**
* Encode a tool result
*/
export function encodeToolResult(toolResult: CursorToolResult): Uint8Array {
const toolCallId = toolResult.tool_call_id || "";
const toolName = toolResult.name || "";
const toolIndex = toolResult.index || 0;
const rawArgs = toolResult.raw_args || "{}";
const toolCallId = toolResult.tool_call_id || '';
const toolName = toolResult.name || '';
const toolIndex = toolResult.index || 0;
const rawArgs = toolResult.raw_args || '{}';
return concatArrays(
encodeField(FIELD.TOOL_RESULT_CALL_ID, WIRE_TYPE.LEN, toolCallId),
encodeField(FIELD.TOOL_RESULT_NAME, WIRE_TYPE.LEN, toolName),
encodeField(FIELD.TOOL_RESULT_INDEX, WIRE_TYPE.VARINT, toolIndex),
encodeField(FIELD.TOOL_RESULT_RAW_ARGS, WIRE_TYPE.LEN, rawArgs)
);
return concatArrays(
encodeField(FIELD.TOOL_RESULT_CALL_ID, WIRE_TYPE.LEN, toolCallId),
encodeField(FIELD.TOOL_RESULT_NAME, WIRE_TYPE.LEN, toolName),
encodeField(FIELD.TOOL_RESULT_INDEX, WIRE_TYPE.VARINT, toolIndex),
encodeField(FIELD.TOOL_RESULT_RAW_ARGS, WIRE_TYPE.LEN, rawArgs)
);
}
/**
* Encode a conversation message
*/
export function encodeMessage(
content: string,
role: RoleType,
messageId: string,
isLast: boolean,
hasTools: boolean,
toolResults: CursorToolResult[]
content: string,
role: RoleType,
messageId: string,
isLast: boolean,
hasTools: boolean,
toolResults: CursorToolResult[]
): Uint8Array {
return concatArrays(
encodeField(FIELD.MSG_CONTENT, WIRE_TYPE.LEN, content),
encodeField(FIELD.MSG_ROLE, WIRE_TYPE.VARINT, role),
encodeField(FIELD.MSG_ID, WIRE_TYPE.LEN, messageId),
...(toolResults.length > 0
? toolResults.map((tr) =>
encodeField(
FIELD.MSG_TOOL_RESULTS,
WIRE_TYPE.LEN,
encodeToolResult(tr)
)
)
: []),
encodeField(FIELD.MSG_IS_AGENTIC, WIRE_TYPE.VARINT, hasTools ? 1 : 0),
encodeField(
FIELD.MSG_UNIFIED_MODE,
WIRE_TYPE.VARINT,
hasTools ? UNIFIED_MODE.AGENT : UNIFIED_MODE.CHAT
),
...(isLast && hasTools
? [
encodeField(
FIELD.MSG_SUPPORTED_TOOLS,
WIRE_TYPE.LEN,
encodeVarint(1)
),
]
: [])
);
return concatArrays(
encodeField(FIELD.MSG_CONTENT, WIRE_TYPE.LEN, content),
encodeField(FIELD.MSG_ROLE, WIRE_TYPE.VARINT, role),
encodeField(FIELD.MSG_ID, WIRE_TYPE.LEN, messageId),
...(toolResults.length > 0
? toolResults.map((tr) =>
encodeField(FIELD.MSG_TOOL_RESULTS, WIRE_TYPE.LEN, encodeToolResult(tr))
)
: []),
encodeField(FIELD.MSG_IS_AGENTIC, WIRE_TYPE.VARINT, hasTools ? 1 : 0),
encodeField(
FIELD.MSG_UNIFIED_MODE,
WIRE_TYPE.VARINT,
hasTools ? UNIFIED_MODE.AGENT : UNIFIED_MODE.CHAT
),
...(isLast && hasTools
? [encodeField(FIELD.MSG_SUPPORTED_TOOLS, WIRE_TYPE.LEN, encodeVarint(1))]
: [])
);
}
/**
* Encode instruction text
*/
export function encodeInstruction(text: string): Uint8Array {
return text
? encodeField(FIELD.INSTRUCTION_TEXT, WIRE_TYPE.LEN, text)
: new Uint8Array(0);
return text ? encodeField(FIELD.INSTRUCTION_TEXT, WIRE_TYPE.LEN, text) : new Uint8Array(0);
}
/**
* Encode model information
*/
export function encodeModel(modelName: string): Uint8Array {
return concatArrays(
encodeField(FIELD.MODEL_NAME, WIRE_TYPE.LEN, modelName),
encodeField(FIELD.MODEL_EMPTY, WIRE_TYPE.LEN, new Uint8Array(0))
);
return concatArrays(
encodeField(FIELD.MODEL_NAME, WIRE_TYPE.LEN, modelName),
encodeField(FIELD.MODEL_EMPTY, WIRE_TYPE.LEN, new Uint8Array(0))
);
}
/**
* Encode cursor settings
*/
export function encodeCursorSetting(): Uint8Array {
const unknown6 = concatArrays(
encodeField(FIELD.SETTING6_FIELD_1, WIRE_TYPE.LEN, new Uint8Array(0)),
encodeField(FIELD.SETTING6_FIELD_2, WIRE_TYPE.LEN, new Uint8Array(0))
);
const unknown6 = concatArrays(
encodeField(FIELD.SETTING6_FIELD_1, WIRE_TYPE.LEN, new Uint8Array(0)),
encodeField(FIELD.SETTING6_FIELD_2, WIRE_TYPE.LEN, new Uint8Array(0))
);
return concatArrays(
encodeField(FIELD.SETTING_PATH, WIRE_TYPE.LEN, "cursor\\aisettings"),
encodeField(FIELD.SETTING_UNKNOWN_3, WIRE_TYPE.LEN, new Uint8Array(0)),
encodeField(FIELD.SETTING_UNKNOWN_6, WIRE_TYPE.LEN, unknown6),
encodeField(FIELD.SETTING_UNKNOWN_8, WIRE_TYPE.VARINT, 1),
encodeField(FIELD.SETTING_UNKNOWN_9, WIRE_TYPE.VARINT, 1)
);
return concatArrays(
encodeField(FIELD.SETTING_PATH, WIRE_TYPE.LEN, 'cursor\\aisettings'),
encodeField(FIELD.SETTING_UNKNOWN_3, WIRE_TYPE.LEN, new Uint8Array(0)),
encodeField(FIELD.SETTING_UNKNOWN_6, WIRE_TYPE.LEN, unknown6),
encodeField(FIELD.SETTING_UNKNOWN_8, WIRE_TYPE.VARINT, 1),
encodeField(FIELD.SETTING_UNKNOWN_9, WIRE_TYPE.VARINT, 1)
);
}
/**
* Encode metadata
*/
export function encodeMetadata(): Uint8Array {
return concatArrays(
encodeField(FIELD.META_PLATFORM, WIRE_TYPE.LEN, process.platform || "linux"),
encodeField(FIELD.META_ARCH, WIRE_TYPE.LEN, process.arch || "x64"),
encodeField(FIELD.META_VERSION, WIRE_TYPE.LEN, process.version || "v20.0.0"),
encodeField(FIELD.META_CWD, WIRE_TYPE.LEN, process.cwd() || "/"),
encodeField(FIELD.META_TIMESTAMP, WIRE_TYPE.LEN, new Date().toISOString())
);
return concatArrays(
encodeField(FIELD.META_PLATFORM, WIRE_TYPE.LEN, process.platform || 'linux'),
encodeField(FIELD.META_ARCH, WIRE_TYPE.LEN, process.arch || 'x64'),
encodeField(FIELD.META_VERSION, WIRE_TYPE.LEN, process.version || 'v20.0.0'),
encodeField(FIELD.META_CWD, WIRE_TYPE.LEN, process.cwd() || '/'),
encodeField(FIELD.META_TIMESTAMP, WIRE_TYPE.LEN, new Date().toISOString())
);
}
/**
* Encode message ID
*/
export function encodeMessageId(
messageId: string,
role: RoleType,
summaryId?: string
): Uint8Array {
return concatArrays(
encodeField(FIELD.MSGID_ID, WIRE_TYPE.LEN, messageId),
...(summaryId
? [encodeField(FIELD.MSGID_SUMMARY, WIRE_TYPE.LEN, summaryId)]
: []),
encodeField(FIELD.MSGID_ROLE, WIRE_TYPE.VARINT, role)
);
export function encodeMessageId(messageId: string, role: RoleType, summaryId?: string): Uint8Array {
return concatArrays(
encodeField(FIELD.MSGID_ID, WIRE_TYPE.LEN, messageId),
...(summaryId ? [encodeField(FIELD.MSGID_SUMMARY, WIRE_TYPE.LEN, summaryId)] : []),
encodeField(FIELD.MSGID_ROLE, WIRE_TYPE.VARINT, role)
);
}
/**
* Encode MCP tool
*/
export function encodeMcpTool(tool: CursorTool): Uint8Array {
const toolName = tool.function?.name || tool.name || "";
const toolDesc = tool.function?.description || tool.description || "";
const inputSchema = tool.function?.parameters || tool.input_schema || {};
const toolName = tool.function?.name || tool.name || '';
const toolDesc = tool.function?.description || tool.description || '';
const inputSchema = tool.function?.parameters || tool.input_schema || {};
return concatArrays(
...(toolName
? [encodeField(FIELD.MCP_TOOL_NAME, WIRE_TYPE.LEN, toolName)]
: []),
...(toolDesc
? [encodeField(FIELD.MCP_TOOL_DESC, WIRE_TYPE.LEN, toolDesc)]
: []),
...(Object.keys(inputSchema).length > 0
? [
encodeField(
FIELD.MCP_TOOL_PARAMS,
WIRE_TYPE.LEN,
JSON.stringify(inputSchema)
),
]
: []),
encodeField(FIELD.MCP_TOOL_SERVER, WIRE_TYPE.LEN, "custom")
);
return concatArrays(
...(toolName ? [encodeField(FIELD.MCP_TOOL_NAME, WIRE_TYPE.LEN, toolName)] : []),
...(toolDesc ? [encodeField(FIELD.MCP_TOOL_DESC, WIRE_TYPE.LEN, toolDesc)] : []),
...(Object.keys(inputSchema).length > 0
? [encodeField(FIELD.MCP_TOOL_PARAMS, WIRE_TYPE.LEN, JSON.stringify(inputSchema))]
: []),
encodeField(FIELD.MCP_TOOL_SERVER, WIRE_TYPE.LEN, 'custom')
);
}
/**
* Wrap payload in ConnectRPC frame (5-byte header + payload)
*/
export function wrapConnectRPCFrame(
payload: Uint8Array,
compress = false
): Uint8Array {
let finalPayload = payload;
let flags: number = COMPRESS_FLAG.NONE;
export function wrapConnectRPCFrame(payload: Uint8Array, compress = false): Uint8Array {
let finalPayload = payload;
let flags: number = COMPRESS_FLAG.NONE;
if (compress) {
finalPayload = new Uint8Array(zlib.gzipSync(Buffer.from(payload)));
flags = COMPRESS_FLAG.GZIP;
}
if (compress) {
finalPayload = new Uint8Array(zlib.gzipSync(Buffer.from(payload)));
flags = COMPRESS_FLAG.GZIP;
}
const frame = new Uint8Array(5 + finalPayload.length);
frame[0] = flags;
frame[1] = (finalPayload.length >> 24) & 0xff;
frame[2] = (finalPayload.length >> 16) & 0xff;
frame[3] = (finalPayload.length >> 8) & 0xff;
frame[4] = finalPayload.length & 0xff;
frame.set(finalPayload, 5);
const frame = new Uint8Array(5 + finalPayload.length);
frame[0] = flags;
frame[1] = (finalPayload.length >> 24) & 0xff;
frame[2] = (finalPayload.length >> 16) & 0xff;
frame[3] = (finalPayload.length >> 8) & 0xff;
frame[4] = finalPayload.length & 0xff;
frame.set(finalPayload, 5);
return frame;
return frame;
}
+143 -135
View File
@@ -5,201 +5,209 @@
/** Wire types for protobuf encoding */
export const WIRE_TYPE = {
VARINT: 0,
FIXED64: 1,
LEN: 2,
FIXED32: 5,
VARINT: 0,
FIXED64: 1,
LEN: 2,
FIXED32: 5,
} as const;
/** Message role constants */
export const ROLE = {
USER: 1,
ASSISTANT: 2,
USER: 1,
ASSISTANT: 2,
} as const;
/** Unified mode constants */
export const UNIFIED_MODE = {
CHAT: 1,
AGENT: 2,
CHAT: 1,
AGENT: 2,
} as const;
/** Thinking level constants */
export const THINKING_LEVEL = {
UNSPECIFIED: 0,
MEDIUM: 1,
HIGH: 2,
UNSPECIFIED: 0,
MEDIUM: 1,
HIGH: 2,
} as const;
/** Field numbers for all protobuf messages */
export const FIELD = {
// StreamUnifiedChatRequestWithTools (top level)
REQUEST: 1,
// ===== StreamUnifiedChatRequestWithTools (top level) =====
REQUEST: 1,
// StreamUnifiedChatRequest
MESSAGES: 1,
UNKNOWN_2: 2,
INSTRUCTION: 3,
UNKNOWN_4: 4,
MODEL: 5,
WEB_TOOL: 8,
UNKNOWN_13: 13,
CURSOR_SETTING: 15,
UNKNOWN_19: 19,
CONVERSATION_ID: 23,
METADATA: 26,
IS_AGENTIC: 27,
SUPPORTED_TOOLS: 29,
MESSAGE_IDS: 30,
MCP_TOOLS: 34,
LARGE_CONTEXT: 35,
UNKNOWN_38: 38,
UNIFIED_MODE: 46,
UNKNOWN_47: 47,
SHOULD_DISABLE_TOOLS: 48,
THINKING_LEVEL: 49,
UNKNOWN_51: 51,
UNKNOWN_53: 53,
UNIFIED_MODE_NAME: 54,
// ===== StreamUnifiedChatRequest =====
MESSAGES: 1,
UNKNOWN_2: 2,
INSTRUCTION: 3,
UNKNOWN_4: 4,
MODEL: 5,
WEB_TOOL: 8,
UNKNOWN_13: 13,
CURSOR_SETTING: 15,
UNKNOWN_19: 19,
CONVERSATION_ID: 23,
METADATA: 26,
IS_AGENTIC: 27,
SUPPORTED_TOOLS: 29,
MESSAGE_IDS: 30,
MCP_TOOLS: 34,
LARGE_CONTEXT: 35,
UNKNOWN_38: 38,
UNIFIED_MODE: 46,
UNKNOWN_47: 47,
SHOULD_DISABLE_TOOLS: 48,
THINKING_LEVEL: 49,
UNKNOWN_51: 51,
UNKNOWN_53: 53,
UNIFIED_MODE_NAME: 54,
// ConversationMessage
MSG_CONTENT: 1,
MSG_ROLE: 2,
MSG_ID: 13,
MSG_TOOL_RESULTS: 18,
MSG_IS_AGENTIC: 29,
MSG_UNIFIED_MODE: 47,
MSG_SUPPORTED_TOOLS: 51,
// ===== ConversationMessage =====
MSG_CONTENT: 1,
MSG_ROLE: 2,
MSG_ID: 13,
MSG_TOOL_RESULTS: 18,
MSG_IS_AGENTIC: 29,
MSG_UNIFIED_MODE: 47,
MSG_SUPPORTED_TOOLS: 51,
// ConversationMessage.ToolResult
TOOL_RESULT_CALL_ID: 1,
TOOL_RESULT_NAME: 2,
TOOL_RESULT_INDEX: 3,
TOOL_RESULT_RAW_ARGS: 5,
TOOL_RESULT_RESULT: 8,
// ===== ConversationMessage.ToolResult =====
TOOL_RESULT_CALL_ID: 1,
TOOL_RESULT_NAME: 2,
TOOL_RESULT_INDEX: 3,
TOOL_RESULT_RAW_ARGS: 5,
TOOL_RESULT_RESULT: 8, // Reserved for future tool result parsing
// Model
MODEL_NAME: 1,
MODEL_EMPTY: 4,
// ===== Model =====
MODEL_NAME: 1,
MODEL_EMPTY: 4,
// Instruction
INSTRUCTION_TEXT: 1,
// ===== Instruction =====
INSTRUCTION_TEXT: 1,
// CursorSetting
SETTING_PATH: 1,
SETTING_UNKNOWN_3: 3,
SETTING_UNKNOWN_6: 6,
SETTING_UNKNOWN_8: 8,
SETTING_UNKNOWN_9: 9,
// ===== CursorSetting =====
SETTING_PATH: 1,
SETTING_UNKNOWN_3: 3,
SETTING_UNKNOWN_6: 6,
SETTING_UNKNOWN_8: 8,
SETTING_UNKNOWN_9: 9,
// CursorSetting.Unknown6
SETTING6_FIELD_1: 1,
SETTING6_FIELD_2: 2,
// ===== CursorSetting.Unknown6 =====
SETTING6_FIELD_1: 1,
SETTING6_FIELD_2: 2,
// Metadata
META_PLATFORM: 1,
META_ARCH: 2,
META_VERSION: 3,
META_CWD: 4,
META_TIMESTAMP: 5,
// ===== Metadata =====
META_PLATFORM: 1,
META_ARCH: 2,
META_VERSION: 3,
META_CWD: 4,
META_TIMESTAMP: 5,
// MessageId
MSGID_ID: 1,
MSGID_SUMMARY: 2,
MSGID_ROLE: 3,
// ===== MessageId =====
MSGID_ID: 1,
MSGID_SUMMARY: 2,
MSGID_ROLE: 3,
// MCPTool
MCP_TOOL_NAME: 1,
MCP_TOOL_DESC: 2,
MCP_TOOL_PARAMS: 3,
MCP_TOOL_SERVER: 4,
// ===== MCPTool =====
MCP_TOOL_NAME: 1,
MCP_TOOL_DESC: 2,
MCP_TOOL_PARAMS: 3,
MCP_TOOL_SERVER: 4,
// StreamUnifiedChatResponseWithTools (response)
TOOL_CALL: 1,
RESPONSE: 2,
// ===== StreamUnifiedChatResponseWithTools (response) =====
TOOL_CALL: 1,
RESPONSE: 2,
// ClientSideToolV2Call
TOOL_ID: 3,
TOOL_NAME: 9,
TOOL_RAW_ARGS: 10,
TOOL_IS_LAST: 11,
TOOL_MCP_PARAMS: 27,
// ===== ClientSideToolV2Call =====
TOOL_ID: 3,
TOOL_NAME: 9,
TOOL_RAW_ARGS: 10,
TOOL_IS_LAST: 11,
TOOL_MCP_PARAMS: 27,
// MCPParams
MCP_TOOLS_LIST: 1,
// ===== MCPParams =====
MCP_TOOLS_LIST: 1,
// MCPParams.Tool (nested)
MCP_NESTED_NAME: 1,
MCP_NESTED_PARAMS: 3,
// ===== MCPParams.Tool (nested) =====
MCP_NESTED_NAME: 1,
MCP_NESTED_PARAMS: 3,
// StreamUnifiedChatResponse
RESPONSE_TEXT: 1,
THINKING: 25,
// ===== StreamUnifiedChatResponse =====
RESPONSE_TEXT: 1,
THINKING: 25,
// Thinking
THINKING_TEXT: 1,
// ===== Thinking =====
THINKING_TEXT: 1,
} as const;
/** Type definitions */
export type WireType = (typeof WIRE_TYPE)[keyof typeof WIRE_TYPE];
export type RoleType = (typeof ROLE)[keyof typeof ROLE];
export type UnifiedModeType = (typeof UNIFIED_MODE)[keyof typeof UNIFIED_MODE];
export type ThinkingLevelType =
(typeof THINKING_LEVEL)[keyof typeof THINKING_LEVEL];
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?: {
name?: string;
description?: string;
parameters?: Record<string, unknown>;
};
name?: string;
description?: string;
input_schema?: Record<string, unknown>;
function?: {
name?: string;
description?: string;
parameters?: Record<string, unknown>;
};
name?: string;
description?: string;
input_schema?: Record<string, unknown>;
}
/** Cursor tool result */
export interface CursorToolResult {
tool_call_id?: string;
name?: string;
index?: number;
raw_args?: string;
tool_call_id?: string;
name?: string;
index?: number;
raw_args?: string;
}
/** Cursor message format */
export interface CursorMessage {
role: string;
content: string;
tool_results?: CursorToolResult[];
tool_calls?: Array<{
id: string;
type: string;
function: {
name: string;
arguments: string;
};
}>;
role: string;
content: string;
tool_results?: CursorToolResult[];
tool_calls?: Array<{
id: string;
type: string;
function: {
name: string;
arguments: string;
};
}>;
}
/** Formatted message for encoding */
export interface FormattedMessage {
content: string;
role: RoleType;
messageId: string;
isLast: boolean;
hasTools: boolean;
toolResults: CursorToolResult[];
content: string;
role: RoleType;
messageId: string;
isLast: boolean;
hasTools: boolean;
toolResults: CursorToolResult[];
}
/** Message ID structure */
export interface MessageId {
messageId: string;
role: RoleType;
messageId: string;
role: RoleType;
}
/** Compression flags for ConnectRPC frames */
export const COMPRESS_FLAG = {
NONE: 0x00,
GZIP: 0x01,
NONE: 0x00,
GZIP: 0x01,
GZIP_ALT: 0x02,
GZIP_BOTH: 0x03,
} as const;
+143 -169
View File
@@ -3,210 +3,184 @@
* Exports encoder/decoder functions and builds complete requests
*/
import { randomUUID } from "crypto";
import { randomUUID } from 'crypto';
import {
ROLE,
UNIFIED_MODE,
THINKING_LEVEL,
FIELD,
type CursorMessage,
type CursorTool,
type FormattedMessage,
type MessageId,
type ThinkingLevelType,
} from "./cursor-protobuf-schema.js";
ROLE,
UNIFIED_MODE,
THINKING_LEVEL,
FIELD,
type CursorMessage,
type CursorTool,
type FormattedMessage,
type MessageId,
type ThinkingLevelType,
} from './cursor-protobuf-schema.js';
import {
encodeField,
encodeVarint,
encodeMessage,
encodeInstruction,
encodeModel,
encodeCursorSetting,
encodeMetadata,
encodeMessageId,
encodeMcpTool,
wrapConnectRPCFrame,
} from "./cursor-protobuf-encoder.js";
encodeField,
encodeVarint,
encodeMessage,
encodeInstruction,
encodeModel,
encodeCursorSetting,
encodeMetadata,
encodeMessageId,
encodeMcpTool,
wrapConnectRPCFrame,
concatArrays,
} from './cursor-protobuf-encoder.js';
import {
decodeVarint,
decodeField,
decodeMessage,
parseConnectRPCFrame,
extractTextFromResponse,
} from "./cursor-protobuf-decoder.js";
import { WIRE_TYPE } from "./cursor-protobuf-schema.js";
decodeVarint,
decodeField,
decodeMessage,
parseConnectRPCFrame,
extractTextFromResponse,
} from './cursor-protobuf-decoder.js';
import { WIRE_TYPE } from './cursor-protobuf-schema.js';
/**
* Build complete chat request protobuf
*/
export function encodeRequest(
messages: CursorMessage[],
modelName: string,
tools: CursorTool[] = [],
reasoningEffort: string | null = null
messages: CursorMessage[],
modelName: string,
tools: CursorTool[] = [],
reasoningEffort: string | null = null
): Uint8Array {
const hasTools = tools?.length > 0;
const isAgentic = hasTools;
const formattedMessages: FormattedMessage[] = [];
const messageIds: MessageId[] = [];
if (messages.length === 0) {
throw new Error('Messages array must not be empty');
}
// Prepare messages
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
const role = msg.role === "user" ? ROLE.USER : ROLE.ASSISTANT;
const msgId = randomUUID();
const isLast = i === messages.length - 1;
const hasTools = tools?.length > 0;
const isAgentic = hasTools;
const formattedMessages: FormattedMessage[] = [];
const messageIds: MessageId[] = [];
formattedMessages.push({
content: msg.content,
role,
messageId: msgId,
isLast,
hasTools,
toolResults: msg.tool_results || [],
});
// Prepare messages
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
const role = msg.role === 'user' ? ROLE.USER : ROLE.ASSISTANT;
const msgId = randomUUID();
const isLast = i === messages.length - 1;
messageIds.push({ messageId: msgId, role });
}
formattedMessages.push({
content: msg.content,
role,
messageId: msgId,
isLast,
hasTools,
toolResults: msg.tool_results || [],
});
// Map reasoning effort to thinking level
let thinkingLevel: ThinkingLevelType = THINKING_LEVEL.UNSPECIFIED;
if (reasoningEffort === "medium") thinkingLevel = THINKING_LEVEL.MEDIUM;
else if (reasoningEffort === "high") thinkingLevel = THINKING_LEVEL.HIGH;
messageIds.push({ messageId: msgId, role });
}
// Build arrays for messages and tools
const messageFields = formattedMessages.map((fm) =>
encodeField(
FIELD.MESSAGES,
WIRE_TYPE.LEN,
encodeMessage(
fm.content,
fm.role,
fm.messageId,
fm.isLast,
fm.hasTools,
fm.toolResults
)
)
);
// Map reasoning effort to thinking level
let thinkingLevel: ThinkingLevelType = THINKING_LEVEL.UNSPECIFIED;
if (reasoningEffort === 'medium') thinkingLevel = THINKING_LEVEL.MEDIUM;
else if (reasoningEffort === 'high') thinkingLevel = THINKING_LEVEL.HIGH;
const messageIdFields = messageIds.map((mid) =>
encodeField(
FIELD.MESSAGE_IDS,
WIRE_TYPE.LEN,
encodeMessageId(mid.messageId, mid.role)
)
);
// Build arrays for messages and tools
const messageFields = formattedMessages.map((fm) =>
encodeField(
FIELD.MESSAGES,
WIRE_TYPE.LEN,
encodeMessage(fm.content, fm.role, fm.messageId, fm.isLast, fm.hasTools, fm.toolResults)
)
);
const toolFields =
tools?.length > 0
? tools.map((tool) =>
encodeField(FIELD.MCP_TOOLS, WIRE_TYPE.LEN, encodeMcpTool(tool))
)
: [];
const messageIdFields = messageIds.map((mid) =>
encodeField(FIELD.MESSAGE_IDS, WIRE_TYPE.LEN, encodeMessageId(mid.messageId, mid.role))
);
const supportedToolsField = isAgentic
? [encodeField(FIELD.SUPPORTED_TOOLS, WIRE_TYPE.LEN, encodeVarint(1))]
: [];
const toolFields =
tools?.length > 0
? tools.map((tool) => encodeField(FIELD.MCP_TOOLS, WIRE_TYPE.LEN, encodeMcpTool(tool)))
: [];
// Concatenate all parts
const parts: Uint8Array[] = [
...messageFields,
encodeField(FIELD.UNKNOWN_2, WIRE_TYPE.VARINT, 1),
encodeField(FIELD.INSTRUCTION, WIRE_TYPE.LEN, encodeInstruction("")),
encodeField(FIELD.UNKNOWN_4, WIRE_TYPE.VARINT, 1),
encodeField(FIELD.MODEL, WIRE_TYPE.LEN, encodeModel(modelName)),
encodeField(FIELD.WEB_TOOL, WIRE_TYPE.LEN, ""),
encodeField(FIELD.UNKNOWN_13, WIRE_TYPE.VARINT, 1),
encodeField(FIELD.CURSOR_SETTING, WIRE_TYPE.LEN, encodeCursorSetting()),
encodeField(FIELD.UNKNOWN_19, WIRE_TYPE.VARINT, 1),
encodeField(FIELD.CONVERSATION_ID, WIRE_TYPE.LEN, randomUUID()),
encodeField(FIELD.METADATA, WIRE_TYPE.LEN, encodeMetadata()),
encodeField(FIELD.IS_AGENTIC, WIRE_TYPE.VARINT, isAgentic ? 1 : 0),
...supportedToolsField,
...messageIdFields,
...toolFields,
encodeField(FIELD.LARGE_CONTEXT, WIRE_TYPE.VARINT, 0),
encodeField(FIELD.UNKNOWN_38, WIRE_TYPE.VARINT, 0),
encodeField(
FIELD.UNIFIED_MODE,
WIRE_TYPE.VARINT,
isAgentic ? UNIFIED_MODE.AGENT : UNIFIED_MODE.CHAT
),
encodeField(FIELD.UNKNOWN_47, WIRE_TYPE.LEN, ""),
encodeField(FIELD.SHOULD_DISABLE_TOOLS, WIRE_TYPE.VARINT, isAgentic ? 0 : 1),
encodeField(FIELD.THINKING_LEVEL, WIRE_TYPE.VARINT, thinkingLevel),
encodeField(FIELD.UNKNOWN_51, WIRE_TYPE.VARINT, 0),
encodeField(FIELD.UNKNOWN_53, WIRE_TYPE.VARINT, 1),
encodeField(
FIELD.UNIFIED_MODE_NAME,
WIRE_TYPE.LEN,
isAgentic ? "Agent" : "Ask"
),
];
const supportedToolsField = isAgentic
? [encodeField(FIELD.SUPPORTED_TOOLS, WIRE_TYPE.LEN, encodeVarint(1))]
: [];
return concatArrays(...parts);
// Concatenate all parts
const parts: Uint8Array[] = [
...messageFields,
encodeField(FIELD.UNKNOWN_2, WIRE_TYPE.VARINT, 1),
encodeField(FIELD.INSTRUCTION, WIRE_TYPE.LEN, encodeInstruction('')),
encodeField(FIELD.UNKNOWN_4, WIRE_TYPE.VARINT, 1),
encodeField(FIELD.MODEL, WIRE_TYPE.LEN, encodeModel(modelName)),
encodeField(FIELD.WEB_TOOL, WIRE_TYPE.LEN, ''),
encodeField(FIELD.UNKNOWN_13, WIRE_TYPE.VARINT, 1),
encodeField(FIELD.CURSOR_SETTING, WIRE_TYPE.LEN, encodeCursorSetting()),
encodeField(FIELD.UNKNOWN_19, WIRE_TYPE.VARINT, 1),
encodeField(FIELD.CONVERSATION_ID, WIRE_TYPE.LEN, randomUUID()),
encodeField(FIELD.METADATA, WIRE_TYPE.LEN, encodeMetadata()),
encodeField(FIELD.IS_AGENTIC, WIRE_TYPE.VARINT, isAgentic ? 1 : 0),
...supportedToolsField,
...messageIdFields,
...toolFields,
encodeField(FIELD.LARGE_CONTEXT, WIRE_TYPE.VARINT, 0),
encodeField(FIELD.UNKNOWN_38, WIRE_TYPE.VARINT, 0),
encodeField(
FIELD.UNIFIED_MODE,
WIRE_TYPE.VARINT,
isAgentic ? UNIFIED_MODE.AGENT : UNIFIED_MODE.CHAT
),
encodeField(FIELD.UNKNOWN_47, WIRE_TYPE.LEN, ''),
encodeField(FIELD.SHOULD_DISABLE_TOOLS, WIRE_TYPE.VARINT, isAgentic ? 0 : 1),
encodeField(FIELD.THINKING_LEVEL, WIRE_TYPE.VARINT, thinkingLevel),
encodeField(FIELD.UNKNOWN_51, WIRE_TYPE.VARINT, 0),
encodeField(FIELD.UNKNOWN_53, WIRE_TYPE.VARINT, 1),
encodeField(FIELD.UNIFIED_MODE_NAME, WIRE_TYPE.LEN, isAgentic ? 'Agent' : 'Ask'),
];
return concatArrays(...parts);
}
/**
* Build chat request wrapped in top-level message
*/
export function buildChatRequest(
messages: CursorMessage[],
modelName: string,
tools: CursorTool[] = [],
reasoningEffort: string | null = null
messages: CursorMessage[],
modelName: string,
tools: CursorTool[] = [],
reasoningEffort: string | null = null
): Uint8Array {
return encodeField(
FIELD.REQUEST,
WIRE_TYPE.LEN,
encodeRequest(messages, modelName, tools, reasoningEffort)
);
return encodeField(
FIELD.REQUEST,
WIRE_TYPE.LEN,
encodeRequest(messages, modelName, tools, reasoningEffort)
);
}
/**
* Generate complete Cursor request body with ConnectRPC framing
*/
export function generateCursorBody(
messages: CursorMessage[],
modelName: string,
tools: CursorTool[] = [],
reasoningEffort: string | null = null
messages: CursorMessage[],
modelName: string,
tools: CursorTool[] = [],
reasoningEffort: string | null = null
): Uint8Array {
const protobuf = buildChatRequest(messages, modelName, tools, reasoningEffort);
const framed = wrapConnectRPCFrame(protobuf, false); // Cursor doesn't support compressed requests
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;
const protobuf = buildChatRequest(messages, modelName, tools, reasoningEffort);
const framed = wrapConnectRPCFrame(protobuf, false); // Cursor doesn't support compressed requests
return framed;
}
// Re-export all functions
export {
encodeVarint,
encodeField,
encodeMessage,
encodeInstruction,
encodeModel,
encodeCursorSetting,
encodeMetadata,
encodeMessageId,
encodeMcpTool,
wrapConnectRPCFrame,
decodeVarint,
decodeField,
decodeMessage,
parseConnectRPCFrame,
extractTextFromResponse,
encodeVarint,
encodeField,
encodeMessage,
encodeInstruction,
encodeModel,
encodeCursorSetting,
encodeMetadata,
encodeMessageId,
encodeMcpTool,
wrapConnectRPCFrame,
decodeVarint,
decodeField,
decodeMessage,
parseConnectRPCFrame,
extractTextFromResponse,
};
+110 -100
View File
@@ -3,30 +3,26 @@
* Converts OpenAI messages to Cursor format
*/
import type {
CursorMessage,
CursorToolResult,
CursorTool,
} from "./cursor-protobuf-schema.js";
import type { CursorMessage, CursorToolResult, CursorTool } from './cursor-protobuf-schema.js';
/** OpenAI message format */
interface OpenAIMessage {
role: string;
content: string | Array<{ type: string; text?: string }>;
name?: string;
tool_call_id?: string;
tool_calls?: Array<{
id: string;
type: string;
function: { name: string; arguments: string };
}>;
role: string;
content: string | Array<{ type: string; text?: string }>;
name?: string;
tool_call_id?: string;
tool_calls?: Array<{
id: string;
type: string;
function: { name: string; arguments: string };
}>;
}
/** OpenAI request body */
interface OpenAIRequestBody {
messages: OpenAIMessage[];
tools?: CursorTool[];
reasoning_effort?: string;
messages: OpenAIMessage[];
tools?: CursorTool[];
reasoning_effort?: string;
}
/**
@@ -36,91 +32,105 @@ interface OpenAIRequestBody {
* - assistant with tool_calls → keep tool_calls structure (Cursor supports it natively)
*/
function convertMessages(messages: OpenAIMessage[]): CursorMessage[] {
const result: CursorMessage[] = [];
let pendingToolResults: CursorToolResult[] = [];
const result: CursorMessage[] = [];
let pendingToolResults: CursorToolResult[] = [];
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
if (msg.role === "system") {
result.push({
role: "user",
content: `[System Instructions]\n${msg.content}`,
});
continue;
}
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${content}`,
});
continue;
}
if (msg.role === "tool") {
let toolContent = "";
if (typeof msg.content === "string") {
toolContent = msg.content;
} else if (Array.isArray(msg.content)) {
for (const part of msg.content) {
if (part.type === "text" && part.text) {
toolContent += part.text;
}
}
}
if (msg.role === 'tool') {
let toolContent = '';
if (typeof msg.content === 'string') {
toolContent = msg.content;
} else if (Array.isArray(msg.content)) {
for (const part of msg.content) {
if (part.type === 'text' && part.text) {
toolContent += part.text;
}
}
}
const toolName = msg.name || "tool";
const toolCallId = msg.tool_call_id || "";
const toolName = msg.name || 'tool';
const toolCallId = msg.tool_call_id || '';
// Accumulate tool result
pendingToolResults.push({
tool_call_id: toolCallId,
name: toolName,
index: pendingToolResults.length,
raw_args: toolContent,
});
continue;
}
// Accumulate tool result
pendingToolResults.push({
tool_call_id: toolCallId,
name: toolName,
index: pendingToolResults.length,
raw_args: toolContent,
});
continue;
}
if (msg.role === "user" || msg.role === "assistant") {
let content = "";
if (msg.role === 'user' || msg.role === 'assistant') {
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;
}
}
}
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;
}
}
}
// Keep tool_calls structure for assistant messages
if (msg.role === "assistant" && msg.tool_calls && msg.tool_calls.length > 0) {
const assistantMsg: CursorMessage = { role: "assistant", content: "" };
if (content) {
assistantMsg.content = content;
}
assistantMsg.tool_calls = msg.tool_calls;
// Keep tool_calls structure for assistant messages
if (msg.role === 'assistant' && msg.tool_calls && msg.tool_calls.length > 0) {
const assistantMsg: CursorMessage = { role: 'assistant', content: '' };
if (content) {
assistantMsg.content = content;
}
assistantMsg.tool_calls = msg.tool_calls;
// Attach pending tool results to assistant message with tool_calls
if (pendingToolResults.length > 0) {
assistantMsg.tool_results = pendingToolResults;
pendingToolResults = [];
}
// Attach pending tool results to assistant message with tool_calls
if (pendingToolResults.length > 0) {
assistantMsg.tool_results = pendingToolResults;
pendingToolResults = [];
}
result.push(assistantMsg);
} else if (content || pendingToolResults.length > 0) {
const msgObj: CursorMessage = {
role: msg.role,
content: content || "",
};
result.push(assistantMsg);
} else if (content || pendingToolResults.length > 0) {
const msgObj: CursorMessage = {
role: msg.role,
content: content || '',
};
// Attach pending tool results to this message
if (pendingToolResults.length > 0) {
msgObj.tool_results = pendingToolResults;
pendingToolResults = [];
}
// Attach pending tool results to this message
if (pendingToolResults.length > 0) {
msgObj.tool_results = pendingToolResults;
pendingToolResults = [];
}
result.push(msgObj);
}
}
}
result.push(msgObj);
}
continue;
}
return result;
// Unknown role - skip with debug warning
if (process.env.CCS_DEBUG) {
console.error(`[cursor] Unknown message role: ${msg.role}, skipping`);
}
}
return result;
}
/**
@@ -128,18 +138,18 @@ function convertMessages(messages: OpenAIMessage[]): CursorMessage[] {
* Returns modified body with converted messages
*/
export function buildCursorRequest(
model: string,
body: OpenAIRequestBody,
stream: boolean,
credentials: unknown
_model: string,
body: OpenAIRequestBody,
_stream: boolean,
_credentials: unknown
): {
messages: CursorMessage[];
tools?: CursorTool[];
messages: CursorMessage[];
tools?: CursorTool[];
} {
const messages = convertMessages(body.messages || []);
const messages = convertMessages(body.messages || []);
return {
...body,
messages,
};
return {
...body,
messages,
};
}
+3 -93
View File
@@ -1,7 +1,7 @@
/**
* Cursor IDE Type Definitions
*
* TypeScript interfaces for the Cursor module.
* TypeScript interfaces for the Cursor auth module.
*/
/**
@@ -32,98 +32,8 @@ export interface CursorAuthStatus {
credentials?: CursorCredentials;
/** Hours since credentials were imported (if available) */
tokenAge?: number;
}
/**
* Cursor daemon/process status
*/
export interface CursorDaemonStatus {
/** Whether daemon is running */
running: boolean;
/** Port number daemon is listening on */
port: number;
/** Process ID (if available) */
pid?: number;
}
/**
* Cursor AI model
*/
export interface CursorModel {
/** Model ID */
id: string;
/** Display name */
name: string;
/** Provider (e.g., 'openai', 'anthropic') */
provider: string;
/** 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;
/** Whether token has expired (>24 hours old) */
expired?: boolean;
}
/**
+2 -2
View File
@@ -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 },
};
+1 -1
View File
@@ -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(),
+1 -1
View File
@@ -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())
+23 -17
View File
@@ -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,
},
// ---------------------------------------------------------------------------
+4 -4
View File
@@ -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();
});
});
});
+621
View File
@@ -0,0 +1,621 @@
/**
* Account Safety Guards Unit Tests
*
* Tests ban detection, email masking, cross-provider duplicate detection,
* enforcement lifecycle, and crash recovery.
*/
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
isBanResponse,
maskEmail,
detectCrossProviderDuplicates,
enforceProviderIsolation,
cleanupStaleAutoPauses,
restoreAutoPausedAccounts,
checkNewAccountConflict,
handleBanDetection,
warnCrossProviderDuplicates,
} from '../../../src/cliproxy/account-safety';
// --- Test isolation: use temp CCS_HOME ---
let tmpDir: string;
let origCcsHome: string | undefined;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-test-safety-'));
origCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tmpDir;
});
afterEach(() => {
if (origCcsHome !== undefined) {
process.env.CCS_HOME = origCcsHome;
} else {
delete process.env.CCS_HOME;
}
fs.rmSync(tmpDir, { recursive: true, force: true });
});
// CCS_HOME appends .ccs — all paths go through getCcsDir() = CCS_HOME/.ccs
function ccsDir(): string {
return path.join(tmpDir, '.ccs');
}
// --- Helper: write accounts registry ---
function writeRegistry(providers: Record<string, unknown>): void {
const registryDir = path.join(ccsDir(), 'cliproxy');
fs.mkdirSync(registryDir, { recursive: true });
fs.writeFileSync(
path.join(registryDir, 'accounts.json'),
JSON.stringify({ version: 1, providers }, null, 2)
);
}
// --- Helper: write auto-paused file ---
function writeAutoPaused(sessions: unknown[]): void {
const dir = path.join(ccsDir(), 'cliproxy');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'auto-paused.json'), JSON.stringify({ sessions }, null, 2));
}
function readAutoPaused(): { sessions: unknown[] } {
const filePath = path.join(ccsDir(), 'cliproxy', 'auto-paused.json');
if (!fs.existsSync(filePath)) return { sessions: [] };
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
}
// --- Helper: write dummy token files ---
function writeTokenFile(filename: string, paused = false): void {
const dir = paused
? path.join(ccsDir(), 'cliproxy', 'auth-paused')
: path.join(ccsDir(), 'cliproxy', 'auth');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, filename), JSON.stringify({ type: 'test' }));
}
// ========================================
// isBanResponse
// ========================================
describe('isBanResponse', () => {
it('should detect "disabled in this account"', () => {
expect(isBanResponse('API access disabled in this account')).toBe(true);
});
it('should detect "violation of terms of service"', () => {
expect(isBanResponse('Your account was flagged for violation of terms of service')).toBe(true);
});
it('should detect "account has been suspended"', () => {
expect(isBanResponse('This account has been suspended by Google')).toBe(true);
});
it('should be case-insensitive', () => {
expect(isBanResponse('ACCOUNT HAS BEEN DISABLED')).toBe(true);
});
it('should return false for normal errors', () => {
expect(isBanResponse('Rate limit exceeded')).toBe(false);
expect(isBanResponse('Internal server error')).toBe(false);
expect(isBanResponse('Network timeout')).toBe(false);
});
it('should return false for empty string', () => {
expect(isBanResponse('')).toBe(false);
});
});
// ========================================
// maskEmail
// ========================================
describe('maskEmail', () => {
it('should mask standard email', () => {
expect(maskEmail('user@example.com')).toBe('use***@example.com');
});
it('should handle short local part', () => {
expect(maskEmail('ab@example.com')).toBe('ab***@example.com');
});
it('should handle single char local part', () => {
expect(maskEmail('a@example.com')).toBe('a***@example.com');
});
it('should return input if no @ sign', () => {
expect(maskEmail('not-an-email')).toBe('not-an-email');
});
it('should return input if empty string', () => {
expect(maskEmail('')).toBe('');
});
});
// ========================================
// detectCrossProviderDuplicates
// ========================================
describe('detectCrossProviderDuplicates', () => {
it('should return empty map when no duplicates', () => {
writeRegistry({
gemini: {
default: 'user1@gmail.com',
accounts: {
'user1@gmail.com': {
email: 'user1@gmail.com',
tokenFile: 'gemini-user1.json',
},
},
},
agy: {
default: 'user2@gmail.com',
accounts: {
'user2@gmail.com': {
email: 'user2@gmail.com',
tokenFile: 'agy-user2.json',
},
},
},
});
const dupes = detectCrossProviderDuplicates();
expect(dupes.size).toBe(0);
});
it('should detect same email across providers', () => {
writeRegistry({
gemini: {
default: 'shared@gmail.com',
accounts: {
'shared@gmail.com': {
email: 'shared@gmail.com',
tokenFile: 'gemini-shared.json',
},
},
},
agy: {
default: 'shared@gmail.com',
accounts: {
'shared@gmail.com': {
email: 'shared@gmail.com',
tokenFile: 'agy-shared.json',
},
},
},
});
const dupes = detectCrossProviderDuplicates();
expect(dupes.size).toBe(1);
expect(dupes.get('shared@gmail.com')).toEqual(['gemini', 'agy']);
});
it('should skip paused accounts', () => {
writeRegistry({
gemini: {
default: 'shared@gmail.com',
accounts: {
'shared@gmail.com': {
email: 'shared@gmail.com',
tokenFile: 'gemini-shared.json',
paused: true,
},
},
},
agy: {
default: 'shared@gmail.com',
accounts: {
'shared@gmail.com': {
email: 'shared@gmail.com',
tokenFile: 'agy-shared.json',
},
},
},
});
const dupes = detectCrossProviderDuplicates();
expect(dupes.size).toBe(0);
});
it('should be case-insensitive on email', () => {
writeRegistry({
gemini: {
default: 'User@Gmail.com',
accounts: {
'User@Gmail.com': {
email: 'User@Gmail.com',
tokenFile: 'gemini-user.json',
},
},
},
agy: {
default: 'user@gmail.com',
accounts: {
'user@gmail.com': {
email: 'user@gmail.com',
tokenFile: 'agy-user.json',
},
},
},
});
const dupes = detectCrossProviderDuplicates();
expect(dupes.size).toBe(1);
});
});
// ========================================
// checkNewAccountConflict
// ========================================
describe('checkNewAccountConflict', () => {
it('should return null for non-Google provider', () => {
const result = checkNewAccountConflict('kiro' as never, 'user@gmail.com');
expect(result).toBeNull();
});
it('should return null when no conflict', () => {
writeRegistry({
gemini: {
default: 'other@gmail.com',
accounts: {
'other@gmail.com': {
email: 'other@gmail.com',
tokenFile: 'gemini-other.json',
},
},
},
});
const result = checkNewAccountConflict('agy', 'new@gmail.com');
expect(result).toBeNull();
});
it('should return conflicting providers', () => {
writeRegistry({
gemini: {
default: 'shared@gmail.com',
accounts: {
'shared@gmail.com': {
email: 'shared@gmail.com',
tokenFile: 'gemini-shared.json',
},
},
},
});
const result = checkNewAccountConflict('agy', 'shared@gmail.com');
expect(result).toEqual(['gemini']);
});
it('should return null when email is undefined', () => {
const result = checkNewAccountConflict('agy', undefined);
expect(result).toBeNull();
});
});
// ========================================
// cleanupStaleAutoPauses
// ========================================
describe('cleanupStaleAutoPauses', () => {
it('should do nothing when no sessions', () => {
// No auto-paused.json exists
cleanupStaleAutoPauses();
// Should not throw
});
it('should remove sessions with dead PIDs', () => {
// Use PID 999999999 which is almost certainly dead
writeAutoPaused([
{
initiator: 'gemini',
pid: 999999999,
pausedAt: new Date().toISOString(),
accounts: [{ provider: 'agy', accountId: 'test@gmail.com' }],
},
]);
// Write registry with the paused account so resumeAccount can find it
writeRegistry({
agy: {
default: 'test@gmail.com',
accounts: {
'test@gmail.com': {
email: 'test@gmail.com',
tokenFile: 'agy-test.json',
paused: true,
pausedAt: new Date().toISOString(),
},
},
},
});
writeTokenFile('agy-test.json', true);
cleanupStaleAutoPauses();
const data = readAutoPaused();
expect(data.sessions.length).toBe(0);
});
it('should keep sessions with alive PIDs', () => {
const alivePid = process.pid; // Current process is alive
writeAutoPaused([
{
initiator: 'gemini',
pid: alivePid,
pausedAt: new Date().toISOString(),
accounts: [{ provider: 'agy', accountId: 'test@gmail.com' }],
},
]);
cleanupStaleAutoPauses();
const data = readAutoPaused();
expect(data.sessions.length).toBe(1);
});
});
// ========================================
// enforceProviderIsolation
// ========================================
describe('enforceProviderIsolation', () => {
it('should return 0 for non-Google provider', () => {
const result = enforceProviderIsolation('kiro' as never);
expect(result).toBe(0);
});
it('should return 0 when no conflicting accounts', () => {
writeRegistry({
gemini: {
default: 'user1@gmail.com',
accounts: {
'user1@gmail.com': {
email: 'user1@gmail.com',
tokenFile: 'gemini-user1.json',
},
},
},
agy: {
default: 'user2@gmail.com',
accounts: {
'user2@gmail.com': {
email: 'user2@gmail.com',
tokenFile: 'agy-user2.json',
},
},
},
});
writeTokenFile('gemini-user1.json');
writeTokenFile('agy-user2.json');
const result = enforceProviderIsolation('gemini');
expect(result).toBe(0);
});
it('should pause conflicting accounts and record session', () => {
writeRegistry({
gemini: {
default: 'shared@gmail.com',
accounts: {
'shared@gmail.com': {
email: 'shared@gmail.com',
tokenFile: 'gemini-shared.json',
},
},
},
agy: {
default: 'shared@gmail.com',
accounts: {
'shared@gmail.com': {
email: 'shared@gmail.com',
tokenFile: 'agy-shared.json',
},
},
},
});
writeTokenFile('gemini-shared.json');
writeTokenFile('agy-shared.json');
const result = enforceProviderIsolation('gemini');
expect(result).toBe(1);
// Verify auto-paused.json was written
const data = readAutoPaused();
expect(data.sessions.length).toBe(1);
expect(data.sessions[0].initiator).toBe('gemini');
expect(data.sessions[0].pid).toBe(process.pid);
});
});
// ========================================
// restoreAutoPausedAccounts
// ========================================
describe('restoreAutoPausedAccounts', () => {
it('should do nothing when no session exists', () => {
restoreAutoPausedAccounts('gemini');
// Should not throw
});
it('should skip accounts re-paused after enforcement', () => {
const enforcementTime = '2024-01-01T00:00:00.000Z';
const laterTime = '2024-01-01T01:00:00.000Z';
writeAutoPaused([
{
initiator: 'gemini',
pid: process.pid,
pausedAt: enforcementTime,
accounts: [{ provider: 'agy', accountId: 'banned@gmail.com' }],
},
]);
writeRegistry({
agy: {
default: 'banned@gmail.com',
accounts: {
'banned@gmail.com': {
email: 'banned@gmail.com',
tokenFile: 'agy-banned.json',
paused: true,
pausedAt: laterTime, // Re-paused AFTER enforcement (e.g., ban)
},
},
},
});
writeTokenFile('agy-banned.json', true);
restoreAutoPausedAccounts('gemini');
// Account should NOT be restored because it was re-paused later
const registry = JSON.parse(
fs.readFileSync(path.join(ccsDir(), 'cliproxy', 'accounts.json'), 'utf-8')
);
expect(registry.providers.agy.accounts['banned@gmail.com'].paused).toBe(true);
});
});
// ========================================
// handleBanDetection
// ========================================
describe('handleBanDetection', () => {
it('should pause account when ban error detected', () => {
writeRegistry({
gemini: {
default: 'user@gmail.com',
accounts: {
'user@gmail.com': {
email: 'user@gmail.com',
tokenFile: 'gemini-user.json',
},
},
},
});
writeTokenFile('gemini-user.json');
const result = handleBanDetection(
'gemini',
'user@gmail.com',
'API access disabled in this account'
);
expect(result).toBe(true);
// Verify account was paused in registry
const registry = JSON.parse(
fs.readFileSync(path.join(ccsDir(), 'cliproxy', 'accounts.json'), 'utf-8')
);
expect(registry.providers.gemini.accounts['user@gmail.com'].paused).toBe(true);
});
it('should return false for non-ban errors', () => {
writeRegistry({
gemini: {
default: 'user@gmail.com',
accounts: {
'user@gmail.com': {
email: 'user@gmail.com',
tokenFile: 'gemini-user.json',
},
},
},
});
writeTokenFile('gemini-user.json');
const result = handleBanDetection('gemini', 'user@gmail.com', 'Rate limit exceeded');
expect(result).toBe(false);
// Verify account was NOT paused
const registry = JSON.parse(
fs.readFileSync(path.join(ccsDir(), 'cliproxy', 'accounts.json'), 'utf-8')
);
expect(registry.providers.gemini.accounts['user@gmail.com'].paused).toBeUndefined();
});
});
// ========================================
// warnCrossProviderDuplicates
// ========================================
describe('warnCrossProviderDuplicates', () => {
it('should return true when duplicates exist', () => {
writeRegistry({
gemini: {
default: 'shared@gmail.com',
accounts: {
'shared@gmail.com': {
email: 'shared@gmail.com',
tokenFile: 'gemini-shared.json',
},
},
},
agy: {
default: 'shared@gmail.com',
accounts: {
'shared@gmail.com': {
email: 'shared@gmail.com',
tokenFile: 'agy-shared.json',
},
},
},
});
const result = warnCrossProviderDuplicates('gemini');
expect(result).toBe(true);
});
it('should return false when no duplicates', () => {
writeRegistry({
gemini: {
default: 'user1@gmail.com',
accounts: {
'user1@gmail.com': {
email: 'user1@gmail.com',
tokenFile: 'gemini-user1.json',
},
},
},
agy: {
default: 'user2@gmail.com',
accounts: {
'user2@gmail.com': {
email: 'user2@gmail.com',
tokenFile: 'agy-user2.json',
},
},
},
});
const result = warnCrossProviderDuplicates('gemini');
expect(result).toBe(false);
});
it('should return false for non-Google providers', () => {
writeRegistry({
kiro: {
default: 'user@example.com',
accounts: {
'user@example.com': {
email: 'user@example.com',
tokenFile: 'kiro-user.json',
},
},
},
});
const result = warnCrossProviderDuplicates('kiro' as never);
expect(result).toBe(false);
});
});
@@ -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();
});
});
});
+448
View File
@@ -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');
});
});
+657
View File
@@ -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;
}
});
});
});
+4 -4
View File
@@ -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',