mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 03:13:12 +00:00
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:
commit
55bc53152c
39 files changed
+4786
-1719
No files matched your search
@@ -33,6 +33,7 @@ pnpm-lock.yaml
|
||||
package-lock.json
|
||||
|
||||
.claude/active-plan
|
||||
.claude/agent-memory/
|
||||
|
||||
# Logs directory
|
||||
logs/
|
||||
|
||||
@@ -1,3 +1,26 @@
|
||||
## [7.43.0](https://github.com/kaitranntt/ccs/compare/v7.42.0...v7.43.0) (2026-02-11)
|
||||
|
||||
### Features
|
||||
|
||||
* **cliproxy:** add account safety guards to prevent Google account bans ([#516](https://github.com/kaitranntt/ccs/issues/516)) ([e055dac](https://github.com/kaitranntt/ccs/commit/e055dac1996bd3cd3c4e5ee0f11dad22d8d2a838)), closes [#509](https://github.com/kaitranntt/ccs/issues/509) [#512](https://github.com/kaitranntt/ccs/issues/512)
|
||||
* **cliproxy:** runtime quota monitoring during active sessions ([#529](https://github.com/kaitranntt/ccs/issues/529)) ([c6c94a0](https://github.com/kaitranntt/ccs/commit/c6c94a0c1e7bf82dd56295a76d833d7d52694718)), closes [#524](https://github.com/kaitranntt/ccs/issues/524)
|
||||
* **glm:** update default model to GLM-5 and fix all GLM pricing ([3e26dee](https://github.com/kaitranntt/ccs/commit/3e26dee0134fa576a57f216f232a0215084e74a3)), closes [#532](https://github.com/kaitranntt/ccs/issues/532)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **cliproxy:** add fork:true for Claude model aliases in config generator ([#523](https://github.com/kaitranntt/ccs/issues/523)) ([4065399](https://github.com/kaitranntt/ccs/commit/4065399d8aa46ccdb115081e461c5651d0afaa2e)), closes [#522](https://github.com/kaitranntt/ccs/issues/522)
|
||||
* **cliproxy:** address all review feedback (Low + informational) ([7d049d8](https://github.com/kaitranntt/ccs/commit/7d049d8f1e8655856a1a9636d21f6eb3992752a0))
|
||||
* **cliproxy:** mask email in ban detection and fix JSDoc default ([fcc605b](https://github.com/kaitranntt/ccs/commit/fcc605bc1f02af4da518d21c10f8c77b38a793ad))
|
||||
* **cliproxy:** migrate deprecated gemini-claude-* model names to upstream claude-* names ([#515](https://github.com/kaitranntt/ccs/issues/515)) ([6afbb72](https://github.com/kaitranntt/ccs/commit/6afbb72b472029358fc3d9b2fed488fd4779695b)), closes [#513](https://github.com/kaitranntt/ccs/issues/513)
|
||||
* **glm:** fix missed help text reference and glm-4.5-air pricing ([7d9c538](https://github.com/kaitranntt/ccs/commit/7d9c538248f93089ae6483af4ea1d01e555e2e20))
|
||||
* **hooks:** isolate image type check before error-prone processing ([#514](https://github.com/kaitranntt/ccs/issues/514)) ([19de427](https://github.com/kaitranntt/ccs/commit/19de42704f683a29134982dfb643e97c3123bf7c)), closes [#511](https://github.com/kaitranntt/ccs/issues/511)
|
||||
|
||||
## [7.42.0](https://github.com/kaitranntt/ccs/compare/v7.41.0...v7.42.0) (2026-02-11)
|
||||
|
||||
### Features
|
||||
|
||||
* account safety, quota monitoring, and stability fixes ([#530](https://github.com/kaitranntt/ccs/issues/530)) ([0518050](https://github.com/kaitranntt/ccs/commit/051805074eb80db839a4deb8ab1dcb89f29766de)), closes [#515](https://github.com/kaitranntt/ccs/issues/515) [#513](https://github.com/kaitranntt/ccs/issues/513) [#514](https://github.com/kaitranntt/ccs/issues/514) [#511](https://github.com/kaitranntt/ccs/issues/511) [#523](https://github.com/kaitranntt/ccs/issues/523) [#522](https://github.com/kaitranntt/ccs/issues/522) [#516](https://github.com/kaitranntt/ccs/issues/516) [#509](https://github.com/kaitranntt/ccs/issues/509) [#512](https://github.com/kaitranntt/ccs/issues/512) [#529](https://github.com/kaitranntt/ccs/issues/529) [#524](https://github.com/kaitranntt/ccs/issues/524)
|
||||
|
||||
## [7.41.0](https://github.com/kaitranntt/ccs/compare/v7.40.0...v7.41.0) (2026-02-11)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic",
|
||||
"ANTHROPIC_AUTH_TOKEN": "YOUR_GLM_API_KEY_HERE",
|
||||
"ANTHROPIC_MODEL": "glm-4.7",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "glm-4.7",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "glm-4.7",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "glm-4.7"
|
||||
"ANTHROPIC_MODEL": "glm-5",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "glm-5",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "glm-5",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "glm-5"
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,10 @@
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.z.ai/api/coding/paas/v4/chat/completions",
|
||||
"ANTHROPIC_AUTH_TOKEN": "YOUR_GLM_API_KEY_HERE",
|
||||
"ANTHROPIC_MODEL": "glm-4.7",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "glm-4.7",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "glm-4.7",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "glm-4.7",
|
||||
"ANTHROPIC_MODEL": "glm-5",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "glm-5",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "glm-5",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "glm-5",
|
||||
"ANTHROPIC_TEMPERATURE": "0.2",
|
||||
"ANTHROPIC_MAX_TOKENS": "65536",
|
||||
"MAX_THINKING_TOKENS": "32768",
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://ollama.com",
|
||||
"ANTHROPIC_AUTH_TOKEN": "YOUR_OLLAMA_CLOUD_API_KEY_HERE",
|
||||
"ANTHROPIC_MODEL": "glm-4.7:cloud",
|
||||
"ANTHROPIC_MODEL": "glm-5:cloud",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "qwen3-coder:480b",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "glm-4.7:cloud",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "glm-5:cloud",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "minimax-m2.1:cloud"
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kaitranntt/ccs",
|
||||
"version": "7.41.0-dev.1",
|
||||
"version": "7.43.0-dev.2",
|
||||
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
|
||||
"keywords": [
|
||||
"cli",
|
||||
|
||||
@@ -65,7 +65,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
||||
description: 'Claude via Z.AI',
|
||||
baseUrl: 'https://api.z.ai/api/anthropic',
|
||||
defaultProfileName: 'glm',
|
||||
defaultModel: 'glm-4.7',
|
||||
defaultModel: 'glm-5',
|
||||
apiKeyPlaceholder: 'ghp_...',
|
||||
apiKeyHint: 'Get your API key from Z.AI',
|
||||
category: 'alternative',
|
||||
@@ -77,7 +77,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
||||
description: 'GLM with Thinking mode support',
|
||||
baseUrl: 'https://api.z.ai/api/coding/paas/v4/chat/completions',
|
||||
defaultProfileName: 'glmt',
|
||||
defaultModel: 'glm-4.7',
|
||||
defaultModel: 'glm-5',
|
||||
apiKeyPlaceholder: 'ghp_...',
|
||||
apiKeyHint: 'Same API key as GLM',
|
||||
category: 'alternative',
|
||||
@@ -156,10 +156,10 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
||||
{
|
||||
id: 'ollama-cloud',
|
||||
name: 'Ollama Cloud',
|
||||
description: 'Ollama cloud models via direct API (glm-4.7:cloud, minimax-m2.1:cloud)',
|
||||
description: 'Ollama cloud models via direct API (glm-5:cloud, minimax-m2.1:cloud)',
|
||||
baseUrl: 'https://ollama.com',
|
||||
defaultProfileName: 'ollama-cloud',
|
||||
defaultModel: 'glm-4.7:cloud',
|
||||
defaultModel: 'glm-5:cloud',
|
||||
apiKeyPlaceholder: 'YOUR_OLLAMA_CLOUD_API_KEY',
|
||||
apiKeyHint: 'Get your API key at ollama.com',
|
||||
category: 'alternative',
|
||||
|
||||
+1
-1
@@ -162,7 +162,7 @@ async function execClaudeWithProxy(
|
||||
|
||||
// 4. Spawn Claude CLI with proxy URL
|
||||
// Use model from user's settings (not hardcoded) - fixes issue #358
|
||||
const configuredModel = envData['ANTHROPIC_MODEL'] || 'glm-4.7';
|
||||
const configuredModel = envData['ANTHROPIC_MODEL'] || 'glm-5';
|
||||
const envVars: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}`,
|
||||
ANTHROPIC_AUTH_TOKEN: apiKey,
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
/**
|
||||
* Account Safety Guards
|
||||
*
|
||||
* Prevents Google account bans by:
|
||||
* 1. Cross-provider isolation (auto-pause conflicting accounts at launch, restore on exit)
|
||||
* 2. Ban/disable detection (auto-pauses affected accounts on error response)
|
||||
* 3. Crash recovery (restores stale auto-pauses from dead sessions)
|
||||
*
|
||||
* Ref: https://github.com/kaitranntt/ccs/issues/509
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { warn, info } from '../utils/ui';
|
||||
import { CLIProxyProvider } from './types';
|
||||
import { loadAccountsRegistry, pauseAccount, resumeAccount } from './accounts/registry';
|
||||
import { getCcsDir } from '../utils/config-manager';
|
||||
|
||||
/** Providers that use Google OAuth (ban risk when overlapping) */
|
||||
const GOOGLE_OAUTH_PROVIDERS: CLIProxyProvider[] = ['gemini', 'agy', 'codex'];
|
||||
|
||||
// --- Auto-pause persistence (crash recovery) ---
|
||||
|
||||
interface AutoPausedSession {
|
||||
initiator: CLIProxyProvider;
|
||||
pid: number;
|
||||
pausedAt: string;
|
||||
accounts: Array<{ provider: CLIProxyProvider; accountId: string }>;
|
||||
}
|
||||
|
||||
interface AutoPausedFile {
|
||||
sessions: AutoPausedSession[];
|
||||
}
|
||||
|
||||
function getAutoPausedPath(): string {
|
||||
return path.join(getCcsDir(), 'cliproxy', 'auto-paused.json');
|
||||
}
|
||||
|
||||
function loadAutoPaused(): AutoPausedFile {
|
||||
try {
|
||||
const filePath = getAutoPausedPath();
|
||||
if (fs.existsSync(filePath)) {
|
||||
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
if (Array.isArray(data.sessions)) return { sessions: data.sessions };
|
||||
}
|
||||
} catch {
|
||||
// Corrupted or malformed file — start fresh
|
||||
}
|
||||
return { sessions: [] };
|
||||
}
|
||||
|
||||
function saveAutoPaused(data: AutoPausedFile): void {
|
||||
const filePath = getAutoPausedPath();
|
||||
if (data.sessions.length === 0) {
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
return;
|
||||
}
|
||||
const dir = path.dirname(filePath);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a process is alive. NOTE: PIDs can be recycled by the OS.
|
||||
* If a stale PID is reused by an unrelated process, cleanup is deferred until that process exits.
|
||||
* This is acceptable — next CCS launch will self-heal via cleanupStaleAutoPauses().
|
||||
*/
|
||||
function isPidAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect same email registered under multiple Google OAuth providers.
|
||||
* This is the primary cause of account bans — Google sees concurrent
|
||||
* OAuth usage from different client IDs as suspicious activity.
|
||||
*
|
||||
* Returns map of email -> providers it appears in (only duplicates).
|
||||
*/
|
||||
export function detectCrossProviderDuplicates(): Map<string, CLIProxyProvider[]> {
|
||||
const registry = loadAccountsRegistry();
|
||||
|
||||
// Build email -> providers mapping (only Google OAuth providers)
|
||||
const emailProviders = new Map<string, CLIProxyProvider[]>();
|
||||
|
||||
for (const provider of GOOGLE_OAUTH_PROVIDERS) {
|
||||
const providerAccounts = registry.providers[provider];
|
||||
if (!providerAccounts) continue;
|
||||
|
||||
for (const [, account] of Object.entries(providerAccounts.accounts)) {
|
||||
const email = account.email;
|
||||
if (!email || account.paused) continue;
|
||||
|
||||
const normalized = email.toLowerCase();
|
||||
const existing = emailProviders.get(normalized) ?? [];
|
||||
existing.push(provider);
|
||||
emailProviders.set(normalized, existing);
|
||||
}
|
||||
}
|
||||
|
||||
// Filter to only duplicates (email in 2+ providers)
|
||||
const duplicates = new Map<string, CLIProxyProvider[]>();
|
||||
for (const [email, providers] of emailProviders) {
|
||||
if (providers.length > 1) {
|
||||
duplicates.set(email, providers);
|
||||
}
|
||||
}
|
||||
|
||||
return duplicates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a newly registered account creates a cross-provider conflict.
|
||||
* Returns the conflicting providers, or null if no conflict.
|
||||
*/
|
||||
export function checkNewAccountConflict(
|
||||
provider: CLIProxyProvider,
|
||||
email: string | undefined
|
||||
): CLIProxyProvider[] | null {
|
||||
if (!email || !GOOGLE_OAUTH_PROVIDERS.includes(provider)) return null;
|
||||
|
||||
const registry = loadAccountsRegistry();
|
||||
const normalized = email.toLowerCase();
|
||||
const conflicts: CLIProxyProvider[] = [];
|
||||
|
||||
for (const other of GOOGLE_OAUTH_PROVIDERS) {
|
||||
if (other === provider) continue;
|
||||
|
||||
const providerAccounts = registry.providers[other];
|
||||
if (!providerAccounts) continue;
|
||||
|
||||
for (const [, account] of Object.entries(providerAccounts.accounts)) {
|
||||
if (account.email?.toLowerCase() === normalized && !account.paused) {
|
||||
conflicts.push(other);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return conflicts.length > 0 ? conflicts : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display cross-provider duplicate warning at session launch.
|
||||
* Returns true if warning was shown.
|
||||
*/
|
||||
export function warnCrossProviderDuplicates(provider: CLIProxyProvider): boolean {
|
||||
if (!GOOGLE_OAUTH_PROVIDERS.includes(provider)) return false;
|
||||
|
||||
const duplicates = detectCrossProviderDuplicates();
|
||||
if (duplicates.size === 0) return false;
|
||||
|
||||
console.error('');
|
||||
console.error(warn('Account safety: cross-provider duplicate detected'));
|
||||
console.error(' Same Google account across providers risks account bans (ref: #509).');
|
||||
console.error('');
|
||||
|
||||
for (const [email, providers] of duplicates) {
|
||||
console.error(` ${maskEmail(email)} -> ${providers.join(', ')}`);
|
||||
}
|
||||
|
||||
console.error('');
|
||||
console.error(' Fix: pause duplicate with "ccs <provider> --pause <account>"');
|
||||
console.error(' or use separate Google accounts per provider.');
|
||||
console.error('');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Warn about a specific new account conflict during OAuth registration.
|
||||
*/
|
||||
export function warnNewAccountConflict(
|
||||
email: string,
|
||||
conflictingProviders: CLIProxyProvider[]
|
||||
): void {
|
||||
console.error('');
|
||||
console.error(warn('Account safety: this email is used by another provider'));
|
||||
console.error(
|
||||
` ${maskEmail(email)} is also registered under: ${conflictingProviders.join(', ')}`
|
||||
);
|
||||
console.error(' Concurrent usage may cause Google to ban your account.');
|
||||
console.error(' Consider pausing the duplicate or using a different account.');
|
||||
console.error('');
|
||||
}
|
||||
|
||||
// --- Enforcement: auto-pause/restore ---
|
||||
|
||||
/**
|
||||
* Restore auto-paused accounts from crashed sessions (dead PIDs).
|
||||
* Call at launch BEFORE enforceProviderIsolation().
|
||||
*/
|
||||
export function cleanupStaleAutoPauses(): void {
|
||||
const data = loadAutoPaused();
|
||||
if (data.sessions.length === 0) return;
|
||||
|
||||
const alive: AutoPausedSession[] = [];
|
||||
|
||||
for (const session of data.sessions) {
|
||||
if (isPidAlive(session.pid)) {
|
||||
alive.push(session);
|
||||
continue;
|
||||
}
|
||||
// Dead PID — restore accounts
|
||||
for (const { provider, accountId } of session.accounts) {
|
||||
resumeAccount(provider, accountId);
|
||||
}
|
||||
console.error(
|
||||
info(
|
||||
`Restored ${session.accounts.length} auto-paused account(s) from crashed ${session.initiator} session`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (alive.length !== data.sessions.length) {
|
||||
saveAutoPaused({ sessions: alive });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce provider isolation by auto-pausing conflicting accounts in other providers.
|
||||
* Records paused accounts for crash recovery and session exit restore.
|
||||
* Returns number of accounts paused.
|
||||
*/
|
||||
export function enforceProviderIsolation(provider: CLIProxyProvider): number {
|
||||
if (!GOOGLE_OAUTH_PROVIDERS.includes(provider)) return 0;
|
||||
|
||||
// If another provider session is actively managing isolation, just warn
|
||||
const data = loadAutoPaused();
|
||||
const otherActive = data.sessions.filter((s) => s.initiator !== provider && isPidAlive(s.pid));
|
||||
if (otherActive.length > 0) return 0;
|
||||
|
||||
const registry = loadAccountsRegistry();
|
||||
const currentAccounts = registry.providers[provider];
|
||||
if (!currentAccounts) return 0;
|
||||
|
||||
// Collect active emails for current provider
|
||||
const myEmails = new Set<string>();
|
||||
for (const [, account] of Object.entries(currentAccounts.accounts)) {
|
||||
if (account.email && !account.paused) {
|
||||
myEmails.add(account.email.toLowerCase());
|
||||
}
|
||||
}
|
||||
if (myEmails.size === 0) return 0;
|
||||
|
||||
// Find conflicting accounts in other Google OAuth providers
|
||||
const toPause: Array<{ provider: CLIProxyProvider; accountId: string }> = [];
|
||||
|
||||
for (const other of GOOGLE_OAUTH_PROVIDERS) {
|
||||
if (other === provider) continue;
|
||||
const otherAccounts = registry.providers[other];
|
||||
if (!otherAccounts) continue;
|
||||
|
||||
for (const [accountId, account] of Object.entries(otherAccounts.accounts)) {
|
||||
if (account.email && !account.paused && myEmails.has(account.email.toLowerCase())) {
|
||||
toPause.push({ provider: other, accountId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (toPause.length === 0) return 0;
|
||||
|
||||
// Pause conflicting accounts
|
||||
for (const { provider: p, accountId } of toPause) {
|
||||
pauseAccount(p, accountId);
|
||||
}
|
||||
|
||||
// Record for crash recovery (re-read to reduce concurrent write race window).
|
||||
// TOCTOU race is acceptable for a single-user CLI tool — self-heals on next launch.
|
||||
const freshData = loadAutoPaused();
|
||||
freshData.sessions = freshData.sessions.filter((s) => s.initiator !== provider);
|
||||
freshData.sessions.push({
|
||||
initiator: provider,
|
||||
pid: process.pid,
|
||||
pausedAt: new Date().toISOString(),
|
||||
accounts: toPause,
|
||||
});
|
||||
saveAutoPaused(freshData);
|
||||
|
||||
console.error('');
|
||||
console.error(info(`Account safety: auto-paused ${toPause.length} conflicting account(s)`));
|
||||
for (const { provider: p, accountId } of toPause) {
|
||||
const acct = registry.providers[p]?.accounts[accountId];
|
||||
const display = acct?.email ? maskEmail(acct.email) : accountId;
|
||||
console.error(` ${display} (${p})`);
|
||||
}
|
||||
console.error(' Will restore on session exit.');
|
||||
console.error('');
|
||||
|
||||
return toPause.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore accounts that were auto-paused by this session.
|
||||
* Called on session exit (process 'exit' event).
|
||||
* Skips accounts re-paused after enforcement (e.g., by ban handler).
|
||||
*/
|
||||
export function restoreAutoPausedAccounts(provider: CLIProxyProvider): void {
|
||||
const data = loadAutoPaused();
|
||||
const mySession = data.sessions.find((s) => s.initiator === provider && s.pid === process.pid);
|
||||
if (!mySession) return;
|
||||
|
||||
const registry = loadAccountsRegistry();
|
||||
|
||||
for (const { provider: p, accountId } of mySession.accounts) {
|
||||
// Don't restore if account was re-paused after enforcement (e.g., ban detected)
|
||||
const account = registry.providers[p]?.accounts[accountId];
|
||||
if (account?.pausedAt && account.pausedAt > mySession.pausedAt) {
|
||||
continue;
|
||||
}
|
||||
resumeAccount(p, accountId);
|
||||
}
|
||||
|
||||
data.sessions = data.sessions.filter((s) => !(s.initiator === provider && s.pid === process.pid));
|
||||
saveAutoPaused(data);
|
||||
}
|
||||
|
||||
// Error patterns that indicate Google has disabled/banned an account
|
||||
const BAN_PATTERNS = [
|
||||
'disabled in this account',
|
||||
'violation of terms of service',
|
||||
'account has been disabled',
|
||||
'account is disabled',
|
||||
'account has been suspended',
|
||||
'account has been banned',
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if an error message indicates an account ban/disable.
|
||||
*/
|
||||
export function isBanResponse(errorMessage: string): boolean {
|
||||
const lower = errorMessage.toLowerCase();
|
||||
return BAN_PATTERNS.some((pattern) => lower.includes(pattern));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle detected account ban by auto-pausing the affected account.
|
||||
* Returns true if account was paused.
|
||||
*/
|
||||
export function handleBanDetection(
|
||||
provider: CLIProxyProvider,
|
||||
accountId: string,
|
||||
errorMessage: string
|
||||
): boolean {
|
||||
if (!isBanResponse(errorMessage)) return false;
|
||||
|
||||
console.error('');
|
||||
console.error(warn('Account safety: account appears disabled by Google'));
|
||||
console.error(` Account "${maskEmail(accountId)}" (${provider}) returned:`);
|
||||
console.error(` "${truncate(errorMessage, 120)}"`);
|
||||
console.error('');
|
||||
console.error(info('Auto-pausing this account to prevent further issues.'));
|
||||
console.error(` Resume later: ccs ${provider} --resume ${accountId}`);
|
||||
console.error('');
|
||||
|
||||
return pauseAccount(provider, accountId);
|
||||
}
|
||||
|
||||
/** Mask email for privacy in terminal output */
|
||||
export function maskEmail(email: string): string {
|
||||
const [local, domain] = email.split('@');
|
||||
if (!local || !domain) return email;
|
||||
return `${local.slice(0, 3)}***@${domain}`;
|
||||
}
|
||||
|
||||
/** Truncate string with ellipsis */
|
||||
function truncate(str: string, maxLen: number): string {
|
||||
return str.length > maxLen ? str.slice(0, maxLen - 3) + '...' : str;
|
||||
}
|
||||
|
||||
// --- Quota Exhaustion Handling ---
|
||||
|
||||
/**
|
||||
* Write boxed quota warning to stderr (20% threshold).
|
||||
* Uses process.stderr.write() to work alongside inherited stdio.
|
||||
* ASCII-only output (no emojis) per project constraints.
|
||||
*/
|
||||
export function writeQuotaWarning(accountId: string, quotaPercent: number): void {
|
||||
const masked = maskEmail(accountId);
|
||||
const lines = [
|
||||
`[!] Quota Low: ${masked} (${Math.round(quotaPercent)}% remaining)`,
|
||||
` Next session will use a different account if available`,
|
||||
];
|
||||
const maxLen = Math.max(...lines.map((l) => l.length));
|
||||
const border = '\u2550'.repeat(maxLen + 2);
|
||||
|
||||
process.stderr.write('\n');
|
||||
process.stderr.write(`\u2554${border}\u2557\n`);
|
||||
for (const line of lines) {
|
||||
process.stderr.write(`\u2551 ${line.padEnd(maxLen)} \u2551\n`);
|
||||
}
|
||||
process.stderr.write(`\u255A${border}\u255D\n`);
|
||||
process.stderr.write('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Write boxed quota exhaustion alert to stderr.
|
||||
* Called when quota falls below exhaustion_threshold — account will be cooled down.
|
||||
*/
|
||||
function writeQuotaExhausted(
|
||||
accountId: string,
|
||||
switchedTo: string | null,
|
||||
cooldownMinutes: number
|
||||
): void {
|
||||
const masked = maskEmail(accountId);
|
||||
const lines = [`[X] Quota Exhausted: ${masked}`, ` Cooldown: ${cooldownMinutes} minutes`];
|
||||
if (switchedTo) {
|
||||
lines.push(` Next session default: ${maskEmail(switchedTo)}`);
|
||||
} else {
|
||||
lines.push(` No alternative accounts available`);
|
||||
}
|
||||
|
||||
const maxLen = Math.max(...lines.map((l) => l.length));
|
||||
const border = '\u2550'.repeat(maxLen + 2);
|
||||
|
||||
process.stderr.write('\n');
|
||||
process.stderr.write(`\u2554${border}\u2557\n`);
|
||||
for (const line of lines) {
|
||||
process.stderr.write(`\u2551 ${line.padEnd(maxLen)} \u2551\n`);
|
||||
}
|
||||
process.stderr.write(`\u255A${border}\u255D\n`);
|
||||
process.stderr.write('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle quota exhaustion for an active session.
|
||||
* Applies cooldown to exhausted account, finds healthy alternative,
|
||||
* switches default, and alerts user via stderr.
|
||||
*
|
||||
* @returns switchedTo account ID or null if no alternatives
|
||||
*/
|
||||
export async function handleQuotaExhaustion(
|
||||
provider: CLIProxyProvider,
|
||||
accountId: string,
|
||||
cooldownMinutes: number
|
||||
): Promise<{ switchedTo: string | null; reason: string }> {
|
||||
// Dynamic imports to avoid circular dependencies
|
||||
const { applyCooldown, findHealthyAccount } = await import('./quota-manager');
|
||||
const { setDefaultAccount, touchAccount } = await import('./account-manager');
|
||||
|
||||
// Apply cooldown to exhausted account
|
||||
applyCooldown(provider, accountId, cooldownMinutes);
|
||||
|
||||
// Find healthy alternative
|
||||
const alternative = await findHealthyAccount(provider, [accountId]);
|
||||
|
||||
if (alternative) {
|
||||
setDefaultAccount(provider, alternative.id);
|
||||
touchAccount(provider, alternative.id);
|
||||
writeQuotaExhausted(accountId, alternative.id, cooldownMinutes);
|
||||
return {
|
||||
switchedTo: alternative.id,
|
||||
reason: `Quota exhausted, switched to ${maskEmail(alternative.id)}`,
|
||||
};
|
||||
}
|
||||
|
||||
// No alternatives — warn but continue (graceful degradation)
|
||||
writeQuotaExhausted(accountId, null, cooldownMinutes);
|
||||
return {
|
||||
switchedTo: null,
|
||||
reason: 'Quota exhausted, no alternatives available',
|
||||
};
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import { getProviderTokenDir, isAuthenticated, registerAccountFromToken } from '
|
||||
import { executeOAuthProcess } from './oauth-process';
|
||||
import { importKiroToken } from './kiro-import';
|
||||
import { getProxyTarget, buildProxyUrl, buildManagementHeaders } from '../proxy-target-resolver';
|
||||
import { checkNewAccountConflict, warnNewAccountConflict } from '../account-safety';
|
||||
|
||||
/**
|
||||
* Prompt user to add another account
|
||||
@@ -379,7 +380,17 @@ async function handlePasteCallbackMode(
|
||||
}
|
||||
|
||||
console.log(ok('Authentication successful!'));
|
||||
return registerAccountFromToken(provider, tokenDir, nickname);
|
||||
const account = registerAccountFromToken(provider, tokenDir, nickname);
|
||||
|
||||
// Account safety: check for cross-provider conflicts
|
||||
if (account?.email) {
|
||||
const conflicts = checkNewAccountConflict(provider, account.email);
|
||||
if (conflicts) {
|
||||
warnNewAccountConflict(account.email, conflicts);
|
||||
}
|
||||
}
|
||||
|
||||
return account;
|
||||
} catch (error) {
|
||||
if (verbose) {
|
||||
console.log(fail(`Error: ${(error as Error).message}`));
|
||||
@@ -543,6 +554,14 @@ export async function triggerOAuth(
|
||||
console.log(' Or enable "Kiro: Use normal browser" in: ccs config');
|
||||
}
|
||||
|
||||
// Account safety: check for cross-provider conflicts
|
||||
if (account?.email) {
|
||||
const conflicts = checkNewAccountConflict(provider, account.email);
|
||||
if (conflicts) {
|
||||
warnNewAccountConflict(account.email, conflicts);
|
||||
}
|
||||
}
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,23 +26,24 @@ export const CCS_CONTROL_PANEL_SECRET = 'ccs';
|
||||
* v4: Added Kiro (AWS) and GitHub Copilot providers
|
||||
* v5: Added disable-cooling: true for stability
|
||||
* v6: Added oauth-model-alias with Opus 4.6 support
|
||||
* v7: Added fork:true for Claude model aliases (keep both upstream and alias names)
|
||||
*/
|
||||
export const CLIPROXY_CONFIG_VERSION = 6;
|
||||
export const CLIPROXY_CONFIG_VERSION = 7;
|
||||
|
||||
/**
|
||||
* Default Antigravity oauth-model-alias entries.
|
||||
* Maps user-facing model names to Antigravity internal model names.
|
||||
* Must stay in sync with CLIProxyAPIPlus defaultAntigravityAliases().
|
||||
*/
|
||||
const DEFAULT_ANTIGRAVITY_ALIASES: Array<{ name: string; alias: string }> = [
|
||||
const DEFAULT_ANTIGRAVITY_ALIASES: Array<{ name: string; alias: string; fork?: boolean }> = [
|
||||
{ name: 'rev19-uic3-1p', alias: 'gemini-2.5-computer-use-preview-10-2025' },
|
||||
{ name: 'gemini-3-pro-image', alias: 'gemini-3-pro-image-preview' },
|
||||
{ name: 'gemini-3-pro-high', alias: 'gemini-3-pro-preview' },
|
||||
{ name: 'gemini-3-flash', alias: 'gemini-3-flash-preview' },
|
||||
{ name: 'claude-sonnet-4-5', alias: 'gemini-claude-sonnet-4-5' },
|
||||
{ name: 'claude-sonnet-4-5-thinking', alias: 'gemini-claude-sonnet-4-5-thinking' },
|
||||
{ name: 'claude-opus-4-5-thinking', alias: 'gemini-claude-opus-4-5-thinking' },
|
||||
{ name: 'claude-opus-4-6-thinking', alias: 'gemini-claude-opus-4-6-thinking' },
|
||||
{ name: 'claude-sonnet-4-5', alias: 'gemini-claude-sonnet-4-5', fork: true },
|
||||
{ name: 'claude-sonnet-4-5-thinking', alias: 'gemini-claude-sonnet-4-5-thinking', fork: true },
|
||||
{ name: 'claude-opus-4-5-thinking', alias: 'gemini-claude-opus-4-5-thinking', fork: true },
|
||||
{ name: 'claude-opus-4-6-thinking', alias: 'gemini-claude-opus-4-6-thinking', fork: true },
|
||||
];
|
||||
|
||||
/** Provider display names (static metadata) */
|
||||
@@ -103,21 +104,44 @@ function generateOAuthModelAliasSection(existingAliases?: string): string {
|
||||
const existingNames = new Set(aliasEntries.map((a) => a.name));
|
||||
const lines = existingAliases.split('\n');
|
||||
let currentName = '';
|
||||
let currentAlias = '';
|
||||
let currentFork = false;
|
||||
for (const line of lines) {
|
||||
const nameMatch = line.match(/^\s+-\s*name:\s*(.+)/);
|
||||
const aliasMatch = line.match(/^\s+alias:\s*(.+)/);
|
||||
const forkMatch = line.match(/^\s+fork:\s*(.+)/);
|
||||
if (nameMatch) {
|
||||
// Flush previous entry if complete
|
||||
if (currentName && currentAlias && !existingNames.has(currentName)) {
|
||||
aliasEntries.push({
|
||||
name: currentName,
|
||||
alias: currentAlias,
|
||||
fork: currentFork || undefined,
|
||||
});
|
||||
existingNames.add(currentName);
|
||||
}
|
||||
currentName = nameMatch[1].trim();
|
||||
} else if (aliasMatch && currentName && !existingNames.has(currentName)) {
|
||||
aliasEntries.push({ name: currentName, alias: aliasMatch[1].trim() });
|
||||
existingNames.add(currentName);
|
||||
currentName = '';
|
||||
currentAlias = '';
|
||||
currentFork = false;
|
||||
} else if (aliasMatch) {
|
||||
currentAlias = aliasMatch[1].trim();
|
||||
} else if (forkMatch) {
|
||||
currentFork = forkMatch[1].trim().toLowerCase() === 'true';
|
||||
}
|
||||
}
|
||||
// Flush last entry
|
||||
if (currentName && currentAlias && !existingNames.has(currentName)) {
|
||||
aliasEntries.push({ name: currentName, alias: currentAlias, fork: currentFork || undefined });
|
||||
existingNames.add(currentName);
|
||||
}
|
||||
}
|
||||
|
||||
const entries = aliasEntries
|
||||
.map((a) => ` - name: ${a.name}\n alias: ${a.alias}`)
|
||||
.map((a) => {
|
||||
let entry = ` - name: ${a.name}\n alias: ${a.alias}`;
|
||||
if (a.fork) entry += '\n fork: true';
|
||||
return entry;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return `oauth-model-alias:\n antigravity:\n${entries}`;
|
||||
|
||||
@@ -63,6 +63,12 @@ import {
|
||||
handleQuotaCheck,
|
||||
} from './retry-handler';
|
||||
import { checkOrJoinProxy, registerProxySession, setupCleanupHandlers } from './session-bridge';
|
||||
import {
|
||||
warnCrossProviderDuplicates,
|
||||
cleanupStaleAutoPauses,
|
||||
enforceProviderIsolation,
|
||||
restoreAutoPausedAccounts,
|
||||
} from '../account-safety';
|
||||
import { getWebSearchHookEnv } from '../../utils/websearch-manager';
|
||||
|
||||
/** Default executor configuration */
|
||||
@@ -507,6 +513,21 @@ export async function execClaudeWithCLIProxy(
|
||||
await handleQuotaCheck(provider);
|
||||
}
|
||||
|
||||
// 3c. Account safety: enforce cross-provider isolation
|
||||
if (!skipLocalAuth) {
|
||||
cleanupStaleAutoPauses();
|
||||
const isolated = enforceProviderIsolation(provider);
|
||||
if (isolated === 0) {
|
||||
// No enforcement — still warn about duplicates for awareness
|
||||
warnCrossProviderDuplicates(provider);
|
||||
} else {
|
||||
// 'exit' handlers must be synchronous — restoreAutoPausedAccounts uses sync fs APIs
|
||||
process.on('exit', () => {
|
||||
restoreAutoPausedAccounts(provider);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 4. First-run model configuration
|
||||
if (supportsModelConfig(provider) && !skipLocalAuth) {
|
||||
await configureProviderModel(provider, false, cfg.customSettingsPath);
|
||||
@@ -773,6 +794,15 @@ export async function execClaudeWithCLIProxy(
|
||||
});
|
||||
}
|
||||
|
||||
// 12b. Start runtime quota monitor (adaptive polling during session)
|
||||
if (!skipLocalAuth) {
|
||||
const { startQuotaMonitor } = await import('../quota-manager');
|
||||
const monitorAccount = getDefaultAccount(provider);
|
||||
if (monitorAccount) {
|
||||
startQuotaMonitor(provider, monitorAccount.id);
|
||||
}
|
||||
}
|
||||
|
||||
// 13. Setup cleanup handlers
|
||||
setupCleanupHandlers(
|
||||
claude,
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
import { fail, warn, info } from '../../utils/ui';
|
||||
import { CLIProxyProvider } from '../types';
|
||||
import { handleBanDetection } from '../account-safety';
|
||||
|
||||
/**
|
||||
* Check if error is network-related
|
||||
@@ -50,6 +51,15 @@ export async function handleTokenExpiration(
|
||||
const tokenResult = await ensureTokenValid(provider, verbose);
|
||||
|
||||
if (!tokenResult.valid) {
|
||||
// Check if this is an account ban/disable before generic error
|
||||
if (tokenResult.error) {
|
||||
const { getDefaultAccount } = await import('../account-manager');
|
||||
const account = getDefaultAccount(provider);
|
||||
if (account) {
|
||||
handleBanDetection(provider, account.id, tokenResult.error);
|
||||
}
|
||||
}
|
||||
|
||||
// Token expired and refresh failed - trigger re-auth
|
||||
console.error(warn('OAuth token expired and refresh failed'));
|
||||
if (tokenResult.error) {
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { detectRunningProxy, waitForProxyHealthy, reclaimOrphanedProxy } from '../proxy-detector';
|
||||
import { withStartupLock } from '../startup-lock';
|
||||
import { killProcessOnPort } from '../../utils/platform-commands';
|
||||
import { stopQuotaMonitor } from '../quota-manager';
|
||||
|
||||
export interface ProxySessionResult {
|
||||
sessionId?: string;
|
||||
@@ -184,6 +185,7 @@ export function setupCleanupHandlers(
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
stopQuotaMonitor();
|
||||
log('Parent signal received, cleaning up');
|
||||
|
||||
if (
|
||||
@@ -214,6 +216,7 @@ export function setupCleanupHandlers(
|
||||
};
|
||||
|
||||
claude.on('exit', (code, signal) => {
|
||||
stopQuotaMonitor();
|
||||
log(`Claude exited: code=${code}, signal=${signal}`);
|
||||
|
||||
if (
|
||||
@@ -250,6 +253,7 @@ export function setupCleanupHandlers(
|
||||
});
|
||||
|
||||
claude.on('error', (error) => {
|
||||
stopQuotaMonitor();
|
||||
console.error(require('../../utils/ui').fail(`Claude CLI error: ${error}`));
|
||||
|
||||
if (
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
type AccountInfo,
|
||||
} from './account-manager';
|
||||
import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
|
||||
import type { RuntimeMonitorConfig } from '../config/unified-config-types';
|
||||
|
||||
// ============================================================================
|
||||
// QUOTA CACHE (30-second TTL)
|
||||
@@ -416,3 +417,137 @@ export async function getQuotaStatus(provider: CLIProxyProvider): Promise<{
|
||||
|
||||
return { accounts: results };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// RUNTIME QUOTA MONITOR (adaptive polling during active sessions)
|
||||
// ============================================================================
|
||||
|
||||
/** Active monitor timer (null = not running) */
|
||||
let monitorTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/** Tracks if warning was shown this session (avoid spam) */
|
||||
let hasWarnedThisSession = false;
|
||||
|
||||
/** Guards against in-flight poll callbacks running after stop */
|
||||
let monitorStopped = false;
|
||||
|
||||
/**
|
||||
* Schedule next quota poll with adaptive interval.
|
||||
* Uses setTimeout chain (not setInterval) for dynamic interval switching.
|
||||
*/
|
||||
function scheduleNextPoll(
|
||||
provider: CLIProxyProvider,
|
||||
accountId: string,
|
||||
monitorConfig: RuntimeMonitorConfig,
|
||||
intervalMs: number
|
||||
): void {
|
||||
monitorTimer = setTimeout(async () => {
|
||||
// Guard: skip if monitor was stopped while this callback was queued
|
||||
if (monitorStopped) return;
|
||||
|
||||
try {
|
||||
const quota = await fetchQuotaWithDedup(provider, accountId);
|
||||
if (monitorStopped) return; // Re-check after async fetch
|
||||
const avgQuota = calculateAverageQuota(quota) ?? 100;
|
||||
|
||||
if (avgQuota <= monitorConfig.exhaustion_threshold) {
|
||||
// EXHAUSTED: cooldown + switch default + stop monitoring.
|
||||
// NOTE: Monitor stops here intentionally. The current session continues
|
||||
// on the exhausted account (can't hot-swap mid-session). The switched
|
||||
// default only takes effect on next session start via preflightCheck().
|
||||
const { handleQuotaExhaustion } = await import('./account-safety');
|
||||
await handleQuotaExhaustion(provider, accountId, monitorConfig.cooldown_minutes);
|
||||
monitorTimer = null;
|
||||
return; // Stop polling
|
||||
}
|
||||
|
||||
if (avgQuota <= monitorConfig.warn_threshold) {
|
||||
// WARNING: switch to critical interval, warn once
|
||||
if (!hasWarnedThisSession) {
|
||||
const { writeQuotaWarning } = await import('./account-safety');
|
||||
writeQuotaWarning(accountId, avgQuota);
|
||||
hasWarnedThisSession = true;
|
||||
}
|
||||
scheduleNextPoll(
|
||||
provider,
|
||||
accountId,
|
||||
monitorConfig,
|
||||
monitorConfig.critical_interval_seconds * 1000
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// HEALTHY: keep normal interval
|
||||
scheduleNextPoll(
|
||||
provider,
|
||||
accountId,
|
||||
monitorConfig,
|
||||
monitorConfig.normal_interval_seconds * 1000
|
||||
);
|
||||
} catch {
|
||||
// API failure: silently reschedule at same interval
|
||||
scheduleNextPoll(provider, accountId, monitorConfig, intervalMs);
|
||||
}
|
||||
}, intervalMs);
|
||||
|
||||
// Prevent monitor from keeping Node.js process alive
|
||||
if (monitorTimer && typeof monitorTimer === 'object' && 'unref' in monitorTimer) {
|
||||
monitorTimer.unref();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start adaptive quota monitor for an active session.
|
||||
* Polls at normal_interval (300s) when healthy, switches to
|
||||
* critical_interval (60s) when quota hits warn_threshold (20%).
|
||||
* Auto-stops on exhaustion or when stopQuotaMonitor() is called.
|
||||
*
|
||||
* Only monitors 'agy' provider (only one with quota API).
|
||||
* No-op for other providers, manual mode, or if disabled in config.
|
||||
*/
|
||||
export function startQuotaMonitor(provider: CLIProxyProvider, accountId: string): void {
|
||||
// Only Antigravity supports quota
|
||||
if (provider !== 'agy') return;
|
||||
|
||||
// Prevent duplicate monitors
|
||||
if (monitorTimer) return;
|
||||
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
const quotaConfig = config.quota_management;
|
||||
|
||||
// Skip if config missing (shouldn't happen with defaults)
|
||||
if (!quotaConfig) return;
|
||||
|
||||
// Skip if manual mode or runtime monitor disabled
|
||||
if (quotaConfig.mode === 'manual') return;
|
||||
if (!quotaConfig.runtime_monitor?.enabled) return;
|
||||
|
||||
// Validate thresholds: warn must be > exhaustion to avoid immediate exhaustion on warning
|
||||
const monitorConfig = quotaConfig.runtime_monitor;
|
||||
if (monitorConfig.warn_threshold <= monitorConfig.exhaustion_threshold) {
|
||||
return; // Invalid config — skip monitoring silently (logged at config level)
|
||||
}
|
||||
|
||||
hasWarnedThisSession = false;
|
||||
monitorStopped = false;
|
||||
|
||||
// Start first poll at normal interval
|
||||
scheduleNextPoll(
|
||||
provider,
|
||||
accountId,
|
||||
quotaConfig.runtime_monitor,
|
||||
quotaConfig.runtime_monitor.normal_interval_seconds * 1000
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the runtime quota monitor. Safe to call multiple times.
|
||||
*/
|
||||
export function stopQuotaMonitor(): void {
|
||||
monitorStopped = true;
|
||||
if (monitorTimer) {
|
||||
clearTimeout(monitorTimer);
|
||||
monitorTimer = null;
|
||||
}
|
||||
hasWarnedThisSession = false;
|
||||
}
|
||||
@@ -463,7 +463,7 @@ async function showHelp(): Promise<void> {
|
||||
` ${color('ollama', 'command')} Ollama - Local open-source models (no API key)`
|
||||
);
|
||||
console.log(
|
||||
` ${color('ollama-cloud', 'command')} Ollama Cloud - glm-4.7:cloud, qwen3-coder:480b`
|
||||
` ${color('ollama-cloud', 'command')} Ollama Cloud - glm-5:cloud, qwen3-coder:480b`
|
||||
);
|
||||
console.log(` ${color('glm', 'command')} GLM - Claude via Z.AI`);
|
||||
console.log(` ${color('glmt', 'command')} GLMT - GLM with Thinking mode`);
|
||||
|
||||
@@ -129,7 +129,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
|
||||
[`Configure in ${dirDisplay}/*.settings.json`],
|
||||
[
|
||||
['ccs', 'Use default Claude account'],
|
||||
['ccs glm', 'GLM 4.6 (API key required)'],
|
||||
['ccs glm', 'GLM 5 (API key required)'],
|
||||
['ccs glmt', 'GLM with thinking mode'],
|
||||
['ccs kimi', 'Kimi for Coding (API key)'],
|
||||
['ccs ollama', 'Local Ollama (http://localhost:11434)'],
|
||||
@@ -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'],
|
||||
]);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
+749
-747
File diff suppressed because it is too large.
Load diff
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -113,7 +113,7 @@ export class GlmtTransformer {
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content,
|
||||
model: openaiResponse.model || 'glm-4.7',
|
||||
model: openaiResponse.model || 'glm-5',
|
||||
stop_reason: this.responseBuilder.mapStopReason(choice.finish_reason || 'stop'),
|
||||
usage: {
|
||||
input_tokens: openaiResponse.usage?.prompt_tokens || 0,
|
||||
@@ -131,7 +131,7 @@ export class GlmtTransformer {
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: '[Transformation Error] ' + err.message }],
|
||||
model: 'glm-4.7',
|
||||
model: 'glm-5',
|
||||
stop_reason: 'end_turn',
|
||||
usage: { input_tokens: 0, output_tokens: 0 },
|
||||
};
|
||||
|
||||
@@ -32,7 +32,7 @@ export class ResponseBuilder {
|
||||
type: 'message',
|
||||
role: accumulator.getRole(),
|
||||
content: [],
|
||||
model: accumulator.getModel() || 'glm-4.7',
|
||||
model: accumulator.getModel() || 'glm-5',
|
||||
stop_reason: null,
|
||||
usage: {
|
||||
input_tokens: accumulator.getInputTokens(),
|
||||
|
||||
@@ -337,7 +337,7 @@ export function getModelDisplayName(profile: string): string {
|
||||
const model = settings.env?.ANTHROPIC_MODEL;
|
||||
|
||||
if (model) {
|
||||
// Format: 'glm-4.7' -> 'GLM-4.7' (uppercase letters, preserve numbers)
|
||||
// Format: 'glm-5' -> 'GLM-5' (uppercase letters, preserve numbers)
|
||||
return model
|
||||
.split('-')
|
||||
.map((part) => part.toUpperCase())
|
||||
|
||||
@@ -410,37 +410,43 @@ const PRICING_REGISTRY: Record<string, ModelPricing> = {
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GLM Models (Zhipu AI / Z.AI) - Source: better-ccusage
|
||||
// GLM Models (Zhipu AI / Z.AI) - Source: OpenRouter verified pricing
|
||||
// ---------------------------------------------------------------------------
|
||||
'glm-4.7': {
|
||||
inputPerMillion: 0.6,
|
||||
outputPerMillion: 2.2,
|
||||
'glm-5': {
|
||||
inputPerMillion: 1.0,
|
||||
outputPerMillion: 3.2,
|
||||
cacheCreationPerMillion: 0.0,
|
||||
cacheReadPerMillion: 0.11,
|
||||
cacheReadPerMillion: 0.2,
|
||||
},
|
||||
'glm-4.7': {
|
||||
inputPerMillion: 0.4,
|
||||
outputPerMillion: 1.5,
|
||||
cacheCreationPerMillion: 0.0,
|
||||
cacheReadPerMillion: 0.2,
|
||||
},
|
||||
'glm-4.6': {
|
||||
inputPerMillion: 0.6,
|
||||
outputPerMillion: 2.2,
|
||||
inputPerMillion: 0.35,
|
||||
outputPerMillion: 1.5,
|
||||
cacheCreationPerMillion: 0.0,
|
||||
cacheReadPerMillion: 0.11,
|
||||
cacheReadPerMillion: 0.175,
|
||||
},
|
||||
'glm-4.6-cc-max': {
|
||||
inputPerMillion: 0.6,
|
||||
outputPerMillion: 2.2,
|
||||
inputPerMillion: 0.35,
|
||||
outputPerMillion: 1.5,
|
||||
cacheCreationPerMillion: 0.0,
|
||||
cacheReadPerMillion: 0.11,
|
||||
cacheReadPerMillion: 0.175,
|
||||
},
|
||||
'glm-4.5': {
|
||||
inputPerMillion: 0.6,
|
||||
outputPerMillion: 2.2,
|
||||
inputPerMillion: 0.35,
|
||||
outputPerMillion: 1.55,
|
||||
cacheCreationPerMillion: 0.0,
|
||||
cacheReadPerMillion: 0.11,
|
||||
cacheReadPerMillion: 0.175,
|
||||
},
|
||||
'glm-4.5-air': {
|
||||
inputPerMillion: 0.2,
|
||||
outputPerMillion: 1.1,
|
||||
inputPerMillion: 0.13,
|
||||
outputPerMillion: 0.85,
|
||||
cacheCreationPerMillion: 0.0,
|
||||
cacheReadPerMillion: 0.03,
|
||||
cacheReadPerMillion: 0.025,
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -35,10 +35,10 @@ module.exports = {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://api.z.ai/api/anthropic",
|
||||
ANTHROPIC_AUTH_TOKEN: "your_api_key_here",
|
||||
ANTHROPIC_MODEL: "glm-4.7",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-4.7",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "glm-4.7",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "glm-4.7"
|
||||
ANTHROPIC_MODEL: "glm-5",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-5",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "glm-5",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "glm-5"
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* Account Safety Quota Exhaustion Handler Tests
|
||||
*
|
||||
* Tests for handleQuotaExhaustion() and writeQuotaWarning():
|
||||
* - Cooldown application
|
||||
* - Account switching
|
||||
* - Fallback when no alternatives
|
||||
* - Warning output formatting
|
||||
* - Email masking
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { handleQuotaExhaustion, writeQuotaWarning, maskEmail } from '../../../src/cliproxy/account-safety';
|
||||
|
||||
// Setup test isolation
|
||||
let tmpDir: string;
|
||||
let origCcsHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-test-exhaust-'));
|
||||
origCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tmpDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (origCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = origCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// Helper: write accounts registry
|
||||
function writeRegistry(providers: Record<string, unknown>): void {
|
||||
const registryDir = path.join(tmpDir, '.ccs', 'cliproxy');
|
||||
fs.mkdirSync(registryDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(registryDir, 'accounts.json'),
|
||||
JSON.stringify({ version: 1, providers }, null, 2)
|
||||
);
|
||||
}
|
||||
|
||||
// Helper: write unified config
|
||||
function writeConfig(quotaConfig: unknown): void {
|
||||
const configDir = path.join(tmpDir, '.ccs', 'config');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(configDir, 'unified-config.json'),
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
quota_management: quotaConfig,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
describe('Quota Exhaustion Handlers', () => {
|
||||
describe('writeQuotaWarning', () => {
|
||||
it('should write to stderr with box format', async () => {
|
||||
const stderrWrites: string[] = [];
|
||||
const originalWrite = process.stderr.write;
|
||||
process.stderr.write = ((chunk: string) => {
|
||||
stderrWrites.push(chunk);
|
||||
return true;
|
||||
}) as any;
|
||||
|
||||
writeQuotaWarning('test@gmail.com', 20);
|
||||
|
||||
process.stderr.write = originalWrite;
|
||||
|
||||
// Verify output contains account
|
||||
const fullOutput = stderrWrites.join('');
|
||||
expect(fullOutput).toContain('tes');
|
||||
expect(fullOutput).toContain('20%');
|
||||
|
||||
// Verify box borders present
|
||||
expect(fullOutput).toContain('\u2554'); // Top-left corner
|
||||
expect(fullOutput).toContain('\u2557'); // Top-right corner
|
||||
expect(fullOutput).toContain('\u255A'); // Bottom-left corner
|
||||
expect(fullOutput).toContain('\u255D'); // Bottom-right corner
|
||||
expect(fullOutput).toContain('\u2551'); // Vertical bar
|
||||
});
|
||||
|
||||
it('should mask email showing only first 3 chars', async () => {
|
||||
const stderrWrites: string[] = [];
|
||||
const originalWrite = process.stderr.write;
|
||||
process.stderr.write = ((chunk: string) => {
|
||||
stderrWrites.push(chunk);
|
||||
return true;
|
||||
}) as any;
|
||||
|
||||
writeQuotaWarning('verylongemail@example.com', 15);
|
||||
|
||||
process.stderr.write = originalWrite;
|
||||
|
||||
const fullOutput = stderrWrites.join('');
|
||||
// Should show "ver***@example.com"
|
||||
expect(fullOutput).toContain('ver***@example.com');
|
||||
expect(fullOutput).not.toContain('verylongemail@example.com');
|
||||
});
|
||||
|
||||
it('should include threshold percentage', async () => {
|
||||
const stderrWrites: string[] = [];
|
||||
const originalWrite = process.stderr.write;
|
||||
process.stderr.write = ((chunk: string) => {
|
||||
stderrWrites.push(chunk);
|
||||
return true;
|
||||
}) as any;
|
||||
|
||||
writeQuotaWarning('test@gmail.com', 5);
|
||||
|
||||
process.stderr.write = originalWrite;
|
||||
|
||||
const fullOutput = stderrWrites.join('');
|
||||
expect(fullOutput).toContain('5%');
|
||||
});
|
||||
});
|
||||
|
||||
describe('maskEmail', () => {
|
||||
it('should mask standard email', () => {
|
||||
const result = maskEmail('user@example.com');
|
||||
expect(result).toBe('use***@example.com');
|
||||
});
|
||||
|
||||
it('should handle short local part', () => {
|
||||
const result = maskEmail('ab@example.com');
|
||||
expect(result).toBe('ab***@example.com');
|
||||
});
|
||||
|
||||
it('should handle single char local part', () => {
|
||||
const result = maskEmail('a@example.com');
|
||||
expect(result).toBe('a***@example.com');
|
||||
});
|
||||
|
||||
it('should return input if no @ sign', () => {
|
||||
const result = maskEmail('not-an-email');
|
||||
expect(result).toBe('not-an-email');
|
||||
});
|
||||
|
||||
it('should return input if empty string', () => {
|
||||
const result = maskEmail('');
|
||||
expect(result).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleQuotaExhaustion', () => {
|
||||
it('should apply cooldown to exhausted account', async () => {
|
||||
writeRegistry({
|
||||
agy: {
|
||||
default: 'exhausted@gmail.com',
|
||||
accounts: {
|
||||
'exhausted@gmail.com': {
|
||||
email: 'exhausted@gmail.com',
|
||||
tokenFile: 'agy-exhausted.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
writeConfig({
|
||||
mode: 'auto',
|
||||
auto: {
|
||||
tier_priority: ['ultra', 'pro'],
|
||||
exhaustion_threshold: 5,
|
||||
cooldown_minutes: 10,
|
||||
preflight_check: true,
|
||||
},
|
||||
runtime_monitor: {
|
||||
enabled: true,
|
||||
normal_interval_seconds: 300,
|
||||
critical_interval_seconds: 60,
|
||||
warn_threshold: 20,
|
||||
exhaustion_threshold: 5,
|
||||
cooldown_minutes: 10,
|
||||
},
|
||||
});
|
||||
|
||||
const { isOnCooldown } = await import('../../../src/cliproxy/quota-manager');
|
||||
|
||||
const result = await handleQuotaExhaustion('agy', 'exhausted@gmail.com', 10);
|
||||
|
||||
// Verify cooldown was applied (account now on cooldown)
|
||||
expect(isOnCooldown('agy', 'exhausted@gmail.com')).toBe(true);
|
||||
// Should return a result with reason
|
||||
expect(result.reason).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle no alternatives gracefully', async () => {
|
||||
writeRegistry({
|
||||
agy: {
|
||||
default: 'only@gmail.com',
|
||||
accounts: {
|
||||
'only@gmail.com': {
|
||||
email: 'only@gmail.com',
|
||||
tokenFile: 'agy-only.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
writeConfig({
|
||||
mode: 'auto',
|
||||
auto: {
|
||||
tier_priority: ['ultra', 'pro'],
|
||||
exhaustion_threshold: 5,
|
||||
cooldown_minutes: 10,
|
||||
preflight_check: true,
|
||||
},
|
||||
runtime_monitor: {
|
||||
enabled: true,
|
||||
normal_interval_seconds: 300,
|
||||
critical_interval_seconds: 60,
|
||||
warn_threshold: 20,
|
||||
exhaustion_threshold: 5,
|
||||
cooldown_minutes: 10,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await handleQuotaExhaustion('agy', 'only@gmail.com', 10);
|
||||
|
||||
// Should return gracefully with null switched
|
||||
expect(result.switchedTo).toBeNull();
|
||||
expect(result.reason).toContain('no alternatives');
|
||||
});
|
||||
|
||||
it('should write warning to stderr', async () => {
|
||||
writeRegistry({
|
||||
agy: {
|
||||
default: 'exhausted@gmail.com',
|
||||
accounts: {
|
||||
'exhausted@gmail.com': {
|
||||
email: 'exhausted@gmail.com',
|
||||
tokenFile: 'agy-exhausted.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
writeConfig({
|
||||
mode: 'auto',
|
||||
auto: {
|
||||
tier_priority: ['ultra', 'pro'],
|
||||
exhaustion_threshold: 5,
|
||||
cooldown_minutes: 10,
|
||||
preflight_check: true,
|
||||
},
|
||||
runtime_monitor: {
|
||||
enabled: true,
|
||||
normal_interval_seconds: 300,
|
||||
critical_interval_seconds: 60,
|
||||
warn_threshold: 20,
|
||||
exhaustion_threshold: 5,
|
||||
cooldown_minutes: 10,
|
||||
},
|
||||
});
|
||||
|
||||
const stderrWrites: string[] = [];
|
||||
const originalWrite = process.stderr.write;
|
||||
process.stderr.write = ((chunk: string) => {
|
||||
stderrWrites.push(chunk);
|
||||
return true;
|
||||
}) as any;
|
||||
|
||||
await handleQuotaExhaustion('agy', 'exhausted@gmail.com', 10);
|
||||
|
||||
process.stderr.write = originalWrite;
|
||||
|
||||
const fullOutput = stderrWrites.join('');
|
||||
// Should contain exhaustion indicator
|
||||
expect(fullOutput).toContain('[X]');
|
||||
});
|
||||
|
||||
it('should complete without throwing', async () => {
|
||||
writeRegistry({
|
||||
agy: {
|
||||
default: 'test@gmail.com',
|
||||
accounts: {
|
||||
'test@gmail.com': {
|
||||
email: 'test@gmail.com',
|
||||
tokenFile: 'agy-test.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
writeConfig({
|
||||
mode: 'auto',
|
||||
auto: {
|
||||
tier_priority: ['ultra', 'pro'],
|
||||
exhaustion_threshold: 5,
|
||||
cooldown_minutes: 5,
|
||||
preflight_check: true,
|
||||
},
|
||||
runtime_monitor: {
|
||||
enabled: true,
|
||||
normal_interval_seconds: 300,
|
||||
critical_interval_seconds: 60,
|
||||
warn_threshold: 20,
|
||||
exhaustion_threshold: 5,
|
||||
cooldown_minutes: 5,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await handleQuotaExhaustion('agy', 'test@gmail.com', 5);
|
||||
expect(result).toBeDefined();
|
||||
expect(result.switchedTo).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,621 @@
|
||||
/**
|
||||
* Account Safety Guards Unit Tests
|
||||
*
|
||||
* Tests ban detection, email masking, cross-provider duplicate detection,
|
||||
* enforcement lifecycle, and crash recovery.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
isBanResponse,
|
||||
maskEmail,
|
||||
detectCrossProviderDuplicates,
|
||||
enforceProviderIsolation,
|
||||
cleanupStaleAutoPauses,
|
||||
restoreAutoPausedAccounts,
|
||||
checkNewAccountConflict,
|
||||
handleBanDetection,
|
||||
warnCrossProviderDuplicates,
|
||||
} from '../../../src/cliproxy/account-safety';
|
||||
|
||||
// --- Test isolation: use temp CCS_HOME ---
|
||||
|
||||
let tmpDir: string;
|
||||
let origCcsHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-test-safety-'));
|
||||
origCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tmpDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (origCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = origCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// CCS_HOME appends .ccs — all paths go through getCcsDir() = CCS_HOME/.ccs
|
||||
function ccsDir(): string {
|
||||
return path.join(tmpDir, '.ccs');
|
||||
}
|
||||
|
||||
// --- Helper: write accounts registry ---
|
||||
|
||||
function writeRegistry(providers: Record<string, unknown>): void {
|
||||
const registryDir = path.join(ccsDir(), 'cliproxy');
|
||||
fs.mkdirSync(registryDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(registryDir, 'accounts.json'),
|
||||
JSON.stringify({ version: 1, providers }, null, 2)
|
||||
);
|
||||
}
|
||||
|
||||
// --- Helper: write auto-paused file ---
|
||||
|
||||
function writeAutoPaused(sessions: unknown[]): void {
|
||||
const dir = path.join(ccsDir(), 'cliproxy');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'auto-paused.json'), JSON.stringify({ sessions }, null, 2));
|
||||
}
|
||||
|
||||
function readAutoPaused(): { sessions: unknown[] } {
|
||||
const filePath = path.join(ccsDir(), 'cliproxy', 'auto-paused.json');
|
||||
if (!fs.existsSync(filePath)) return { sessions: [] };
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
}
|
||||
|
||||
// --- Helper: write dummy token files ---
|
||||
|
||||
function writeTokenFile(filename: string, paused = false): void {
|
||||
const dir = paused
|
||||
? path.join(ccsDir(), 'cliproxy', 'auth-paused')
|
||||
: path.join(ccsDir(), 'cliproxy', 'auth');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, filename), JSON.stringify({ type: 'test' }));
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// isBanResponse
|
||||
// ========================================
|
||||
|
||||
describe('isBanResponse', () => {
|
||||
it('should detect "disabled in this account"', () => {
|
||||
expect(isBanResponse('API access disabled in this account')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect "violation of terms of service"', () => {
|
||||
expect(isBanResponse('Your account was flagged for violation of terms of service')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect "account has been suspended"', () => {
|
||||
expect(isBanResponse('This account has been suspended by Google')).toBe(true);
|
||||
});
|
||||
|
||||
it('should be case-insensitive', () => {
|
||||
expect(isBanResponse('ACCOUNT HAS BEEN DISABLED')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for normal errors', () => {
|
||||
expect(isBanResponse('Rate limit exceeded')).toBe(false);
|
||||
expect(isBanResponse('Internal server error')).toBe(false);
|
||||
expect(isBanResponse('Network timeout')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for empty string', () => {
|
||||
expect(isBanResponse('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// maskEmail
|
||||
// ========================================
|
||||
|
||||
describe('maskEmail', () => {
|
||||
it('should mask standard email', () => {
|
||||
expect(maskEmail('user@example.com')).toBe('use***@example.com');
|
||||
});
|
||||
|
||||
it('should handle short local part', () => {
|
||||
expect(maskEmail('ab@example.com')).toBe('ab***@example.com');
|
||||
});
|
||||
|
||||
it('should handle single char local part', () => {
|
||||
expect(maskEmail('a@example.com')).toBe('a***@example.com');
|
||||
});
|
||||
|
||||
it('should return input if no @ sign', () => {
|
||||
expect(maskEmail('not-an-email')).toBe('not-an-email');
|
||||
});
|
||||
|
||||
it('should return input if empty string', () => {
|
||||
expect(maskEmail('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// detectCrossProviderDuplicates
|
||||
// ========================================
|
||||
|
||||
describe('detectCrossProviderDuplicates', () => {
|
||||
it('should return empty map when no duplicates', () => {
|
||||
writeRegistry({
|
||||
gemini: {
|
||||
default: 'user1@gmail.com',
|
||||
accounts: {
|
||||
'user1@gmail.com': {
|
||||
email: 'user1@gmail.com',
|
||||
tokenFile: 'gemini-user1.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
agy: {
|
||||
default: 'user2@gmail.com',
|
||||
accounts: {
|
||||
'user2@gmail.com': {
|
||||
email: 'user2@gmail.com',
|
||||
tokenFile: 'agy-user2.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const dupes = detectCrossProviderDuplicates();
|
||||
expect(dupes.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should detect same email across providers', () => {
|
||||
writeRegistry({
|
||||
gemini: {
|
||||
default: 'shared@gmail.com',
|
||||
accounts: {
|
||||
'shared@gmail.com': {
|
||||
email: 'shared@gmail.com',
|
||||
tokenFile: 'gemini-shared.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
agy: {
|
||||
default: 'shared@gmail.com',
|
||||
accounts: {
|
||||
'shared@gmail.com': {
|
||||
email: 'shared@gmail.com',
|
||||
tokenFile: 'agy-shared.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const dupes = detectCrossProviderDuplicates();
|
||||
expect(dupes.size).toBe(1);
|
||||
expect(dupes.get('shared@gmail.com')).toEqual(['gemini', 'agy']);
|
||||
});
|
||||
|
||||
it('should skip paused accounts', () => {
|
||||
writeRegistry({
|
||||
gemini: {
|
||||
default: 'shared@gmail.com',
|
||||
accounts: {
|
||||
'shared@gmail.com': {
|
||||
email: 'shared@gmail.com',
|
||||
tokenFile: 'gemini-shared.json',
|
||||
paused: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
agy: {
|
||||
default: 'shared@gmail.com',
|
||||
accounts: {
|
||||
'shared@gmail.com': {
|
||||
email: 'shared@gmail.com',
|
||||
tokenFile: 'agy-shared.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const dupes = detectCrossProviderDuplicates();
|
||||
expect(dupes.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should be case-insensitive on email', () => {
|
||||
writeRegistry({
|
||||
gemini: {
|
||||
default: 'User@Gmail.com',
|
||||
accounts: {
|
||||
'User@Gmail.com': {
|
||||
email: 'User@Gmail.com',
|
||||
tokenFile: 'gemini-user.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
agy: {
|
||||
default: 'user@gmail.com',
|
||||
accounts: {
|
||||
'user@gmail.com': {
|
||||
email: 'user@gmail.com',
|
||||
tokenFile: 'agy-user.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const dupes = detectCrossProviderDuplicates();
|
||||
expect(dupes.size).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// checkNewAccountConflict
|
||||
// ========================================
|
||||
|
||||
describe('checkNewAccountConflict', () => {
|
||||
it('should return null for non-Google provider', () => {
|
||||
const result = checkNewAccountConflict('kiro' as never, 'user@gmail.com');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when no conflict', () => {
|
||||
writeRegistry({
|
||||
gemini: {
|
||||
default: 'other@gmail.com',
|
||||
accounts: {
|
||||
'other@gmail.com': {
|
||||
email: 'other@gmail.com',
|
||||
tokenFile: 'gemini-other.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = checkNewAccountConflict('agy', 'new@gmail.com');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return conflicting providers', () => {
|
||||
writeRegistry({
|
||||
gemini: {
|
||||
default: 'shared@gmail.com',
|
||||
accounts: {
|
||||
'shared@gmail.com': {
|
||||
email: 'shared@gmail.com',
|
||||
tokenFile: 'gemini-shared.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = checkNewAccountConflict('agy', 'shared@gmail.com');
|
||||
expect(result).toEqual(['gemini']);
|
||||
});
|
||||
|
||||
it('should return null when email is undefined', () => {
|
||||
const result = checkNewAccountConflict('agy', undefined);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// cleanupStaleAutoPauses
|
||||
// ========================================
|
||||
|
||||
describe('cleanupStaleAutoPauses', () => {
|
||||
it('should do nothing when no sessions', () => {
|
||||
// No auto-paused.json exists
|
||||
cleanupStaleAutoPauses();
|
||||
// Should not throw
|
||||
});
|
||||
|
||||
it('should remove sessions with dead PIDs', () => {
|
||||
// Use PID 999999999 which is almost certainly dead
|
||||
writeAutoPaused([
|
||||
{
|
||||
initiator: 'gemini',
|
||||
pid: 999999999,
|
||||
pausedAt: new Date().toISOString(),
|
||||
accounts: [{ provider: 'agy', accountId: 'test@gmail.com' }],
|
||||
},
|
||||
]);
|
||||
|
||||
// Write registry with the paused account so resumeAccount can find it
|
||||
writeRegistry({
|
||||
agy: {
|
||||
default: 'test@gmail.com',
|
||||
accounts: {
|
||||
'test@gmail.com': {
|
||||
email: 'test@gmail.com',
|
||||
tokenFile: 'agy-test.json',
|
||||
paused: true,
|
||||
pausedAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
writeTokenFile('agy-test.json', true);
|
||||
|
||||
cleanupStaleAutoPauses();
|
||||
|
||||
const data = readAutoPaused();
|
||||
expect(data.sessions.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should keep sessions with alive PIDs', () => {
|
||||
const alivePid = process.pid; // Current process is alive
|
||||
|
||||
writeAutoPaused([
|
||||
{
|
||||
initiator: 'gemini',
|
||||
pid: alivePid,
|
||||
pausedAt: new Date().toISOString(),
|
||||
accounts: [{ provider: 'agy', accountId: 'test@gmail.com' }],
|
||||
},
|
||||
]);
|
||||
|
||||
cleanupStaleAutoPauses();
|
||||
|
||||
const data = readAutoPaused();
|
||||
expect(data.sessions.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// enforceProviderIsolation
|
||||
// ========================================
|
||||
|
||||
describe('enforceProviderIsolation', () => {
|
||||
it('should return 0 for non-Google provider', () => {
|
||||
const result = enforceProviderIsolation('kiro' as never);
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it('should return 0 when no conflicting accounts', () => {
|
||||
writeRegistry({
|
||||
gemini: {
|
||||
default: 'user1@gmail.com',
|
||||
accounts: {
|
||||
'user1@gmail.com': {
|
||||
email: 'user1@gmail.com',
|
||||
tokenFile: 'gemini-user1.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
agy: {
|
||||
default: 'user2@gmail.com',
|
||||
accounts: {
|
||||
'user2@gmail.com': {
|
||||
email: 'user2@gmail.com',
|
||||
tokenFile: 'agy-user2.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
writeTokenFile('gemini-user1.json');
|
||||
writeTokenFile('agy-user2.json');
|
||||
|
||||
const result = enforceProviderIsolation('gemini');
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it('should pause conflicting accounts and record session', () => {
|
||||
writeRegistry({
|
||||
gemini: {
|
||||
default: 'shared@gmail.com',
|
||||
accounts: {
|
||||
'shared@gmail.com': {
|
||||
email: 'shared@gmail.com',
|
||||
tokenFile: 'gemini-shared.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
agy: {
|
||||
default: 'shared@gmail.com',
|
||||
accounts: {
|
||||
'shared@gmail.com': {
|
||||
email: 'shared@gmail.com',
|
||||
tokenFile: 'agy-shared.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
writeTokenFile('gemini-shared.json');
|
||||
writeTokenFile('agy-shared.json');
|
||||
|
||||
const result = enforceProviderIsolation('gemini');
|
||||
expect(result).toBe(1);
|
||||
|
||||
// Verify auto-paused.json was written
|
||||
const data = readAutoPaused();
|
||||
expect(data.sessions.length).toBe(1);
|
||||
expect(data.sessions[0].initiator).toBe('gemini');
|
||||
expect(data.sessions[0].pid).toBe(process.pid);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// restoreAutoPausedAccounts
|
||||
// ========================================
|
||||
|
||||
describe('restoreAutoPausedAccounts', () => {
|
||||
it('should do nothing when no session exists', () => {
|
||||
restoreAutoPausedAccounts('gemini');
|
||||
// Should not throw
|
||||
});
|
||||
|
||||
it('should skip accounts re-paused after enforcement', () => {
|
||||
const enforcementTime = '2024-01-01T00:00:00.000Z';
|
||||
const laterTime = '2024-01-01T01:00:00.000Z';
|
||||
|
||||
writeAutoPaused([
|
||||
{
|
||||
initiator: 'gemini',
|
||||
pid: process.pid,
|
||||
pausedAt: enforcementTime,
|
||||
accounts: [{ provider: 'agy', accountId: 'banned@gmail.com' }],
|
||||
},
|
||||
]);
|
||||
|
||||
writeRegistry({
|
||||
agy: {
|
||||
default: 'banned@gmail.com',
|
||||
accounts: {
|
||||
'banned@gmail.com': {
|
||||
email: 'banned@gmail.com',
|
||||
tokenFile: 'agy-banned.json',
|
||||
paused: true,
|
||||
pausedAt: laterTime, // Re-paused AFTER enforcement (e.g., ban)
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
writeTokenFile('agy-banned.json', true);
|
||||
|
||||
restoreAutoPausedAccounts('gemini');
|
||||
|
||||
// Account should NOT be restored because it was re-paused later
|
||||
const registry = JSON.parse(
|
||||
fs.readFileSync(path.join(ccsDir(), 'cliproxy', 'accounts.json'), 'utf-8')
|
||||
);
|
||||
expect(registry.providers.agy.accounts['banned@gmail.com'].paused).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// handleBanDetection
|
||||
// ========================================
|
||||
|
||||
describe('handleBanDetection', () => {
|
||||
it('should pause account when ban error detected', () => {
|
||||
writeRegistry({
|
||||
gemini: {
|
||||
default: 'user@gmail.com',
|
||||
accounts: {
|
||||
'user@gmail.com': {
|
||||
email: 'user@gmail.com',
|
||||
tokenFile: 'gemini-user.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
writeTokenFile('gemini-user.json');
|
||||
|
||||
const result = handleBanDetection(
|
||||
'gemini',
|
||||
'user@gmail.com',
|
||||
'API access disabled in this account'
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
// Verify account was paused in registry
|
||||
const registry = JSON.parse(
|
||||
fs.readFileSync(path.join(ccsDir(), 'cliproxy', 'accounts.json'), 'utf-8')
|
||||
);
|
||||
expect(registry.providers.gemini.accounts['user@gmail.com'].paused).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-ban errors', () => {
|
||||
writeRegistry({
|
||||
gemini: {
|
||||
default: 'user@gmail.com',
|
||||
accounts: {
|
||||
'user@gmail.com': {
|
||||
email: 'user@gmail.com',
|
||||
tokenFile: 'gemini-user.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
writeTokenFile('gemini-user.json');
|
||||
|
||||
const result = handleBanDetection('gemini', 'user@gmail.com', 'Rate limit exceeded');
|
||||
|
||||
expect(result).toBe(false);
|
||||
|
||||
// Verify account was NOT paused
|
||||
const registry = JSON.parse(
|
||||
fs.readFileSync(path.join(ccsDir(), 'cliproxy', 'accounts.json'), 'utf-8')
|
||||
);
|
||||
expect(registry.providers.gemini.accounts['user@gmail.com'].paused).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// warnCrossProviderDuplicates
|
||||
// ========================================
|
||||
|
||||
describe('warnCrossProviderDuplicates', () => {
|
||||
it('should return true when duplicates exist', () => {
|
||||
writeRegistry({
|
||||
gemini: {
|
||||
default: 'shared@gmail.com',
|
||||
accounts: {
|
||||
'shared@gmail.com': {
|
||||
email: 'shared@gmail.com',
|
||||
tokenFile: 'gemini-shared.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
agy: {
|
||||
default: 'shared@gmail.com',
|
||||
accounts: {
|
||||
'shared@gmail.com': {
|
||||
email: 'shared@gmail.com',
|
||||
tokenFile: 'agy-shared.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = warnCrossProviderDuplicates('gemini');
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when no duplicates', () => {
|
||||
writeRegistry({
|
||||
gemini: {
|
||||
default: 'user1@gmail.com',
|
||||
accounts: {
|
||||
'user1@gmail.com': {
|
||||
email: 'user1@gmail.com',
|
||||
tokenFile: 'gemini-user1.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
agy: {
|
||||
default: 'user2@gmail.com',
|
||||
accounts: {
|
||||
'user2@gmail.com': {
|
||||
email: 'user2@gmail.com',
|
||||
tokenFile: 'agy-user2.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = warnCrossProviderDuplicates('gemini');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for non-Google providers', () => {
|
||||
writeRegistry({
|
||||
kiro: {
|
||||
default: 'user@example.com',
|
||||
accounts: {
|
||||
'user@example.com': {
|
||||
email: 'user@example.com',
|
||||
tokenFile: 'kiro-user.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = warnCrossProviderDuplicates('kiro' as never);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -552,4 +552,108 @@ auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth"
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('oauth-model-alias fork:true', () => {
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
let testDir;
|
||||
let originalCcsHome;
|
||||
let regenerateConfig;
|
||||
|
||||
beforeEach(() => {
|
||||
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-test-fork-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = testDir;
|
||||
|
||||
delete require.cache[require.resolve('../../../dist/cliproxy/config-generator')];
|
||||
delete require.cache[require.resolve('../../../dist/utils/config-manager')];
|
||||
const configGenerator = require('../../../dist/cliproxy/config-generator');
|
||||
regenerateConfig = configGenerator.regenerateConfig;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
if (testDir && fs.existsSync(testDir)) {
|
||||
fs.rmSync(testDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('generates fork:true for Claude model aliases', () => {
|
||||
regenerateConfig();
|
||||
|
||||
const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy');
|
||||
const config = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8');
|
||||
|
||||
// Claude aliases should have fork: true
|
||||
assert(config.includes('claude-sonnet-4-5'), 'Should include Claude sonnet model');
|
||||
assert(config.includes('fork: true'), 'Should include fork: true for Claude aliases');
|
||||
|
||||
// Verify fork: true appears after each Claude alias entry
|
||||
const lines = config.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].includes('alias: gemini-claude-')) {
|
||||
assert(
|
||||
lines[i + 1] && lines[i + 1].trim() === 'fork: true',
|
||||
`fork: true should follow Claude alias at line ${i}: ${lines[i]}`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('does not generate fork:true for non-Claude aliases', () => {
|
||||
regenerateConfig();
|
||||
|
||||
const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy');
|
||||
const config = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8');
|
||||
|
||||
// Gemini aliases should NOT have fork: true
|
||||
const lines = config.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].includes('alias: gemini-3-') || lines[i].includes('alias: gemini-2.5-')) {
|
||||
const nextLine = lines[i + 1] || '';
|
||||
assert(
|
||||
!nextLine.trim().startsWith('fork:'),
|
||||
`Gemini alias should not have fork: ${lines[i]}`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves user-added aliases with fork during regeneration', () => {
|
||||
const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy');
|
||||
fs.mkdirSync(cliproxyDir, { recursive: true });
|
||||
|
||||
const initialConfig = `# CLIProxyAPI config generated by CCS v6
|
||||
port: 8317
|
||||
api-keys:
|
||||
- "ccs-internal-managed"
|
||||
auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth"
|
||||
oauth-model-alias:
|
||||
antigravity:
|
||||
- name: custom-model
|
||||
alias: my-custom-alias
|
||||
fork: true
|
||||
`;
|
||||
fs.writeFileSync(path.join(cliproxyDir, 'config.yaml'), initialConfig);
|
||||
|
||||
regenerateConfig();
|
||||
|
||||
const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8');
|
||||
assert(newConfig.includes('custom-model'), 'Should preserve custom alias name');
|
||||
assert(newConfig.includes('my-custom-alias'), 'Should preserve custom alias');
|
||||
|
||||
// Check fork is preserved for user alias
|
||||
const lines = newConfig.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].includes('alias: my-custom-alias')) {
|
||||
assert(
|
||||
lines[i + 1] && lines[i + 1].trim() === 'fork: true',
|
||||
'Should preserve fork: true for user-added alias'
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Runtime Quota Monitor Unit Tests
|
||||
*
|
||||
* Tests the quota monitor lifecycle:
|
||||
* - startQuotaMonitor / stopQuotaMonitor behavior
|
||||
* - No-op conditions for non-agy, manual mode, disabled config
|
||||
* - Idempotent stopQuotaMonitor
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { startQuotaMonitor, stopQuotaMonitor, clearQuotaCache } from '../../../src/cliproxy/quota-manager';
|
||||
|
||||
// Setup test isolation
|
||||
let tmpDir: string;
|
||||
let origCcsHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-test-monitor-'));
|
||||
origCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tmpDir;
|
||||
clearQuotaCache(); // Clean cache between tests
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stopQuotaMonitor(); // Clean up any active timers
|
||||
clearQuotaCache();
|
||||
if (origCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = origCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('Runtime Quota Monitor', () => {
|
||||
describe('startQuotaMonitor', () => {
|
||||
it('should accept non-agy provider without throwing', () => {
|
||||
// Non-agy providers should be silently ignored
|
||||
expect(() => {
|
||||
startQuotaMonitor('gemini', 'test@gmail.com');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should accept agy provider without throwing', () => {
|
||||
// Setup config
|
||||
const configDir = path.join(tmpDir, '.ccs', 'config');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(configDir, 'unified-config.json'),
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
quota_management: {
|
||||
mode: 'auto',
|
||||
runtime_monitor: {
|
||||
enabled: false, // Disabled to avoid actual polling
|
||||
normal_interval_seconds: 300,
|
||||
critical_interval_seconds: 60,
|
||||
warn_threshold: 20,
|
||||
exhaustion_threshold: 5,
|
||||
cooldown_minutes: 5,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(() => {
|
||||
startQuotaMonitor('agy', 'test@gmail.com');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should be no-op when config missing or no quota_management', () => {
|
||||
// No config file — should not throw
|
||||
expect(() => {
|
||||
startQuotaMonitor('agy', 'test@gmail.com');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle manual mode gracefully', () => {
|
||||
const configDir = path.join(tmpDir, '.ccs', 'config');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(configDir, 'unified-config.json'),
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
quota_management: {
|
||||
mode: 'manual',
|
||||
runtime_monitor: {
|
||||
enabled: true,
|
||||
normal_interval_seconds: 300,
|
||||
critical_interval_seconds: 60,
|
||||
warn_threshold: 20,
|
||||
exhaustion_threshold: 5,
|
||||
cooldown_minutes: 5,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(() => {
|
||||
startQuotaMonitor('agy', 'test@gmail.com');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle disabled monitor gracefully', () => {
|
||||
const configDir = path.join(tmpDir, '.ccs', 'config');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(configDir, 'unified-config.json'),
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
quota_management: {
|
||||
mode: 'auto',
|
||||
runtime_monitor: {
|
||||
enabled: false,
|
||||
normal_interval_seconds: 300,
|
||||
critical_interval_seconds: 60,
|
||||
warn_threshold: 20,
|
||||
exhaustion_threshold: 5,
|
||||
cooldown_minutes: 5,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(() => {
|
||||
startQuotaMonitor('agy', 'test@gmail.com');
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('stopQuotaMonitor', () => {
|
||||
it('should be idempotent', () => {
|
||||
expect(() => {
|
||||
stopQuotaMonitor();
|
||||
stopQuotaMonitor();
|
||||
stopQuotaMonitor();
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should complete safely when called without prior start', () => {
|
||||
// No prior startQuotaMonitor call
|
||||
expect(() => {
|
||||
stopQuotaMonitor();
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle multiple start/stop cycles', () => {
|
||||
const configDir = path.join(tmpDir, '.ccs', 'config');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(configDir, 'unified-config.json'),
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
quota_management: {
|
||||
mode: 'auto',
|
||||
runtime_monitor: {
|
||||
enabled: false,
|
||||
normal_interval_seconds: 300,
|
||||
critical_interval_seconds: 60,
|
||||
warn_threshold: 20,
|
||||
exhaustion_threshold: 5,
|
||||
cooldown_minutes: 5,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(() => {
|
||||
startQuotaMonitor('agy', 'test@gmail.com');
|
||||
stopQuotaMonitor();
|
||||
startQuotaMonitor('agy', 'test@gmail.com');
|
||||
stopQuotaMonitor();
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,448 @@
|
||||
/**
|
||||
* Unit tests for Cursor authentication module
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import type { CursorCredentials } from '../../../src/cursor/types';
|
||||
import {
|
||||
validateToken,
|
||||
extractUserInfo,
|
||||
saveCredentials,
|
||||
loadCredentials,
|
||||
checkAuthStatus,
|
||||
deleteCredentials,
|
||||
autoDetectTokens,
|
||||
} from '../../../src/cursor/cursor-auth';
|
||||
|
||||
// Test isolation
|
||||
let originalCcsHome: string | undefined;
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
// Save original CCS_HOME
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
|
||||
// Create temp directory for test isolation
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cursor-test-'));
|
||||
process.env.CCS_HOME = tempDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original CCS_HOME
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
|
||||
// Clean up temp directory
|
||||
if (fs.existsSync(tempDir)) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('validateToken', () => {
|
||||
it('should accept valid token and machineId', () => {
|
||||
const token = 'a'.repeat(50); // 50 chars minimum
|
||||
const machineId = 'a'.repeat(32); // 32 hex chars
|
||||
expect(validateToken(token, machineId)).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject 49-char token (too short)', () => {
|
||||
const token = 'a'.repeat(49); // Just under minimum
|
||||
const machineId = 'a'.repeat(32);
|
||||
expect(validateToken(token, machineId)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject 31-char hex UUID (too short)', () => {
|
||||
const token = 'a'.repeat(50);
|
||||
const machineId = 'a'.repeat(31); // Just under 32
|
||||
expect(validateToken(token, machineId)).toBe(false);
|
||||
});
|
||||
|
||||
it('should accept UUID with hyphens (strips them)', () => {
|
||||
const token = 'a'.repeat(50);
|
||||
const machineId = '12345678-1234-1234-1234-123456789abc'; // 36 chars with hyphens
|
||||
expect(validateToken(token, machineId)).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject empty token', () => {
|
||||
const machineId = 'a'.repeat(32);
|
||||
expect(validateToken('', machineId)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject empty machineId', () => {
|
||||
const token = 'a'.repeat(50);
|
||||
expect(validateToken(token, '')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject non-hex characters in machineId', () => {
|
||||
const token = 'a'.repeat(50);
|
||||
const machineId = 'g'.repeat(32); // 'g' is not valid hex
|
||||
expect(validateToken(token, machineId)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractUserInfo', () => {
|
||||
it('should extract email and sub from valid JWT', () => {
|
||||
// JWT: {"email":"user@example.com","sub":"12345","exp":1234567890}
|
||||
const payload = Buffer.from(
|
||||
JSON.stringify({ email: 'user@example.com', sub: '12345', exp: 1234567890 })
|
||||
).toString('base64');
|
||||
const token = `header.${payload}.signature`;
|
||||
|
||||
const result = extractUserInfo(token);
|
||||
expect(result).toEqual({
|
||||
email: 'user@example.com',
|
||||
userId: '12345',
|
||||
exp: 1234567890,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined email when only sub claim exists', () => {
|
||||
// JWT: {"sub":"uuid-12345","exp":1234567890}
|
||||
const payload = Buffer.from(JSON.stringify({ sub: 'uuid-12345', exp: 1234567890 })).toString(
|
||||
'base64'
|
||||
);
|
||||
const token = `header.${payload}.signature`;
|
||||
|
||||
const result = extractUserInfo(token);
|
||||
expect(result).toEqual({
|
||||
email: undefined,
|
||||
userId: 'uuid-12345',
|
||||
exp: 1234567890,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null for non-JWT token', () => {
|
||||
const token = 'a'.repeat(50); // Plain token
|
||||
const result = extractUserInfo(token);
|
||||
expect(result).toBe(null);
|
||||
});
|
||||
|
||||
it('should return null for malformed base64', () => {
|
||||
const token = 'header.!!!invalid-base64!!!.signature';
|
||||
const result = extractUserInfo(token);
|
||||
expect(result).toBe(null);
|
||||
});
|
||||
|
||||
it('should handle JWT with user_id instead of sub', () => {
|
||||
// JWT: {"email":"user@example.com","user_id":"67890"}
|
||||
const payload = Buffer.from(
|
||||
JSON.stringify({ email: 'user@example.com', user_id: '67890' })
|
||||
).toString('base64');
|
||||
const token = `header.${payload}.signature`;
|
||||
|
||||
const result = extractUserInfo(token);
|
||||
expect(result).toEqual({
|
||||
email: 'user@example.com',
|
||||
userId: '67890',
|
||||
exp: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null for JWT with no meaningful claims', () => {
|
||||
// JWT: {"iat":1234567890} (only issued-at, no email/sub/exp)
|
||||
const payload = Buffer.from(JSON.stringify({ iat: 1234567890 })).toString('base64');
|
||||
const token = `header.${payload}.signature`;
|
||||
|
||||
const result = extractUserInfo(token);
|
||||
expect(result).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveCredentials and loadCredentials', () => {
|
||||
it('should save and load credentials successfully', () => {
|
||||
const credentials: CursorCredentials = {
|
||||
accessToken: 'a'.repeat(50),
|
||||
machineId: 'b'.repeat(32),
|
||||
authMethod: 'auto-detect',
|
||||
importedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
saveCredentials(credentials);
|
||||
const loaded = loadCredentials();
|
||||
|
||||
expect(loaded).toEqual(credentials);
|
||||
});
|
||||
|
||||
it('should return null when no credentials file exists', () => {
|
||||
const loaded = loadCredentials();
|
||||
expect(loaded).toBe(null);
|
||||
});
|
||||
|
||||
it('should create directory with restrictive permissions', () => {
|
||||
const credentials: CursorCredentials = {
|
||||
accessToken: 'a'.repeat(50),
|
||||
machineId: 'b'.repeat(32),
|
||||
authMethod: 'manual',
|
||||
importedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
saveCredentials(credentials);
|
||||
|
||||
// CCS_HOME is set to tempDir, but getCcsDir() appends '.ccs' to it
|
||||
const credDir = path.join(tempDir, '.ccs', 'cursor');
|
||||
expect(fs.existsSync(credDir)).toBe(true);
|
||||
|
||||
// Check directory permissions (skip on Windows)
|
||||
if (process.platform !== 'win32') {
|
||||
const stats = fs.statSync(credDir);
|
||||
const mode = stats.mode & 0o777;
|
||||
expect(mode).toBe(0o700);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return null for invalid JSON in credentials file', () => {
|
||||
// CCS_HOME is set to tempDir, getCcsDir() returns path.join(tempDir, '.ccs')
|
||||
const credDir = path.join(tempDir, '.ccs', 'cursor');
|
||||
const credPath = path.join(credDir, 'credentials.json');
|
||||
fs.mkdirSync(credDir, { recursive: true });
|
||||
fs.writeFileSync(credPath, 'invalid json{{{');
|
||||
|
||||
const loaded = loadCredentials();
|
||||
expect(loaded).toBe(null);
|
||||
});
|
||||
|
||||
it('should return null for credentials missing required fields', () => {
|
||||
const credDir = path.join(tempDir, '.ccs', 'cursor');
|
||||
const credPath = path.join(credDir, 'credentials.json');
|
||||
fs.mkdirSync(credDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
credPath,
|
||||
JSON.stringify({
|
||||
accessToken: 'token',
|
||||
// Missing machineId, authMethod, importedAt
|
||||
})
|
||||
);
|
||||
|
||||
const loaded = loadCredentials();
|
||||
expect(loaded).toBe(null);
|
||||
});
|
||||
|
||||
it('should return null for credentials with wrong types', () => {
|
||||
const credDir = path.join(tempDir, '.ccs', 'cursor');
|
||||
const credPath = path.join(credDir, 'credentials.json');
|
||||
fs.mkdirSync(credDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
credPath,
|
||||
JSON.stringify({
|
||||
accessToken: 123, // Wrong type (number instead of string)
|
||||
machineId: 'abc',
|
||||
authMethod: 'auto-detect',
|
||||
importedAt: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
|
||||
const loaded = loadCredentials();
|
||||
expect(loaded).toBe(null);
|
||||
});
|
||||
|
||||
it('should return null for invalid authMethod', () => {
|
||||
const credDir = path.join(tempDir, '.ccs', 'cursor');
|
||||
const credPath = path.join(credDir, 'credentials.json');
|
||||
fs.mkdirSync(credDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
credPath,
|
||||
JSON.stringify({
|
||||
accessToken: 'token',
|
||||
machineId: 'abc',
|
||||
authMethod: 'invalid-method', // Invalid authMethod
|
||||
importedAt: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
|
||||
const loaded = loadCredentials();
|
||||
expect(loaded).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkAuthStatus', () => {
|
||||
it('should return not authenticated when no credentials exist', () => {
|
||||
const status = checkAuthStatus();
|
||||
expect(status.authenticated).toBe(false);
|
||||
expect(status.credentials).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return authenticated for valid credentials', () => {
|
||||
const credentials: CursorCredentials = {
|
||||
accessToken: 'a'.repeat(50),
|
||||
machineId: 'b'.repeat(32),
|
||||
authMethod: 'auto-detect',
|
||||
importedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
saveCredentials(credentials);
|
||||
const status = checkAuthStatus();
|
||||
|
||||
expect(status.authenticated).toBe(true);
|
||||
expect(status.credentials).toEqual(credentials);
|
||||
expect(status.expired).toBe(false);
|
||||
expect(status.tokenAge).toBeDefined();
|
||||
expect(status.tokenAge).toBeLessThan(1); // Just imported
|
||||
});
|
||||
|
||||
it('should detect expired credentials (importedAt > 24h ago)', () => {
|
||||
// Create credentials from 25 hours ago
|
||||
const past = new Date();
|
||||
past.setHours(past.getHours() - 25);
|
||||
|
||||
const credentials: CursorCredentials = {
|
||||
accessToken: 'a'.repeat(50),
|
||||
machineId: 'b'.repeat(32),
|
||||
authMethod: 'manual',
|
||||
importedAt: past.toISOString(),
|
||||
};
|
||||
|
||||
saveCredentials(credentials);
|
||||
const status = checkAuthStatus();
|
||||
|
||||
expect(status.authenticated).toBe(true);
|
||||
expect(status.expired).toBe(true);
|
||||
expect(status.tokenAge).toBeGreaterThanOrEqual(24);
|
||||
});
|
||||
|
||||
it('should return not authenticated for invalid token format', () => {
|
||||
const credentials: CursorCredentials = {
|
||||
accessToken: 'short', // Invalid (too short)
|
||||
machineId: 'b'.repeat(32),
|
||||
authMethod: 'manual',
|
||||
importedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
saveCredentials(credentials);
|
||||
const status = checkAuthStatus();
|
||||
|
||||
expect(status.authenticated).toBe(false);
|
||||
});
|
||||
|
||||
it('should use JWT exp claim when available', () => {
|
||||
// Create JWT token that expired 1 hour ago
|
||||
const expiredTime = Math.floor(Date.now() / 1000) - 3600;
|
||||
const payload = Buffer.from(
|
||||
JSON.stringify({ email: 'test@example.com', sub: '123', exp: expiredTime })
|
||||
).toString('base64');
|
||||
const jwtToken = `header.${payload}.signature`;
|
||||
|
||||
const credentials: CursorCredentials = {
|
||||
accessToken: jwtToken,
|
||||
machineId: 'b'.repeat(32),
|
||||
authMethod: 'auto-detect',
|
||||
importedAt: new Date().toISOString(), // Recent import
|
||||
};
|
||||
|
||||
saveCredentials(credentials);
|
||||
const status = checkAuthStatus();
|
||||
|
||||
expect(status.authenticated).toBe(true);
|
||||
expect(status.expired).toBe(true); // Should detect expiry from JWT exp
|
||||
});
|
||||
|
||||
it('should handle invalid importedAt date gracefully', () => {
|
||||
// Create credentials with valid format but garbage date value
|
||||
const credentials: CursorCredentials = {
|
||||
accessToken: 'a'.repeat(50),
|
||||
machineId: 'b'.repeat(32),
|
||||
authMethod: 'manual',
|
||||
importedAt: 'invalid-date-garbage-2026-99-99T99:99:99Z',
|
||||
};
|
||||
|
||||
saveCredentials(credentials);
|
||||
const status = checkAuthStatus();
|
||||
|
||||
// Should still authenticate if token format is valid
|
||||
expect(status.authenticated).toBe(true);
|
||||
// tokenAge should be undefined due to invalid date (NaN from getTime())
|
||||
expect(status.tokenAge).toBeUndefined();
|
||||
// expired should be false (defaults to false when date parsing fails)
|
||||
expect(status.expired).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteCredentials', () => {
|
||||
it('should delete existing credentials file and return true', () => {
|
||||
const credentials: CursorCredentials = {
|
||||
accessToken: 'a'.repeat(50),
|
||||
machineId: 'b'.repeat(32),
|
||||
authMethod: 'auto-detect',
|
||||
importedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
saveCredentials(credentials);
|
||||
expect(loadCredentials()).not.toBe(null);
|
||||
|
||||
const result = deleteCredentials();
|
||||
expect(result).toBe(true);
|
||||
expect(loadCredentials()).toBe(null);
|
||||
});
|
||||
|
||||
it('should return false when credentials file does not exist', () => {
|
||||
const result = deleteCredentials();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle multiple delete calls gracefully', () => {
|
||||
const credentials: CursorCredentials = {
|
||||
accessToken: 'a'.repeat(50),
|
||||
machineId: 'b'.repeat(32),
|
||||
authMethod: 'manual',
|
||||
importedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
saveCredentials(credentials);
|
||||
|
||||
// First delete should succeed
|
||||
expect(deleteCredentials()).toBe(true);
|
||||
|
||||
// Second delete should return false (already deleted)
|
||||
expect(deleteCredentials()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('autoDetectTokens', () => {
|
||||
it('should return not found for Windows platform', () => {
|
||||
// Save original platform
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
// Mock Windows platform
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'win32',
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const result = autoDetectTokens();
|
||||
|
||||
expect(result.found).toBe(false);
|
||||
expect(result.error).toContain('not supported on Windows');
|
||||
|
||||
// Restore original platform
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return not found when database file does not exist', () => {
|
||||
// Skip on Windows (already covered by previous test)
|
||||
if (process.platform === 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = autoDetectTokens();
|
||||
|
||||
// Should fail because Cursor database doesn't exist in test environment
|
||||
expect(result.found).toBe(false);
|
||||
expect(result.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have found property in return type', () => {
|
||||
const result = autoDetectTokens();
|
||||
|
||||
// Verify return type structure
|
||||
expect(result).toHaveProperty('found');
|
||||
expect(typeof result.found).toBe('boolean');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,657 @@
|
||||
/**
|
||||
* Cursor Protobuf Module Unit Tests
|
||||
* Tests encoder, decoder, translator, and executor components
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import {
|
||||
encodeVarint,
|
||||
encodeField,
|
||||
wrapConnectRPCFrame,
|
||||
concatArrays,
|
||||
} from '../../../src/cursor/cursor-protobuf-encoder';
|
||||
import {
|
||||
decodeVarint,
|
||||
decodeField,
|
||||
parseConnectRPCFrame,
|
||||
} from '../../../src/cursor/cursor-protobuf-decoder';
|
||||
import { buildCursorRequest } from '../../../src/cursor/cursor-translator';
|
||||
import { generateCursorBody } from '../../../src/cursor/cursor-protobuf';
|
||||
import { CursorExecutor } from '../../../src/cursor/cursor-executor';
|
||||
import { WIRE_TYPE, FIELD } from '../../../src/cursor/cursor-protobuf-schema';
|
||||
|
||||
describe('Protobuf Encoding/Decoding', () => {
|
||||
describe('encodeVarint / decodeVarint round-trip', () => {
|
||||
it('should encode and decode 0', () => {
|
||||
const encoded = encodeVarint(0);
|
||||
const [decoded, offset] = decodeVarint(encoded, 0);
|
||||
expect(decoded).toBe(0);
|
||||
expect(offset).toBe(1);
|
||||
});
|
||||
|
||||
it('should encode and decode 1', () => {
|
||||
const encoded = encodeVarint(1);
|
||||
const [decoded, offset] = decodeVarint(encoded, 0);
|
||||
expect(decoded).toBe(1);
|
||||
expect(offset).toBe(1);
|
||||
});
|
||||
|
||||
it('should encode and decode 127', () => {
|
||||
const encoded = encodeVarint(127);
|
||||
const [decoded, offset] = decodeVarint(encoded, 0);
|
||||
expect(decoded).toBe(127);
|
||||
expect(offset).toBe(1);
|
||||
});
|
||||
|
||||
it('should encode and decode 128', () => {
|
||||
const encoded = encodeVarint(128);
|
||||
const [decoded, offset] = decodeVarint(encoded, 0);
|
||||
expect(decoded).toBe(128);
|
||||
expect(offset).toBe(2);
|
||||
});
|
||||
|
||||
it('should encode and decode 16383', () => {
|
||||
const encoded = encodeVarint(16383);
|
||||
const [decoded, offset] = decodeVarint(encoded, 0);
|
||||
expect(decoded).toBe(16383);
|
||||
expect(offset).toBe(2);
|
||||
});
|
||||
|
||||
it('should encode and decode 0xFFFFFFFF', () => {
|
||||
const encoded = encodeVarint(0xffffffff);
|
||||
const [decoded, offset] = decodeVarint(encoded, 0);
|
||||
expect(decoded).toBe(0xffffffff);
|
||||
expect(offset).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('encodeField / decodeField round-trip', () => {
|
||||
it('should encode and decode VARINT field', () => {
|
||||
const fieldNum = 5;
|
||||
const value = 42;
|
||||
const encoded = encodeField(fieldNum, WIRE_TYPE.VARINT, value);
|
||||
|
||||
const [decodedFieldNum, wireType, decodedValue, offset] = decodeField(encoded, 0);
|
||||
expect(decodedFieldNum).toBe(fieldNum);
|
||||
expect(wireType).toBe(WIRE_TYPE.VARINT);
|
||||
expect(decodedValue).toBe(value);
|
||||
expect(offset).toBe(encoded.length);
|
||||
});
|
||||
|
||||
it('should encode and decode LEN field with string', () => {
|
||||
const fieldNum = 10;
|
||||
const value = 'Hello, World!';
|
||||
const encoded = encodeField(fieldNum, WIRE_TYPE.LEN, value);
|
||||
|
||||
const [decodedFieldNum, wireType, decodedValue, offset] = decodeField(encoded, 0);
|
||||
expect(decodedFieldNum).toBe(fieldNum);
|
||||
expect(wireType).toBe(WIRE_TYPE.LEN);
|
||||
expect(new TextDecoder().decode(decodedValue as Uint8Array)).toBe(value);
|
||||
expect(offset).toBe(encoded.length);
|
||||
});
|
||||
|
||||
it('should encode and decode LEN field with binary data', () => {
|
||||
const fieldNum = 15;
|
||||
const value = new Uint8Array([1, 2, 3, 4, 5]);
|
||||
const encoded = encodeField(fieldNum, WIRE_TYPE.LEN, value);
|
||||
|
||||
const [decodedFieldNum, wireType, decodedValue, offset] = decodeField(encoded, 0);
|
||||
expect(decodedFieldNum).toBe(fieldNum);
|
||||
expect(wireType).toBe(WIRE_TYPE.LEN);
|
||||
expect(decodedValue).toEqual(value);
|
||||
expect(offset).toBe(encoded.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('wrapConnectRPCFrame / parseConnectRPCFrame round-trip', () => {
|
||||
it('should wrap and parse uncompressed frame', () => {
|
||||
const payload = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
|
||||
const frame = wrapConnectRPCFrame(payload, false);
|
||||
|
||||
const parsed = parseConnectRPCFrame(Buffer.from(frame));
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed!.flags).toBe(0x00);
|
||||
expect(parsed!.length).toBe(payload.length);
|
||||
expect(parsed!.payload).toEqual(payload);
|
||||
expect(parsed!.consumed).toBe(5 + payload.length);
|
||||
});
|
||||
|
||||
it('should wrap and parse compressed frame', () => {
|
||||
const payload = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
|
||||
const frame = wrapConnectRPCFrame(payload, true);
|
||||
|
||||
const parsed = parseConnectRPCFrame(Buffer.from(frame));
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed!.flags).toBe(0x01); // GZIP flag
|
||||
expect(parsed!.payload).toEqual(payload); // Should be decompressed
|
||||
});
|
||||
|
||||
it('should handle incomplete frame', () => {
|
||||
const partial = new Uint8Array([0x00, 0x00, 0x00]); // Only 3 bytes
|
||||
const parsed = parseConnectRPCFrame(Buffer.from(partial));
|
||||
expect(parsed).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('concatArrays', () => {
|
||||
it('should concatenate multiple arrays', () => {
|
||||
const arr1 = new Uint8Array([1, 2, 3]);
|
||||
const arr2 = new Uint8Array([4, 5]);
|
||||
const arr3 = new Uint8Array([6, 7, 8, 9]);
|
||||
|
||||
const result = concatArrays(arr1, arr2, arr3);
|
||||
expect(result).toEqual(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9]));
|
||||
});
|
||||
|
||||
it('should handle empty arrays', () => {
|
||||
const arr1 = new Uint8Array([1, 2]);
|
||||
const arr2 = new Uint8Array([]);
|
||||
const arr3 = new Uint8Array([3, 4]);
|
||||
|
||||
const result = concatArrays(arr1, arr2, arr3);
|
||||
expect(result).toEqual(new Uint8Array([1, 2, 3, 4]));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Message Translation', () => {
|
||||
describe('buildCursorRequest', () => {
|
||||
it('should convert system message to user with prefix', () => {
|
||||
const result = buildCursorRequest(
|
||||
'gpt-4',
|
||||
{
|
||||
messages: [{ role: 'system', content: 'You are a helpful assistant.' }],
|
||||
},
|
||||
false,
|
||||
{}
|
||||
);
|
||||
|
||||
expect(result.messages).toHaveLength(1);
|
||||
expect(result.messages[0].role).toBe('user');
|
||||
expect(result.messages[0].content).toContain('[System Instructions]');
|
||||
expect(result.messages[0].content).toContain('You are a helpful assistant.');
|
||||
});
|
||||
|
||||
it('should keep user and assistant messages', () => {
|
||||
const result = buildCursorRequest(
|
||||
'gpt-4',
|
||||
{
|
||||
messages: [
|
||||
{ role: 'user', content: 'Hello' },
|
||||
{ role: 'assistant', content: 'Hi there!' },
|
||||
],
|
||||
},
|
||||
false,
|
||||
{}
|
||||
);
|
||||
|
||||
expect(result.messages).toHaveLength(2);
|
||||
expect(result.messages[0].role).toBe('user');
|
||||
expect(result.messages[0].content).toBe('Hello');
|
||||
expect(result.messages[1].role).toBe('assistant');
|
||||
expect(result.messages[1].content).toBe('Hi there!');
|
||||
});
|
||||
|
||||
it('should handle assistant messages with tool_calls', () => {
|
||||
const result = buildCursorRequest(
|
||||
'gpt-4',
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_123',
|
||||
type: 'function',
|
||||
function: { name: 'get_weather', arguments: '{"city":"NYC"}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
false,
|
||||
{}
|
||||
);
|
||||
|
||||
expect(result.messages).toHaveLength(1);
|
||||
expect(result.messages[0].role).toBe('assistant');
|
||||
expect(result.messages[0].tool_calls).toHaveLength(1);
|
||||
expect(result.messages[0].tool_calls![0].id).toBe('call_123');
|
||||
expect(result.messages[0].tool_calls![0].function.name).toBe('get_weather');
|
||||
});
|
||||
|
||||
it('should accumulate tool results', () => {
|
||||
const result = buildCursorRequest(
|
||||
'gpt-4',
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_123',
|
||||
type: 'function',
|
||||
function: { name: 'get_weather', arguments: '{"city":"NYC"}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
content: '{"temperature": 72}',
|
||||
name: 'get_weather',
|
||||
tool_call_id: 'call_123',
|
||||
},
|
||||
{ role: 'user', content: 'What is the weather?' },
|
||||
],
|
||||
},
|
||||
false,
|
||||
{}
|
||||
);
|
||||
|
||||
expect(result.messages).toHaveLength(2);
|
||||
// Tool result should be attached to next message
|
||||
expect(result.messages[1].tool_results).toBeDefined();
|
||||
expect(result.messages[1].tool_results).toHaveLength(1);
|
||||
expect(result.messages[1].tool_results![0].tool_call_id).toBe('call_123');
|
||||
});
|
||||
|
||||
it('should handle array content format', () => {
|
||||
const result = buildCursorRequest(
|
||||
'gpt-4',
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'Hello' },
|
||||
{ type: 'text', text: ' World' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
false,
|
||||
{}
|
||||
);
|
||||
|
||||
expect(result.messages).toHaveLength(1);
|
||||
expect(result.messages[0].content).toBe('Hello World');
|
||||
});
|
||||
|
||||
it('should handle system message with array content format', () => {
|
||||
const result = buildCursorRequest(
|
||||
'gpt-4',
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: [
|
||||
{ type: 'text', text: 'System instruction part 1' },
|
||||
{ type: 'text', text: ' part 2' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
false,
|
||||
{}
|
||||
);
|
||||
|
||||
expect(result.messages).toHaveLength(1);
|
||||
expect(result.messages[0].role).toBe('user');
|
||||
expect(result.messages[0].content).toBe('[System Instructions]\nSystem instruction part 1 part 2');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Request Encoding', () => {
|
||||
describe('generateCursorBody', () => {
|
||||
it('should encode basic text message', () => {
|
||||
const result = generateCursorBody([{ role: 'user', content: 'Hello' }], 'gpt-4', [], null);
|
||||
|
||||
expect(result).toBeInstanceOf(Uint8Array);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should encode message with tools', () => {
|
||||
const tools = [
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'get_weather',
|
||||
description: 'Get weather data',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
city: { type: 'string' },
|
||||
},
|
||||
required: ['city'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const result = generateCursorBody([{ role: 'user', content: 'What is the weather?' }], 'gpt-4', tools, null);
|
||||
|
||||
expect(result).toBeInstanceOf(Uint8Array);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle malformed frame gracefully', () => {
|
||||
const executor = new CursorExecutor();
|
||||
|
||||
// Incomplete frame header (only 3 bytes instead of 5)
|
||||
const incompleteFrame = Buffer.from([0x00, 0x00, 0x00]);
|
||||
|
||||
const result = executor.transformProtobufToJSON(incompleteFrame, 'gpt-4', {
|
||||
messages: [],
|
||||
});
|
||||
|
||||
// Should return valid response even with malformed input
|
||||
expect(result.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should handle truncated payload', () => {
|
||||
const executor = new CursorExecutor();
|
||||
|
||||
// Frame header says payload is 100 bytes but only 5 bytes follow
|
||||
const truncatedFrame = Buffer.from([0x00, 0x00, 0x00, 0x00, 0x64, 0x01, 0x02, 0x03, 0x04, 0x05]);
|
||||
|
||||
const result = executor.transformProtobufToJSON(truncatedFrame, 'gpt-4', {
|
||||
messages: [],
|
||||
});
|
||||
|
||||
// Should handle gracefully
|
||||
expect(result.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should handle multi-frame buffer', () => {
|
||||
const executor = new CursorExecutor();
|
||||
|
||||
// Create two simple frames
|
||||
const frame1 = wrapConnectRPCFrame(
|
||||
encodeField(FIELD.RESPONSE_TEXT, WIRE_TYPE.LEN, 'Frame 1'),
|
||||
false
|
||||
);
|
||||
const frame2 = wrapConnectRPCFrame(
|
||||
encodeField(FIELD.RESPONSE_TEXT, WIRE_TYPE.LEN, ' Frame 2'),
|
||||
false
|
||||
);
|
||||
|
||||
// Concatenate them
|
||||
const multiFrame = Buffer.concat([Buffer.from(frame1), Buffer.from(frame2)]);
|
||||
|
||||
const result = executor.transformProtobufToJSON(multiFrame, 'gpt-4', {
|
||||
messages: [],
|
||||
});
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('CursorExecutor', () => {
|
||||
const executor = new CursorExecutor();
|
||||
|
||||
describe('generateChecksum', () => {
|
||||
it('should generate valid checksum format', () => {
|
||||
const machineId = 'test-machine-id';
|
||||
const checksum = executor.generateChecksum(machineId);
|
||||
|
||||
// Should end with machine ID
|
||||
expect(checksum.endsWith(machineId)).toBe(true);
|
||||
|
||||
// Should have base64url-like prefix (8 chars from 6 bytes)
|
||||
const prefix = checksum.slice(0, -machineId.length);
|
||||
expect(prefix.length).toBe(8);
|
||||
expect(/^[A-Za-z0-9_-]+$/.test(prefix)).toBe(true);
|
||||
});
|
||||
|
||||
it('should generate valid checksums at different call times', async () => {
|
||||
const machineId = 'test-machine-id';
|
||||
const checksum1 = executor.generateChecksum(machineId);
|
||||
|
||||
// Wait to ensure timestamp may change (though timestamp granularity is ~16 min)
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
const checksum2 = executor.generateChecksum(machineId);
|
||||
|
||||
// Verify both checksums are valid (may be same due to timestamp granularity)
|
||||
expect(checksum1.endsWith(machineId)).toBe(true);
|
||||
expect(checksum2.endsWith(machineId)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildHeaders', () => {
|
||||
it('should generate all required headers', () => {
|
||||
const credentials = {
|
||||
accessToken: 'test-token',
|
||||
machineId: 'test-machine-id',
|
||||
};
|
||||
|
||||
const headers = executor.buildHeaders(credentials);
|
||||
|
||||
expect(headers).toHaveProperty('authorization');
|
||||
expect(headers.authorization).toContain('Bearer');
|
||||
expect(headers).toHaveProperty('connect-accept-encoding', 'gzip');
|
||||
expect(headers).toHaveProperty('connect-protocol-version', '1');
|
||||
expect(headers).toHaveProperty('content-type', 'application/connect+proto');
|
||||
expect(headers).toHaveProperty('user-agent', 'connect-es/1.6.1');
|
||||
expect(headers).toHaveProperty('x-cursor-checksum');
|
||||
expect(headers).toHaveProperty('x-cursor-client-version', '2.3.41');
|
||||
expect(headers).toHaveProperty('x-cursor-client-type', 'ide');
|
||||
expect(headers).toHaveProperty('x-ghost-mode', 'true');
|
||||
});
|
||||
|
||||
it('should handle token with :: delimiter', () => {
|
||||
const credentials = {
|
||||
accessToken: 'prefix::actual-token',
|
||||
machineId: 'test-machine-id',
|
||||
};
|
||||
|
||||
const headers = executor.buildHeaders(credentials);
|
||||
|
||||
expect(headers.authorization).toBe('Bearer actual-token');
|
||||
});
|
||||
|
||||
it('should respect ghostMode flag', () => {
|
||||
const credentialsGhost = {
|
||||
accessToken: 'test-token',
|
||||
machineId: 'test-machine-id',
|
||||
ghostMode: true,
|
||||
};
|
||||
|
||||
const credentialsNoGhost = {
|
||||
accessToken: 'test-token',
|
||||
machineId: 'test-machine-id',
|
||||
ghostMode: false,
|
||||
};
|
||||
|
||||
const headersGhost = executor.buildHeaders(credentialsGhost);
|
||||
const headersNoGhost = executor.buildHeaders(credentialsNoGhost);
|
||||
|
||||
expect(headersGhost['x-ghost-mode']).toBe('true');
|
||||
expect(headersNoGhost['x-ghost-mode']).toBe('false');
|
||||
});
|
||||
|
||||
it('should throw error if machineId missing', () => {
|
||||
const credentials = {
|
||||
accessToken: 'test-token',
|
||||
machineId: '',
|
||||
};
|
||||
|
||||
expect(() => executor.buildHeaders(credentials)).toThrow('Machine ID is required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildUrl', () => {
|
||||
it('should return correct API endpoint', () => {
|
||||
const url = executor.buildUrl();
|
||||
expect(url).toBe('https://api2.cursor.sh/aiserver.v1.AiService/StreamChat');
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformProtobufToJSON', () => {
|
||||
it('should handle basic text response', async () => {
|
||||
// Create minimal protobuf response with text
|
||||
const textContent = 'Hello, world!';
|
||||
const responseField = encodeField(FIELD.RESPONSE_TEXT, WIRE_TYPE.LEN, textContent);
|
||||
const responseMsg = encodeField(FIELD.RESPONSE, WIRE_TYPE.LEN, responseField);
|
||||
const frame = wrapConnectRPCFrame(responseMsg, false);
|
||||
|
||||
const result = executor.transformProtobufToJSON(Buffer.from(frame), 'gpt-4', {
|
||||
messages: [],
|
||||
});
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
const bodyText = await result.text();
|
||||
const body = JSON.parse(bodyText);
|
||||
expect(body.choices[0].message.content).toBe(textContent);
|
||||
expect(body.choices[0].finish_reason).toBe('stop');
|
||||
});
|
||||
|
||||
it('should handle JSON error response', async () => {
|
||||
const errorJson = JSON.stringify({
|
||||
error: {
|
||||
code: 'resource_exhausted',
|
||||
message: 'Rate limit exceeded',
|
||||
},
|
||||
});
|
||||
const frame = wrapConnectRPCFrame(new TextEncoder().encode(errorJson), false);
|
||||
|
||||
const result = executor.transformProtobufToJSON(Buffer.from(frame), 'gpt-4', {
|
||||
messages: [],
|
||||
});
|
||||
|
||||
expect(result.status).toBe(429);
|
||||
const bodyText = await result.text();
|
||||
const body = JSON.parse(bodyText);
|
||||
expect(body.error.type).toBe('rate_limit_error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformProtobufToSSE', () => {
|
||||
it('should output SSE format', async () => {
|
||||
// Create minimal protobuf response with text
|
||||
const textContent = 'Hello';
|
||||
const responseField = encodeField(FIELD.RESPONSE_TEXT, WIRE_TYPE.LEN, textContent);
|
||||
const responseMsg = encodeField(FIELD.RESPONSE, WIRE_TYPE.LEN, responseField);
|
||||
const frame = wrapConnectRPCFrame(responseMsg, false);
|
||||
|
||||
const result = executor.transformProtobufToSSE(Buffer.from(frame), 'gpt-4', {
|
||||
messages: [],
|
||||
});
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.headers.get('content-type')).toBe('text/event-stream');
|
||||
|
||||
const bodyText = await result.text();
|
||||
expect(bodyText).toContain('data: ');
|
||||
expect(bodyText).toContain('data: [DONE]');
|
||||
expect(bodyText).toContain(textContent);
|
||||
});
|
||||
|
||||
it('should handle JSON error response', async () => {
|
||||
const errorJson = JSON.stringify({
|
||||
error: {
|
||||
code: 'resource_exhausted',
|
||||
message: 'Rate limit exceeded',
|
||||
},
|
||||
});
|
||||
const frame = wrapConnectRPCFrame(new TextEncoder().encode(errorJson), false);
|
||||
|
||||
const result = executor.transformProtobufToSSE(Buffer.from(frame), 'gpt-4', {
|
||||
messages: [],
|
||||
});
|
||||
|
||||
expect(result.status).toBe(429);
|
||||
const bodyText = await result.text();
|
||||
const body = JSON.parse(bodyText);
|
||||
expect(body.error.type).toBe('rate_limit_error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('decompressPayload error handling', () => {
|
||||
it('should return empty buffer on decompression failure', () => {
|
||||
// Create invalid gzip data
|
||||
const invalidGzip = Buffer.from([0x1f, 0x8b, 0x08, 0x00, 0xff, 0xff]);
|
||||
const frame = new Uint8Array(5 + invalidGzip.length);
|
||||
frame[0] = 0x01; // GZIP flag
|
||||
frame[1] = 0;
|
||||
frame[2] = 0;
|
||||
frame[3] = 0;
|
||||
frame[4] = invalidGzip.length;
|
||||
frame.set(invalidGzip, 5);
|
||||
|
||||
const result = executor.transformProtobufToJSON(Buffer.from(frame), 'gpt-4', {
|
||||
messages: [],
|
||||
});
|
||||
|
||||
// Should handle gracefully and return valid response
|
||||
expect(result.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should return empty buffer on decompression failure', () => {
|
||||
const executor = new CursorExecutor();
|
||||
|
||||
// Invalid compressed payload (not actually gzipped)
|
||||
const invalidGzipPayload = new Uint8Array([1, 2, 3, 4, 5]);
|
||||
const flags = 0x01; // GZIP flag
|
||||
|
||||
// Wrap with ConnectRPC frame header (flags + length)
|
||||
const length = invalidGzipPayload.length;
|
||||
const frame = new Uint8Array(5 + length);
|
||||
frame[0] = flags;
|
||||
frame[1] = (length >> 24) & 0xff;
|
||||
frame[2] = (length >> 16) & 0xff;
|
||||
frame[3] = (length >> 8) & 0xff;
|
||||
frame[4] = length & 0xff;
|
||||
frame.set(invalidGzipPayload, 5);
|
||||
|
||||
const buffer = Buffer.from(frame);
|
||||
|
||||
// Should not crash - decompression failure returns empty buffer
|
||||
const result = executor.transformProtobufToJSON(buffer, 'test-model', {
|
||||
messages: [],
|
||||
stream: false,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should log unknown message roles in debug mode', () => {
|
||||
const originalDebug = process.env.CCS_DEBUG;
|
||||
process.env.CCS_DEBUG = '1';
|
||||
|
||||
const consoleSpy: string[] = [];
|
||||
const originalError = console.error;
|
||||
console.error = (...args: unknown[]) => {
|
||||
const msg = args.map((a) => String(a)).join(' ');
|
||||
consoleSpy.push(msg);
|
||||
};
|
||||
|
||||
try {
|
||||
const messages = [
|
||||
{
|
||||
role: 'unknown_role' as 'user', // Type assertion to bypass TS
|
||||
content: 'test',
|
||||
},
|
||||
];
|
||||
|
||||
// buildCursorRequest expects (model, body, stream, credentials)
|
||||
buildCursorRequest('test-model', { messages }, false, { machineId: '12345', accessToken: 'test' });
|
||||
|
||||
// Should have logged warning
|
||||
const hasWarning = consoleSpy.some((log) => log.includes('Unknown message role'));
|
||||
expect(hasWarning).toBe(true);
|
||||
} finally {
|
||||
console.error = originalError;
|
||||
process.env.CCS_DEBUG = originalDebug;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -69,7 +69,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
||||
defaultProfileName: 'glm',
|
||||
badge: 'Z.AI',
|
||||
icon: '/icons/zai.svg',
|
||||
defaultModel: 'glm-4.7',
|
||||
defaultModel: 'glm-5',
|
||||
requiresApiKey: true,
|
||||
apiKeyPlaceholder: 'ghp_...',
|
||||
apiKeyHint: 'Get your API key from Z.AI',
|
||||
@@ -83,7 +83,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
||||
defaultProfileName: 'glmt',
|
||||
badge: 'Thinking',
|
||||
icon: '/icons/zai.svg',
|
||||
defaultModel: 'glm-4.7',
|
||||
defaultModel: 'glm-5',
|
||||
requiresApiKey: true,
|
||||
apiKeyPlaceholder: 'ghp_...',
|
||||
apiKeyHint: 'Same API key as GLM',
|
||||
@@ -162,12 +162,12 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
||||
{
|
||||
id: 'ollama-cloud',
|
||||
name: 'Ollama Cloud',
|
||||
description: 'Ollama cloud models via direct API (glm-4.7:cloud, minimax-m2.1:cloud)',
|
||||
description: 'Ollama cloud models via direct API (glm-5:cloud, minimax-m2.1:cloud)',
|
||||
baseUrl: 'https://ollama.com',
|
||||
defaultProfileName: 'ollama-cloud',
|
||||
badge: 'Cloud',
|
||||
icon: '/icons/ollama.svg',
|
||||
defaultModel: 'glm-4.7:cloud',
|
||||
defaultModel: 'glm-5:cloud',
|
||||
requiresApiKey: true,
|
||||
apiKeyPlaceholder: 'YOUR_OLLAMA_CLOUD_API_KEY',
|
||||
apiKeyHint: 'Get your API key at ollama.com',
|
||||
|
||||
Reference in new issue
Block a user