feat(cursor): harden daemon integration and model discovery

This commit is contained in:
Tam Nhu Tran committed 2026-02-17 04:09:06 +07:00
1 parent 4bd6877dcf
commit c639cefa7b
11 files changed
+953 -252

No files matched your search

+1
View File
@@ -59,6 +59,7 @@ ccs cursor stop
- Default port: `20129` - Default port: `20129`
- `ghost_mode`: enabled - `ghost_mode`: enabled
- `auto_start`: disabled - `auto_start`: disabled
- Model list resolution: authenticated live fetch when available, with cached/default fallback.
These values are managed in unified config and can be updated from CLI or dashboard. These values are managed in unified config and can be updated from CLI or dashboard.
+124
View File
@@ -0,0 +1,124 @@
/**
* Cursor Client Policy
*
* Single source of truth for Cursor request identity headers and checksum generation.
*/
import * as crypto from 'crypto';
import type { CursorCredentials } from './cursor-protobuf-schema';
export const CURSOR_CLIENT_VERSION = '2.3.41';
export const CURSOR_USER_AGENT = 'connect-es/1.6.1';
function getClientOs(): string {
if (process.platform === 'win32') return 'windows';
if (process.platform === 'darwin') return 'macos';
return 'linux';
}
function getClientArch(): string {
return process.arch === 'arm64' ? 'aarch64' : 'x64';
}
export function normalizeCursorAccessToken(accessToken: string): string {
const delimIdx = accessToken.indexOf('::');
return delimIdx !== -1 ? accessToken.slice(delimIdx + 2) : accessToken;
}
/**
* Generate checksum using Jyh cipher (time-based XOR with rolling key seed=165)
*/
export function generateCursorChecksum(machineId: string, nowMs: number = Date.now()): string {
if (!machineId) {
throw new Error('Machine ID is required for Cursor API');
}
const timestamp = Math.floor(nowMs / 1000000);
// JS bitwise shifts wrap modulo 32, so >>40 and >>32 give wrong results.
// Use Math.trunc division for upper bytes that exceed 32-bit range.
const byteArray = new Uint8Array([
Math.trunc(timestamp / 2 ** 40) & 0xff,
Math.trunc(timestamp / 2 ** 32) & 0xff,
(timestamp >>> 24) & 0xff,
(timestamp >>> 16) & 0xff,
(timestamp >>> 8) & 0xff,
timestamp & 0xff,
]);
let t = 165;
for (let i = 0; i < byteArray.length; i++) {
byteArray[i] = ((byteArray[i] ^ t) + (i % 256)) & 0xff;
t = byteArray[i];
}
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
let encoded = '';
for (let i = 0; i < byteArray.length; i += 3) {
const a = byteArray[i];
const b = i + 1 < byteArray.length ? byteArray[i + 1] : 0;
const c = i + 2 < byteArray.length ? byteArray[i + 2] : 0;
encoded += alphabet[a >> 2];
encoded += alphabet[((a & 3) << 4) | (b >> 4)];
if (i + 1 < byteArray.length) {
encoded += alphabet[((b & 15) << 2) | (c >> 6)];
}
if (i + 2 < byteArray.length) {
encoded += alphabet[c & 63];
}
}
return `${encoded}${machineId}`;
}
function buildCursorBaseHeaders(credentials: CursorCredentials): Record<string, string> {
const cleanToken = normalizeCursorAccessToken(credentials.accessToken);
if (!cleanToken) {
throw new Error('Access token is empty after parsing');
}
if (!credentials.machineId) {
throw new Error('Machine ID is required for Cursor API');
}
const ghostMode = credentials.ghostMode !== false;
const tokenHash = crypto.createHash('sha256').update(cleanToken).digest('hex');
return {
authorization: `Bearer ${cleanToken}`,
'x-amzn-trace-id': `Root=${crypto.randomUUID()}`,
'x-client-key': tokenHash,
'x-cursor-checksum': generateCursorChecksum(credentials.machineId),
'x-cursor-client-version': CURSOR_CLIENT_VERSION,
'x-cursor-client-type': 'ide',
'x-cursor-client-os': getClientOs(),
'x-cursor-client-arch': getClientArch(),
'x-cursor-client-device-type': 'desktop',
'x-cursor-config-version': crypto.randomUUID(),
'x-cursor-timezone': Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
'x-ghost-mode': ghostMode ? 'true' : 'false',
'x-request-id': crypto.randomUUID(),
'x-session-id': tokenHash.substring(0, 36),
};
}
export function buildCursorConnectHeaders(credentials: CursorCredentials): Record<string, string> {
return {
...buildCursorBaseHeaders(credentials),
'connect-accept-encoding': 'gzip',
'connect-protocol-version': '1',
'content-type': 'application/connect+proto',
'user-agent': CURSOR_USER_AGENT,
};
}
export function buildCursorModelsHeaders(credentials: CursorCredentials): Record<string, string> {
return {
...buildCursorBaseHeaders(credentials),
accept: 'application/json',
'user-agent': CURSOR_USER_AGENT,
};
}
+14 -2
View File
@@ -8,7 +8,7 @@ import * as http from 'http';
import { Readable } from 'stream'; import { Readable } from 'stream';
import { CursorExecutor } from './cursor-executor'; import { CursorExecutor } from './cursor-executor';
import { checkAuthStatus } from './cursor-auth'; import { checkAuthStatus } from './cursor-auth';
import { DEFAULT_CURSOR_MODEL, DEFAULT_CURSOR_MODELS } from './cursor-models'; import { DEFAULT_CURSOR_MODEL, getModelsForDaemon } from './cursor-models';
import type { CursorTool } from './cursor-protobuf-schema'; import type { CursorTool } from './cursor-protobuf-schema';
interface DaemonRuntimeOptions { interface DaemonRuntimeOptions {
@@ -184,7 +184,19 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser
} }
if (method === 'GET' && requestUrl === '/v1/models') { if (method === 'GET' && requestUrl === '/v1/models') {
const data = DEFAULT_CURSOR_MODELS.map((model) => ({ const authStatus = checkAuthStatus();
const models = await getModelsForDaemon({
credentials:
authStatus.authenticated && !authStatus.expired && authStatus.credentials
? {
accessToken: authStatus.credentials.accessToken,
machineId: authStatus.credentials.machineId,
ghostMode: options.ghostMode,
}
: null,
});
const data = models.map((model) => ({
id: model.id, id: model.id,
object: 'model', object: 'model',
created: 0, created: 0,
+173
View File
@@ -0,0 +1,173 @@
/**
* Cursor Default Model Catalog
*/
import type { CursorModel } from './types';
/** Default model ID */
export const DEFAULT_CURSOR_MODEL = 'gpt-5.3-codex';
/**
* Default models available through Cursor IDE.
* Used as fallback when daemon is not reachable.
* Source: Cursor docs model catalog (Feb 2026)
*/
export const DEFAULT_CURSOR_MODELS: CursorModel[] = [
// Anthropic Models
{
id: 'claude-4.6-opus',
name: 'Claude 4.6 Opus',
provider: 'anthropic',
},
{
id: 'claude-4.6-opus-fast-mode',
name: 'Claude 4.6 Opus (Fast mode)',
provider: 'anthropic',
},
{
id: 'claude-4.5-sonnet',
name: 'Claude 4.5 Sonnet',
provider: 'anthropic',
},
{
id: 'claude-4.5-opus',
name: 'Claude 4.5 Opus',
provider: 'anthropic',
},
{
id: 'claude-4.5-haiku',
name: 'Claude 4.5 Haiku',
provider: 'anthropic',
},
{
id: 'claude-4-sonnet',
name: 'Claude 4 Sonnet',
provider: 'anthropic',
},
{
id: 'claude-4-sonnet-1m',
name: 'Claude 4 Sonnet 1M',
provider: 'anthropic',
},
// Cursor Models
{
id: 'composer-1.5',
name: 'Composer 1.5',
provider: 'cursor',
},
{
id: 'composer-1',
name: 'Composer 1',
provider: 'cursor',
},
// OpenAI Models
{
id: 'gpt-5.3-codex',
name: 'GPT-5.3 Codex',
provider: 'openai',
isDefault: true,
},
{
id: 'gpt-5.2-codex',
name: 'GPT-5.2 Codex',
provider: 'openai',
},
{
id: 'gpt-5.2',
name: 'GPT-5.2',
provider: 'openai',
},
{
id: 'gpt-5.1-codex',
name: 'GPT-5.1 Codex',
provider: 'openai',
},
{
id: 'gpt-5.1-codex-max',
name: 'GPT-5.1 Codex Max',
provider: 'openai',
},
{
id: 'gpt-5.1-codex-mini',
name: 'GPT-5.1 Codex Mini',
provider: 'openai',
},
{
id: 'gpt-5-codex',
name: 'GPT-5-Codex',
provider: 'openai',
},
{
id: 'gpt-5',
name: 'GPT-5',
provider: 'openai',
},
{
id: 'gpt-5-fast',
name: 'GPT-5 Fast',
provider: 'openai',
},
{
id: 'gpt-5-mini',
name: 'GPT-5 Mini',
provider: 'openai',
},
// Google Models
{
id: 'gemini-3-pro',
name: 'Gemini 3 Pro',
provider: 'google',
},
{
id: 'gemini-3-pro-image-preview',
name: 'Gemini 3 Pro Image Preview',
provider: 'google',
},
{
id: 'gemini-3-flash',
name: 'Gemini 3 Flash',
provider: 'google',
},
{
id: 'gemini-2.5-flash',
name: 'Gemini 2.5 Flash',
provider: 'google',
},
// xAI Models
{
id: 'grok-code',
name: 'Grok Code',
provider: 'xai',
},
];
/**
* Detect provider from model ID.
*/
export function detectProvider(modelId: string): string {
if (modelId.includes('claude')) return 'anthropic';
if (modelId.includes('gpt') || /^o[1-9]\d*(-|$)/.test(modelId)) return 'openai';
if (modelId.includes('gemini')) return 'google';
if (modelId.includes('cursor') || modelId.includes('composer')) return 'cursor';
if (modelId.includes('grok')) return 'xai';
return 'unknown';
}
/**
* Format model ID to human-readable name.
*/
export function formatModelName(modelId: string): string {
const model = DEFAULT_CURSOR_MODELS.find((m) => m.id === modelId);
if (model) {
return model.name;
}
return modelId
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
+48 -81
View File
@@ -3,11 +3,11 @@
* Handles HTTP/2 requests to Cursor API with protobuf encoding/decoding * Handles HTTP/2 requests to Cursor API with protobuf encoding/decoding
*/ */
import * as crypto from 'crypto';
import type { IncomingHttpHeaders } from 'http'; import type { IncomingHttpHeaders } from 'http';
import { generateCursorBody, extractTextFromResponse } from './cursor-protobuf.js'; import { generateCursorBody, extractTextFromResponse } from './cursor-protobuf.js';
import { buildCursorRequest } from './cursor-translator.js'; import { buildCursorRequest } from './cursor-translator.js';
import type { CursorTool, CursorCredentials } from './cursor-protobuf-schema.js'; import type { CursorTool, CursorCredentials } from './cursor-protobuf-schema.js';
import { buildCursorConnectHeaders, generateCursorChecksum } from './cursor-client-policy.js';
import { StreamingFrameParser, decompressPayload } from './cursor-stream-parser.js'; import { StreamingFrameParser, decompressPayload } from './cursor-stream-parser.js';
@@ -92,8 +92,6 @@ function createErrorResponse(jsonError: {
export class CursorExecutor { export class CursorExecutor {
private readonly baseUrl = 'https://api2.cursor.sh'; private readonly baseUrl = 'https://api2.cursor.sh';
private readonly chatPath = '/aiserver.v1.AiService/StreamChat'; private readonly chatPath = '/aiserver.v1.AiService/StreamChat';
private readonly CURSOR_CLIENT_VERSION = '2.3.41';
private readonly CURSOR_USER_AGENT = 'connect-es/1.6.1';
buildUrl(): string { buildUrl(): string {
return `${this.baseUrl}${this.chatPath}`; return `${this.baseUrl}${this.chatPath}`;
@@ -103,87 +101,11 @@ export class CursorExecutor {
* Generate checksum using Jyh cipher (time-based XOR with rolling key seed=165) * Generate checksum using Jyh cipher (time-based XOR with rolling key seed=165)
*/ */
generateChecksum(machineId: string): string { generateChecksum(machineId: string): string {
const timestamp = Math.floor(Date.now() / 1000000); return generateCursorChecksum(machineId);
// JS bitwise shifts wrap modulo 32, so >>40 and >>32 give wrong results.
// Use Math.trunc division for upper bytes that exceed 32-bit range.
const byteArray = new Uint8Array([
Math.trunc(timestamp / 2 ** 40) & 0xff,
Math.trunc(timestamp / 2 ** 32) & 0xff,
(timestamp >>> 24) & 0xff,
(timestamp >>> 16) & 0xff,
(timestamp >>> 8) & 0xff,
timestamp & 0xff,
]);
let t = 165;
for (let i = 0; i < byteArray.length; i++) {
byteArray[i] = ((byteArray[i] ^ t) + (i % 256)) & 0xff;
t = byteArray[i];
}
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
let encoded = '';
for (let i = 0; i < byteArray.length; i += 3) {
const a = byteArray[i];
const b = i + 1 < byteArray.length ? byteArray[i + 1] : 0;
const c = i + 2 < byteArray.length ? byteArray[i + 2] : 0;
encoded += alphabet[a >> 2];
encoded += alphabet[((a & 3) << 4) | (b >> 4)];
if (i + 1 < byteArray.length) {
encoded += alphabet[((b & 15) << 2) | (c >> 6)];
}
if (i + 2 < byteArray.length) {
encoded += alphabet[c & 63];
}
}
return `${encoded}${machineId}`;
} }
buildHeaders(credentials: CursorCredentials): Record<string, string> { buildHeaders(credentials: CursorCredentials): Record<string, string> {
const accessToken = credentials.accessToken; return buildCursorConnectHeaders(credentials);
const machineId = credentials.machineId;
const ghostMode = credentials.ghostMode !== false;
if (!machineId) {
throw new Error('Machine ID is required for Cursor API');
}
const delimIdx = accessToken.indexOf('::');
const cleanToken = delimIdx !== -1 ? accessToken.slice(delimIdx + 2) : accessToken;
if (!cleanToken) {
throw new Error('Access token is empty after parsing');
}
return {
authorization: `Bearer ${cleanToken}`,
'connect-accept-encoding': 'gzip',
'connect-protocol-version': '1',
'content-type': 'application/connect+proto',
'user-agent': this.CURSOR_USER_AGENT,
'x-amzn-trace-id': `Root=${crypto.randomUUID()}`,
'x-client-key': crypto.createHash('sha256').update(cleanToken).digest('hex'),
'x-cursor-checksum': this.generateChecksum(machineId),
'x-cursor-client-version': this.CURSOR_CLIENT_VERSION,
'x-cursor-client-type': 'ide',
'x-cursor-client-os':
process.platform === 'win32'
? 'windows'
: process.platform === 'darwin'
? 'macos'
: 'linux',
'x-cursor-client-arch': process.arch === 'arm64' ? 'aarch64' : 'x64',
'x-cursor-client-device-type': 'desktop',
'x-cursor-config-version': crypto.randomUUID(),
'x-cursor-timezone': Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
'x-ghost-mode': ghostMode ? 'true' : 'false',
'x-request-id': crypto.randomUUID(),
'x-session-id': crypto.createHash('sha256').update(cleanToken).digest('hex').substring(0, 36),
};
} }
transformRequest( transformRequest(
@@ -589,6 +511,15 @@ export class CursorExecutor {
emitSSE(buildChunk(delta, null)); emitSSE(buildChunk(delta, null));
chunkCount++; chunkCount++;
} }
if (frame.type === 'thinking') {
const delta =
chunkCount === 0 && toolCallCount === 0
? { role: 'assistant', reasoning_content: frame.text }
: { reasoning_content: frame.text };
emitSSE(buildChunk(delta, null));
chunkCount++;
}
} }
}); });
@@ -659,6 +590,7 @@ export class CursorExecutor {
private *parseProtobufFrames(buffer: Buffer): Generator< private *parseProtobufFrames(buffer: Buffer): Generator<
| { type: 'error'; response: Response } | { type: 'error'; response: Response }
| { type: 'text'; text: string } | { type: 'text'; text: string }
| { type: 'thinking'; text: string }
| { | {
type: 'toolCall'; type: 'toolCall';
toolCall: { toolCall: {
@@ -732,6 +664,10 @@ export class CursorExecutor {
if (result.text) { if (result.text) {
yield { type: 'text', text: result.text }; yield { type: 'text', text: result.text };
} }
if (result.thinking) {
yield { type: 'thinking', text: result.thinking };
}
} }
} }
@@ -740,6 +676,7 @@ export class CursorExecutor {
const created = Math.floor(Date.now() / 1000); const created = Math.floor(Date.now() / 1000);
let totalContent = ''; let totalContent = '';
let totalReasoning = '';
const toolCalls: Array<{ const toolCalls: Array<{
id: string; id: string;
type: string; type: string;
@@ -793,6 +730,10 @@ export class CursorExecutor {
if (frame.type === 'text') { if (frame.type === 'text') {
totalContent += frame.text; totalContent += frame.text;
} }
if (frame.type === 'thinking') {
totalReasoning += frame.text;
}
} }
// Finalize remaining tool calls // Finalize remaining tool calls
@@ -814,6 +755,7 @@ export class CursorExecutor {
const message: { const message: {
role: string; role: string;
content: string | null; content: string | null;
reasoning_content?: string | null;
tool_calls?: Array<{ tool_calls?: Array<{
id: string; id: string;
type: string; type: string;
@@ -828,6 +770,10 @@ export class CursorExecutor {
message.tool_calls = toolCalls; message.tool_calls = toolCalls;
} }
if (totalReasoning) {
message.reasoning_content = totalReasoning;
}
const completion = { const completion = {
id: responseId, id: responseId,
object: 'chat.completion', object: 'chat.completion',
@@ -992,6 +938,27 @@ export class CursorExecutor {
})}\n\n` })}\n\n`
); );
} }
if (frame.type === 'thinking') {
chunks.push(
`data: ${JSON.stringify({
id: responseId,
object: 'chat.completion.chunk',
created,
model,
choices: [
{
index: 0,
delta:
chunks.length === 0 && toolCalls.length === 0
? { role: 'assistant', reasoning_content: frame.text }
: { reasoning_content: frame.text },
finish_reason: null,
},
],
})}\n\n`
);
}
} }
if (chunks.length === 0 && toolCalls.length === 0) { if (chunks.length === 0 && toolCalls.length === 0) {
+138 -165
View File
@@ -2,156 +2,158 @@
* Cursor Model Catalog * Cursor Model Catalog
* *
* Manages available models from Cursor IDE. * Manages available models from Cursor IDE.
* Based on Cursor's supported models catalog.
*/ */
import * as http from 'http'; import * as http from 'http';
import type { CursorModel } from './types'; import type { CursorModel } from './types';
import type { CursorCredentials } from './cursor-protobuf-schema';
import { isDaemonRunning } from './cursor-daemon'; import { isDaemonRunning } from './cursor-daemon';
import { buildCursorModelsHeaders } from './cursor-client-policy';
import {
DEFAULT_CURSOR_MODEL,
DEFAULT_CURSOR_MODELS,
detectProvider,
formatModelName,
} from './cursor-default-models';
/** Default daemon port */ /** Default daemon port */
export const DEFAULT_CURSOR_PORT = 20129; export const DEFAULT_CURSOR_PORT = 20129;
/** Default model ID */ export { DEFAULT_CURSOR_MODEL, DEFAULT_CURSOR_MODELS, detectProvider, formatModelName };
export const DEFAULT_CURSOR_MODEL = 'gpt-5.3-codex';
/** const CURSOR_MODELS_API_ENDPOINT = 'https://api2.cursor.sh/v1/models';
* Default models available through Cursor IDE. const CURSOR_MODELS_CACHE_TTL_MS = 5 * 60 * 1000;
* Used as fallback when daemon is not reachable.
* Source: Cursor docs model catalog (Feb 2026)
*/
export const DEFAULT_CURSOR_MODELS: CursorModel[] = [
// Anthropic Models
{
id: 'claude-4.6-opus',
name: 'Claude 4.6 Opus',
provider: 'anthropic',
},
{
id: 'claude-4.6-opus-fast-mode',
name: 'Claude 4.6 Opus (Fast mode)',
provider: 'anthropic',
},
{
id: 'claude-4.5-sonnet',
name: 'Claude 4.5 Sonnet',
provider: 'anthropic',
},
{
id: 'claude-4.5-opus',
name: 'Claude 4.5 Opus',
provider: 'anthropic',
},
{
id: 'claude-4.5-haiku',
name: 'Claude 4.5 Haiku',
provider: 'anthropic',
},
{
id: 'claude-4-sonnet',
name: 'Claude 4 Sonnet',
provider: 'anthropic',
},
{
id: 'claude-4-sonnet-1m',
name: 'Claude 4 Sonnet 1M',
provider: 'anthropic',
},
// Cursor Models let liveModelsCache: {
{ models: CursorModel[];
id: 'composer-1.5', expiresAtMs: number;
name: 'Composer 1.5', } | null = null;
provider: 'cursor',
},
{
id: 'composer-1',
name: 'Composer 1',
provider: 'cursor',
},
// OpenAI Models interface CursorModelsApiResponse {
{ data?: Array<{ id?: unknown; name?: unknown; provider?: unknown }>;
id: 'gpt-5.3-codex', models?: Array<{ id?: unknown; name?: unknown; provider?: unknown }>;
name: 'GPT-5.3 Codex', }
provider: 'openai',
isDefault: true,
},
{
id: 'gpt-5.2-codex',
name: 'GPT-5.2 Codex',
provider: 'openai',
},
{
id: 'gpt-5.2',
name: 'GPT-5.2',
provider: 'openai',
},
{
id: 'gpt-5.1-codex',
name: 'GPT-5.1 Codex',
provider: 'openai',
},
{
id: 'gpt-5.1-codex-max',
name: 'GPT-5.1 Codex Max',
provider: 'openai',
},
{
id: 'gpt-5.1-codex-mini',
name: 'GPT-5.1 Codex Mini',
provider: 'openai',
},
{
id: 'gpt-5-codex',
name: 'GPT-5-Codex',
provider: 'openai',
},
{
id: 'gpt-5',
name: 'GPT-5',
provider: 'openai',
},
{
id: 'gpt-5-fast',
name: 'GPT-5 Fast',
provider: 'openai',
},
{
id: 'gpt-5-mini',
name: 'GPT-5 Mini',
provider: 'openai',
},
// Google Models function normalizeModelRecords(
{ records: Array<{ id?: unknown; name?: unknown; provider?: unknown }>
id: 'gemini-3-pro', ): CursorModel[] {
name: 'Gemini 3 Pro', const models: CursorModel[] = [];
provider: 'google', for (const record of records) {
}, if (!record || typeof record !== 'object') continue;
{ if (typeof record.id !== 'string' || !record.id) continue;
id: 'gemini-3-pro-image-preview', const modelId = record.id;
name: 'Gemini 3 Pro Image Preview', const modelName = typeof record.name === 'string' && record.name ? record.name : modelId;
provider: 'google', const provider =
}, typeof record.provider === 'string' && record.provider
{ ? record.provider
id: 'gemini-3-flash', : detectProvider(modelId);
name: 'Gemini 3 Flash', models.push({
provider: 'google', id: modelId,
}, name: modelName,
{ provider,
id: 'gemini-2.5-flash', isDefault: modelId === DEFAULT_CURSOR_MODEL,
name: 'Gemini 2.5 Flash', });
provider: 'google', }
}, return models;
}
// xAI Models function parseApiModelsResponse(payload: unknown): CursorModel[] | null {
{ if (!payload || typeof payload !== 'object') return null;
id: 'grok-code', const response = payload as CursorModelsApiResponse;
name: 'Grok Code', const records = Array.isArray(response.data)
provider: 'xai', ? response.data
}, : Array.isArray(response.models)
]; ? response.models
: null;
if (!records) return null;
const models = normalizeModelRecords(records);
return models.length > 0 ? models : null;
}
function getCachedLiveModels(nowMs: number = Date.now()): CursorModel[] | null {
if (!liveModelsCache) return null;
if (liveModelsCache.expiresAtMs <= nowMs) {
liveModelsCache = null;
return null;
}
return liveModelsCache.models;
}
function setCachedLiveModels(models: CursorModel[], nowMs: number = Date.now()): void {
liveModelsCache = {
models,
expiresAtMs: nowMs + CURSOR_MODELS_CACHE_TTL_MS,
};
}
export function clearCursorModelsCache(): void {
liveModelsCache = null;
}
export async function fetchModelsFromCursorApi(
credentials: CursorCredentials,
options: {
endpoint?: string;
timeoutMs?: number;
} = {}
): Promise<CursorModel[] | null> {
if (!credentials.accessToken || !credentials.machineId) {
return null;
}
const endpoint = options.endpoint || CURSOR_MODELS_API_ENDPOINT;
const timeoutMs = options.timeoutMs ?? 5000;
const abortController = new AbortController();
const timeout = setTimeout(() => abortController.abort(), timeoutMs);
try {
const response = await fetch(endpoint, {
method: 'GET',
headers: buildCursorModelsHeaders(credentials),
signal: abortController.signal,
});
if (!response.ok) {
return null;
}
const payload = (await response.json()) as unknown;
return parseApiModelsResponse(payload);
} catch {
return null;
} finally {
clearTimeout(timeout);
}
}
export async function getModelsForDaemon(
options: {
credentials?: CursorCredentials | null;
endpoint?: string;
timeoutMs?: number;
} = {}
): Promise<CursorModel[]> {
const cached = getCachedLiveModels();
if (cached) {
return cached;
}
const credentials = options.credentials;
if (credentials?.accessToken && credentials.machineId) {
const liveModels = await fetchModelsFromCursorApi(credentials, {
endpoint: options.endpoint,
timeoutMs: options.timeoutMs,
});
if (liveModels && liveModels.length > 0) {
setCachedLiveModels(liveModels);
return liveModels;
}
}
return DEFAULT_CURSOR_MODELS;
}
/** /**
* Fetch available models from running cursor daemon. * Fetch available models from running cursor daemon.
@@ -241,32 +243,3 @@ export async function getAvailableModels(port: number): Promise<CursorModel[]> {
export function getDefaultModel(): string { export function getDefaultModel(): string {
return DEFAULT_CURSOR_MODEL; return DEFAULT_CURSOR_MODEL;
} }
/**
* Detect provider from model ID.
*/
export function detectProvider(modelId: string): string {
if (modelId.includes('claude')) return 'anthropic';
if (modelId.includes('gpt') || /^o[1-9]\d*(-|$)/.test(modelId)) return 'openai';
if (modelId.includes('gemini')) return 'google';
if (modelId.includes('cursor') || modelId.includes('composer')) return 'cursor';
if (modelId.includes('grok')) return 'xai';
return 'unknown';
}
/**
* Format model ID to human-readable name.
*/
export function formatModelName(modelId: string): string {
// Find model in catalog for metadata
const model = DEFAULT_CURSOR_MODELS.find((m) => m.id === modelId);
if (model) {
return model.name;
}
// Fallback: convert kebab-case to title case
return modelId
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
+3
View File
@@ -34,8 +34,11 @@ export {
DEFAULT_CURSOR_PORT, DEFAULT_CURSOR_PORT,
DEFAULT_CURSOR_MODEL, DEFAULT_CURSOR_MODEL,
fetchModelsFromDaemon, fetchModelsFromDaemon,
fetchModelsFromCursorApi,
getModelsForDaemon,
getAvailableModels, getAvailableModels,
getDefaultModel, getDefaultModel,
clearCursorModelsCache,
detectProvider, detectProvider,
formatModelName, formatModelName,
} from './cursor-models'; } from './cursor-models';
+7
View File
@@ -153,6 +153,13 @@ describe('startDaemon', () => {
const running = await isDaemonRunning(port); const running = await isDaemonRunning(port);
expect(running).toBe(true); expect(running).toBe(true);
// Verify models endpoint exists and is OpenAI-compatible list shape
const modelsResponse = await fetch(`http://127.0.0.1:${port}/v1/models`);
expect(modelsResponse.status).toBe(200);
const modelsJson = (await modelsResponse.json()) as { object?: string; data?: unknown[] };
expect(modelsJson.object).toBe('list');
expect(Array.isArray(modelsJson.data)).toBe(true);
// Verify chat endpoint exists (requires auth, should not be 404) // Verify chat endpoint exists (requires auth, should not be 404)
const chatResponse = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { const chatResponse = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
method: 'POST', method: 'POST',
+129 -1
View File
@@ -2,7 +2,8 @@
* Unit tests for Cursor models module * Unit tests for Cursor models module
*/ */
import { describe, it, expect } from 'bun:test'; import { describe, it, expect, beforeEach } from 'bun:test';
import * as http from 'http';
import { import {
DEFAULT_CURSOR_MODELS, DEFAULT_CURSOR_MODELS,
DEFAULT_CURSOR_PORT, DEFAULT_CURSOR_PORT,
@@ -11,6 +12,9 @@ import {
detectProvider, detectProvider,
formatModelName, formatModelName,
fetchModelsFromDaemon, fetchModelsFromDaemon,
fetchModelsFromCursorApi,
getModelsForDaemon,
clearCursorModelsCache,
} from '../../../src/cursor/cursor-models'; } from '../../../src/cursor/cursor-models';
describe('DEFAULT_CURSOR_MODELS', () => { describe('DEFAULT_CURSOR_MODELS', () => {
@@ -102,3 +106,127 @@ describe('fetchModelsFromDaemon', () => {
expect(models).toEqual(DEFAULT_CURSOR_MODELS); expect(models).toEqual(DEFAULT_CURSOR_MODELS);
}); });
}); });
describe('fetchModelsFromCursorApi', () => {
it('parses model list from API response', async () => {
const server = http.createServer((_req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
data: [
{ id: 'gpt-5.3-codex', name: 'GPT-5.3 Codex', provider: 'openai' },
{ id: 'claude-4.6-opus', name: 'Claude 4.6 Opus', provider: 'anthropic' },
],
})
);
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Unable to resolve test server port');
}
try {
const models = await fetchModelsFromCursorApi(
{
accessToken: 'test-token-123',
machineId: '1234567890abcdef1234567890abcdef',
},
{
endpoint: `http://127.0.0.1:${address.port}/v1/models`,
timeoutMs: 2000,
}
);
expect(models).not.toBeNull();
expect(models?.[0].id).toBe('gpt-5.3-codex');
expect(models?.[1].id).toBe('claude-4.6-opus');
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
it('returns null for non-200 responses', async () => {
const server = http.createServer((_req, res) => {
res.writeHead(403, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'forbidden' }));
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Unable to resolve test server port');
}
try {
const models = await fetchModelsFromCursorApi(
{
accessToken: 'test-token-123',
machineId: '1234567890abcdef1234567890abcdef',
},
{
endpoint: `http://127.0.0.1:${address.port}/v1/models`,
timeoutMs: 2000,
}
);
expect(models).toBeNull();
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
});
describe('getModelsForDaemon', () => {
beforeEach(() => {
clearCursorModelsCache();
});
it('falls back to defaults without credentials', async () => {
const models = await getModelsForDaemon();
expect(models).toEqual(DEFAULT_CURSOR_MODELS);
});
it('uses cached live models when endpoint becomes unavailable', async () => {
const liveModelId = 'test-live-model';
const server = http.createServer((_req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
data: [{ id: liveModelId, name: 'Live Model', provider: 'openai' }],
})
);
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Unable to resolve test server port');
}
const endpoint = `http://127.0.0.1:${address.port}/v1/models`;
try {
const first = await getModelsForDaemon({
credentials: {
accessToken: 'test-token-123',
machineId: '1234567890abcdef1234567890abcdef',
},
endpoint,
timeoutMs: 2000,
});
expect(first[0]?.id).toBe(liveModelId);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
const second = await getModelsForDaemon({
endpoint: 'http://127.0.0.1:9/v1/models',
timeoutMs: 250,
});
expect(second[0]?.id).toBe(liveModelId);
});
});
+61
View File
@@ -531,6 +531,28 @@ describe('CursorExecutor', () => {
const body = JSON.parse(bodyText); const body = JSON.parse(bodyText);
expect(body.error.type).toBe('rate_limit_error'); expect(body.error.type).toBe('rate_limit_error');
}); });
it('should surface reasoning_content when thinking payload is present', async () => {
const textContent = 'Final answer';
const thinkingContent = 'Internal reasoning trail';
const thinkingField = encodeField(FIELD.Thinking.TEXT, WIRE_TYPE.LEN, thinkingContent);
const chatResponse = concatArrays(
encodeField(FIELD.ChatResponse.TEXT, WIRE_TYPE.LEN, textContent),
encodeField(FIELD.ChatResponse.THINKING, WIRE_TYPE.LEN, thinkingField)
);
const responseMsg = encodeField(FIELD.Response.RESPONSE, WIRE_TYPE.LEN, chatResponse);
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].message.reasoning_content).toBe(thinkingContent);
});
}); });
describe('transformProtobufToSSE', () => { describe('transformProtobufToSSE', () => {
@@ -572,6 +594,23 @@ describe('CursorExecutor', () => {
const body = JSON.parse(bodyText); const body = JSON.parse(bodyText);
expect(body.error.type).toBe('rate_limit_error'); expect(body.error.type).toBe('rate_limit_error');
}); });
it('should emit reasoning_content deltas for thinking payloads', async () => {
const thinkingContent = 'Deliberate reasoning';
const thinkingField = encodeField(FIELD.Thinking.TEXT, WIRE_TYPE.LEN, thinkingContent);
const chatResponse = encodeField(FIELD.ChatResponse.THINKING, WIRE_TYPE.LEN, thinkingField);
const responseMsg = encodeField(FIELD.Response.RESPONSE, WIRE_TYPE.LEN, chatResponse);
const frame = wrapConnectRPCFrame(responseMsg, false);
const result = executor.transformProtobufToSSE(Buffer.from(frame), 'gpt-4', {
messages: [],
});
expect(result.status).toBe(200);
const bodyText = await result.text();
expect(bodyText).toContain('reasoning_content');
expect(bodyText).toContain(thinkingContent);
});
}); });
describe('decompressPayload error handling', () => { describe('decompressPayload error handling', () => {
@@ -676,6 +715,16 @@ function buildTextFrame(text: string): Buffer {
return buildFrame(responseMsg); return buildFrame(responseMsg);
} }
/**
* Helper: build a protobuf thinking response frame
*/
function buildThinkingFrame(thinking: string): Buffer {
const thinkingField = encodeField(FIELD.Thinking.TEXT, WIRE_TYPE.LEN, thinking);
const responseField = encodeField(FIELD.ChatResponse.THINKING, WIRE_TYPE.LEN, thinkingField);
const responseMsg = encodeField(FIELD.Response.RESPONSE, WIRE_TYPE.LEN, responseField);
return buildFrame(responseMsg);
}
describe('StreamingFrameParser', () => { describe('StreamingFrameParser', () => {
it('should parse a complete single frame', () => { it('should parse a complete single frame', () => {
const parser = new StreamingFrameParser(); const parser = new StreamingFrameParser();
@@ -784,6 +833,18 @@ describe('StreamingFrameParser', () => {
} }
}); });
it('should parse thinking frames', () => {
const parser = new StreamingFrameParser();
const frame = buildThinkingFrame('Think step by step');
const results = parser.push(frame);
expect(results.length).toBe(1);
expect(results[0].type).toBe('thinking');
if (results[0].type === 'thinking') {
expect(results[0].text).toBe('Think step by step');
}
});
it('should report hasPartial() correctly', () => { it('should report hasPartial() correctly', () => {
const parser = new StreamingFrameParser(); const parser = new StreamingFrameParser();
expect(parser.hasPartial()).toBe(false); expect(parser.hasPartial()).toBe(false);
+255 -3
View File
@@ -1,10 +1,148 @@
/** /**
* Cursor Routes Tests * Cursor Routes Tests
* Tests for daemon start precondition validation logic. * Endpoint contract tests without module-level mocks.
*/ */
import { describe, it, expect } from 'bun:test'; import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'bun:test';
import { getDaemonStartPreconditionError } from '../../../src/web-server/routes/cursor-routes'; import express from 'express';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import type { Server } from 'http';
let server: Server;
let baseUrl = '';
let tempDir = '';
let originalCcsHome: string | undefined;
let setGlobalConfigDir: (dir: string | undefined) => void;
let getCcsDir: () => string;
let loadOrCreateUnifiedConfig: () => {
cursor?: {
enabled?: boolean;
port?: number;
auto_start?: boolean;
ghost_mode?: boolean;
model?: string;
};
};
let saveUnifiedConfig: (config: {
cursor?: {
enabled?: boolean;
port?: number;
auto_start?: boolean;
ghost_mode?: boolean;
model?: string;
};
}) => void;
let saveCredentials: (credentials: {
accessToken: string;
machineId: string;
authMethod: 'manual' | 'auto-detect';
importedAt: string;
}) => void;
let deleteCredentials: () => boolean;
let checkAuthStatus: () => { authenticated: boolean; expired?: boolean };
let getDaemonStartPreconditionError: (
input: { enabled: boolean; authenticated: boolean; tokenExpired?: boolean }
) => { status: number; error: string } | null;
function seedCursorConfig(overrides: {
enabled?: boolean;
port?: number;
auto_start?: boolean;
ghost_mode?: boolean;
model?: string;
} = {}): void {
const config = loadOrCreateUnifiedConfig();
config.cursor = {
enabled: overrides.enabled ?? true,
port: overrides.port ?? 20129,
auto_start: overrides.auto_start ?? false,
ghost_mode: overrides.ghost_mode ?? true,
model: overrides.model ?? 'gpt-5.3-codex',
};
saveUnifiedConfig(config);
}
function seedCredentials(expired: boolean): void {
saveCredentials({
accessToken: 'a'.repeat(60),
machineId: '1234567890abcdef1234567890abcdef',
authMethod: 'manual',
importedAt: expired
? new Date(Date.now() - 26 * 60 * 60 * 1000).toISOString()
: new Date().toISOString(),
});
}
beforeAll(async () => {
originalCcsHome = process.env.CCS_HOME;
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cursor-routes-test-'));
process.env.CCS_HOME = tempDir;
const configManager = await import('../../../src/utils/config-manager');
setGlobalConfigDir = configManager.setGlobalConfigDir;
getCcsDir = configManager.getCcsDir;
setGlobalConfigDir(undefined);
const unifiedConfig = await import('../../../src/config/unified-config-loader');
loadOrCreateUnifiedConfig = unifiedConfig.loadOrCreateUnifiedConfig;
saveUnifiedConfig = unifiedConfig.saveUnifiedConfig;
const cursorAuth = await import('../../../src/cursor/cursor-auth');
saveCredentials = cursorAuth.saveCredentials;
deleteCredentials = cursorAuth.deleteCredentials;
checkAuthStatus = cursorAuth.checkAuthStatus;
const cursorRoutesModule = await import('../../../src/web-server/routes/cursor-routes');
getDaemonStartPreconditionError = cursorRoutesModule.getDaemonStartPreconditionError;
const app = express();
app.use(express.json());
app.use('/api/cursor', cursorRoutesModule.default);
server = app.listen(0, '127.0.0.1');
await new Promise<void>((resolve) => server.on('listening', () => resolve()));
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Unable to resolve test server port');
}
baseUrl = `http://127.0.0.1:${address.port}`;
});
beforeEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
fs.mkdirSync(tempDir, { recursive: true });
setGlobalConfigDir(undefined);
const ccsDir = getCcsDir();
if (!fs.existsSync(ccsDir)) {
fs.mkdirSync(ccsDir, { recursive: true });
}
seedCursorConfig();
// Ensure clean auth state for each test.
deleteCredentials();
});
afterAll(async () => {
if (server) {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
setGlobalConfigDir(undefined);
if (tempDir && fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
describe('Cursor Routes Logic', () => { describe('Cursor Routes Logic', () => {
describe('POST /daemon/start preconditions', () => { describe('POST /daemon/start preconditions', () => {
@@ -57,4 +195,118 @@ describe('Cursor Routes Logic', () => {
expect(result).toBeNull(); expect(result).toBeNull();
}); });
}); });
describe('HTTP contracts', () => {
it('GET /api/cursor/status returns current state', async () => {
const res = await fetch(`${baseUrl}/api/cursor/status`);
expect(res.status).toBe(200);
const json = (await res.json()) as {
enabled: boolean;
authenticated: boolean;
token_expired: boolean;
daemon_running: boolean;
port: number;
};
expect(json.enabled).toBe(true);
expect(json.authenticated).toBe(false);
expect(json.token_expired).toBe(false);
expect(json.daemon_running).toBe(false);
expect(json.port).toBe(20129);
});
it('POST /api/cursor/auth/import validates required fields', async () => {
const res = await fetch(`${baseUrl}/api/cursor/auth/import`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accessToken: 'only-token' }),
});
expect(res.status).toBe(400);
const json = (await res.json()) as { error?: string };
expect(json.error).toContain('Missing accessToken or machineId');
});
it('POST /api/cursor/auth/import rejects invalid token format', async () => {
const res = await fetch(`${baseUrl}/api/cursor/auth/import`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
accessToken: 'short',
machineId: 'bad',
}),
});
expect(res.status).toBe(400);
const json = (await res.json()) as { error?: string };
expect(json.error).toContain('Invalid token or machine ID format');
expect(checkAuthStatus().authenticated).toBe(false);
});
it('POST /api/cursor/auth/import persists valid credentials', async () => {
const res = await fetch(`${baseUrl}/api/cursor/auth/import`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
accessToken: 'a'.repeat(60),
machineId: '1234567890abcdef1234567890abcdef',
}),
});
expect(res.status).toBe(200);
expect(checkAuthStatus().authenticated).toBe(true);
});
it('POST /api/cursor/auth/auto-detect returns 404 when no token source found', async () => {
const res = await fetch(`${baseUrl}/api/cursor/auth/auto-detect`, {
method: 'POST',
});
expect(res.status).toBe(404);
const json = (await res.json()) as { error?: string };
expect(typeof json.error).toBe('string');
expect(json.error?.length).toBeGreaterThan(0);
});
it('POST /api/cursor/daemon/start returns 400 when integration is disabled', async () => {
seedCursorConfig({ enabled: false });
seedCredentials(false);
const res = await fetch(`${baseUrl}/api/cursor/daemon/start`, { method: 'POST' });
expect(res.status).toBe(400);
const json = (await res.json()) as { success?: boolean; error?: string };
expect(json.success).toBe(false);
expect(json.error).toContain('disabled');
});
it('POST /api/cursor/daemon/start returns 401 when unauthenticated', async () => {
seedCursorConfig({ enabled: true });
const res = await fetch(`${baseUrl}/api/cursor/daemon/start`, { method: 'POST' });
expect(res.status).toBe(401);
const json = (await res.json()) as { error?: string };
expect(json.error).toContain('authentication required');
});
it('POST /api/cursor/daemon/start returns 401 when token is expired', async () => {
seedCursorConfig({ enabled: true });
seedCredentials(true);
const res = await fetch(`${baseUrl}/api/cursor/daemon/start`, { method: 'POST' });
expect(res.status).toBe(401);
const json = (await res.json()) as { error?: string };
expect(json.error).toContain('expired');
});
it('GET /api/cursor/models returns current model and list payload', async () => {
const res = await fetch(`${baseUrl}/api/cursor/models`);
expect(res.status).toBe(200);
const json = (await res.json()) as { models: Array<{ id: string }>; current: string };
expect(Array.isArray(json.models)).toBe(true);
expect(json.models.length).toBeGreaterThan(0);
expect(json.current).toBe('gpt-5.3-codex');
});
});
}); });