From c639cefa7bd33807e96d96fd566c64a89166f22f Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 17 Feb 2026 04:09:06 +0700 Subject: [PATCH] feat(cursor): harden daemon integration and model discovery --- docs/cursor-integration.md | 1 + src/cursor/cursor-client-policy.ts | 124 ++++++++ src/cursor/cursor-daemon-entry.ts | 16 +- src/cursor/cursor-default-models.ts | 173 +++++++++++ src/cursor/cursor-executor.ts | 129 ++++----- src/cursor/cursor-models.ts | 303 +++++++++----------- src/cursor/index.ts | 3 + tests/unit/cursor/cursor-daemon.test.ts | 7 + tests/unit/cursor/cursor-models.test.ts | 130 ++++++++- tests/unit/cursor/cursor-protobuf.test.ts | 61 ++++ tests/unit/web-server/cursor-routes.test.ts | 258 ++++++++++++++++- 11 files changed, 953 insertions(+), 252 deletions(-) create mode 100644 src/cursor/cursor-client-policy.ts create mode 100644 src/cursor/cursor-default-models.ts diff --git a/docs/cursor-integration.md b/docs/cursor-integration.md index 6997dfd3..fad1eabd 100644 --- a/docs/cursor-integration.md +++ b/docs/cursor-integration.md @@ -59,6 +59,7 @@ ccs cursor stop - Default port: `20129` - `ghost_mode`: enabled - `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. diff --git a/src/cursor/cursor-client-policy.ts b/src/cursor/cursor-client-policy.ts new file mode 100644 index 00000000..157b46d2 --- /dev/null +++ b/src/cursor/cursor-client-policy.ts @@ -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 { + 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 { + 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 { + return { + ...buildCursorBaseHeaders(credentials), + accept: 'application/json', + 'user-agent': CURSOR_USER_AGENT, + }; +} diff --git a/src/cursor/cursor-daemon-entry.ts b/src/cursor/cursor-daemon-entry.ts index 2fcf0afd..841de56d 100644 --- a/src/cursor/cursor-daemon-entry.ts +++ b/src/cursor/cursor-daemon-entry.ts @@ -8,7 +8,7 @@ import * as http from 'http'; import { Readable } from 'stream'; import { CursorExecutor } from './cursor-executor'; 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'; interface DaemonRuntimeOptions { @@ -184,7 +184,19 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser } 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, object: 'model', created: 0, diff --git a/src/cursor/cursor-default-models.ts b/src/cursor/cursor-default-models.ts new file mode 100644 index 00000000..1457de05 --- /dev/null +++ b/src/cursor/cursor-default-models.ts @@ -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(' '); +} diff --git a/src/cursor/cursor-executor.ts b/src/cursor/cursor-executor.ts index 43ec4d5e..1aa7e48a 100644 --- a/src/cursor/cursor-executor.ts +++ b/src/cursor/cursor-executor.ts @@ -3,11 +3,11 @@ * Handles HTTP/2 requests to Cursor API with protobuf encoding/decoding */ -import * as crypto from 'crypto'; import type { IncomingHttpHeaders } from 'http'; import { generateCursorBody, extractTextFromResponse } from './cursor-protobuf.js'; import { buildCursorRequest } from './cursor-translator.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'; @@ -92,8 +92,6 @@ function createErrorResponse(jsonError: { export class CursorExecutor { private readonly baseUrl = 'https://api2.cursor.sh'; private readonly chatPath = '/aiserver.v1.AiService/StreamChat'; - private readonly CURSOR_CLIENT_VERSION = '2.3.41'; - private readonly CURSOR_USER_AGENT = 'connect-es/1.6.1'; buildUrl(): string { return `${this.baseUrl}${this.chatPath}`; @@ -103,87 +101,11 @@ export class CursorExecutor { * Generate checksum using Jyh cipher (time-based XOR with rolling key seed=165) */ generateChecksum(machineId: string): string { - const timestamp = Math.floor(Date.now() / 1000000); - // JS bitwise shifts wrap modulo 32, so >>40 and >>32 give wrong results. - // Use Math.trunc division for upper bytes that exceed 32-bit range. - const byteArray = new Uint8Array([ - 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}`; + return generateCursorChecksum(machineId); } buildHeaders(credentials: CursorCredentials): Record { - const accessToken = credentials.accessToken; - 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), - }; + return buildCursorConnectHeaders(credentials); } transformRequest( @@ -589,6 +511,15 @@ export class CursorExecutor { emitSSE(buildChunk(delta, null)); 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< | { type: 'error'; response: Response } | { type: 'text'; text: string } + | { type: 'thinking'; text: string } | { type: 'toolCall'; toolCall: { @@ -732,6 +664,10 @@ export class CursorExecutor { if (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); let totalContent = ''; + let totalReasoning = ''; const toolCalls: Array<{ id: string; type: string; @@ -793,6 +730,10 @@ export class CursorExecutor { if (frame.type === 'text') { totalContent += frame.text; } + + if (frame.type === 'thinking') { + totalReasoning += frame.text; + } } // Finalize remaining tool calls @@ -814,6 +755,7 @@ export class CursorExecutor { const message: { role: string; content: string | null; + reasoning_content?: string | null; tool_calls?: Array<{ id: string; type: string; @@ -828,6 +770,10 @@ export class CursorExecutor { message.tool_calls = toolCalls; } + if (totalReasoning) { + message.reasoning_content = totalReasoning; + } + const completion = { id: responseId, object: 'chat.completion', @@ -992,6 +938,27 @@ export class CursorExecutor { })}\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) { diff --git a/src/cursor/cursor-models.ts b/src/cursor/cursor-models.ts index c1355fa0..460c0a83 100644 --- a/src/cursor/cursor-models.ts +++ b/src/cursor/cursor-models.ts @@ -2,156 +2,158 @@ * Cursor Model Catalog * * Manages available models from Cursor IDE. - * Based on Cursor's supported models catalog. */ import * as http from 'http'; import type { CursorModel } from './types'; +import type { CursorCredentials } from './cursor-protobuf-schema'; 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 */ export const DEFAULT_CURSOR_PORT = 20129; -/** Default model ID */ -export const DEFAULT_CURSOR_MODEL = 'gpt-5.3-codex'; +export { DEFAULT_CURSOR_MODEL, DEFAULT_CURSOR_MODELS, detectProvider, formatModelName }; -/** - * 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', - }, +const CURSOR_MODELS_API_ENDPOINT = 'https://api2.cursor.sh/v1/models'; +const CURSOR_MODELS_CACHE_TTL_MS = 5 * 60 * 1000; - // Cursor Models - { - id: 'composer-1.5', - name: 'Composer 1.5', - provider: 'cursor', - }, - { - id: 'composer-1', - name: 'Composer 1', - provider: 'cursor', - }, +let liveModelsCache: { + models: CursorModel[]; + expiresAtMs: number; +} | null = null; - // 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', - }, +interface CursorModelsApiResponse { + data?: Array<{ id?: unknown; name?: unknown; provider?: unknown }>; + models?: Array<{ id?: unknown; name?: unknown; provider?: unknown }>; +} - // 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', - }, +function normalizeModelRecords( + records: Array<{ id?: unknown; name?: unknown; provider?: unknown }> +): CursorModel[] { + const models: CursorModel[] = []; + for (const record of records) { + if (!record || typeof record !== 'object') continue; + if (typeof record.id !== 'string' || !record.id) continue; + const modelId = record.id; + const modelName = typeof record.name === 'string' && record.name ? record.name : modelId; + const provider = + typeof record.provider === 'string' && record.provider + ? record.provider + : detectProvider(modelId); + models.push({ + id: modelId, + name: modelName, + provider, + isDefault: modelId === DEFAULT_CURSOR_MODEL, + }); + } + return models; +} - // xAI Models - { - id: 'grok-code', - name: 'Grok Code', - provider: 'xai', - }, -]; +function parseApiModelsResponse(payload: unknown): CursorModel[] | null { + if (!payload || typeof payload !== 'object') return null; + const response = payload as CursorModelsApiResponse; + const records = Array.isArray(response.data) + ? 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 { + 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 { + 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. @@ -241,32 +243,3 @@ export async function getAvailableModels(port: number): Promise { export function getDefaultModel(): string { 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(' '); -} diff --git a/src/cursor/index.ts b/src/cursor/index.ts index 59fd6d74..0322083c 100644 --- a/src/cursor/index.ts +++ b/src/cursor/index.ts @@ -34,8 +34,11 @@ export { DEFAULT_CURSOR_PORT, DEFAULT_CURSOR_MODEL, fetchModelsFromDaemon, + fetchModelsFromCursorApi, + getModelsForDaemon, getAvailableModels, getDefaultModel, + clearCursorModelsCache, detectProvider, formatModelName, } from './cursor-models'; diff --git a/tests/unit/cursor/cursor-daemon.test.ts b/tests/unit/cursor/cursor-daemon.test.ts index b46fa317..bccd17c3 100644 --- a/tests/unit/cursor/cursor-daemon.test.ts +++ b/tests/unit/cursor/cursor-daemon.test.ts @@ -153,6 +153,13 @@ describe('startDaemon', () => { const running = await isDaemonRunning(port); 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) const chatResponse = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { method: 'POST', diff --git a/tests/unit/cursor/cursor-models.test.ts b/tests/unit/cursor/cursor-models.test.ts index e5127f02..3221e7f8 100644 --- a/tests/unit/cursor/cursor-models.test.ts +++ b/tests/unit/cursor/cursor-models.test.ts @@ -2,7 +2,8 @@ * 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 { DEFAULT_CURSOR_MODELS, DEFAULT_CURSOR_PORT, @@ -11,6 +12,9 @@ import { detectProvider, formatModelName, fetchModelsFromDaemon, + fetchModelsFromCursorApi, + getModelsForDaemon, + clearCursorModelsCache, } from '../../../src/cursor/cursor-models'; describe('DEFAULT_CURSOR_MODELS', () => { @@ -102,3 +106,127 @@ describe('fetchModelsFromDaemon', () => { 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((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((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((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((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((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((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); + }); +}); diff --git a/tests/unit/cursor/cursor-protobuf.test.ts b/tests/unit/cursor/cursor-protobuf.test.ts index 0ea16a7c..a4a699e1 100644 --- a/tests/unit/cursor/cursor-protobuf.test.ts +++ b/tests/unit/cursor/cursor-protobuf.test.ts @@ -531,6 +531,28 @@ describe('CursorExecutor', () => { const body = JSON.parse(bodyText); 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', () => { @@ -572,6 +594,23 @@ describe('CursorExecutor', () => { const body = JSON.parse(bodyText); 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', () => { @@ -676,6 +715,16 @@ function buildTextFrame(text: string): Buffer { 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', () => { it('should parse a complete single frame', () => { 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', () => { const parser = new StreamingFrameParser(); expect(parser.hasPartial()).toBe(false); diff --git a/tests/unit/web-server/cursor-routes.test.ts b/tests/unit/web-server/cursor-routes.test.ts index a54bd2c6..1bb76640 100644 --- a/tests/unit/web-server/cursor-routes.test.ts +++ b/tests/unit/web-server/cursor-routes.test.ts @@ -1,10 +1,148 @@ /** * 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 { getDaemonStartPreconditionError } from '../../../src/web-server/routes/cursor-routes'; +import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'bun:test'; +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((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((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('POST /daemon/start preconditions', () => { @@ -57,4 +195,118 @@ describe('Cursor Routes Logic', () => { 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'); + }); + }); });