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-auth.ts b/src/cursor/cursor-auth.ts index 7440e983..924077bd 100644 --- a/src/cursor/cursor-auth.ts +++ b/src/cursor/cursor-auth.ts @@ -21,12 +21,24 @@ import * as os from 'os'; import type { CursorCredentials, CursorAuthStatus, AutoDetectResult } from './types'; import { getCcsDir } from '../utils/config-manager'; +/** + * Resolve home directory from environment first for deterministic testability, + * then fall back to os.homedir() when env vars are unavailable. + */ +function resolveHomeDir(): string { + if (process.platform === 'win32') { + return process.env.USERPROFILE || process.env.HOME || os.homedir(); + } + + return process.env.HOME || os.homedir(); +} + /** * Get platform-specific path to Cursor's state.vscdb */ export function getTokenStoragePath(): string { const platform = process.platform; - const home = os.homedir(); + const home = resolveHomeDir(); if (platform === 'win32') { const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming'); diff --git a/src/cursor/cursor-client-policy.ts b/src/cursor/cursor-client-policy.ts new file mode 100644 index 00000000..e5a5fe75 --- /dev/null +++ b/src/cursor/cursor-client-policy.ts @@ -0,0 +1,129 @@ +/** + * Cursor Client Policy + * + * Single source of truth for Cursor request identity headers and checksum generation. + */ + +import * as crypto from 'crypto'; +import type { CursorApiCredentials } 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'); + } + + // Convert milliseconds to coarse ~1000-second units required by Cursor's checksum routine. + 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: CursorApiCredentials): 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: CursorApiCredentials +): 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: CursorApiCredentials +): 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..355cedfc 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 { @@ -59,12 +59,26 @@ function readJsonBody(req: http.IncomingMessage): Promise { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; let total = 0; + let settled = false; + + const resolveOnce = (payload: unknown) => { + if (settled) return; + settled = true; + resolve(payload); + }; + + const rejectOnce = (error: Error) => { + if (settled) return; + settled = true; + reject(error); + }; req.on('data', (chunk: Buffer) => { total += chunk.length; if (total > MAX_BODY_SIZE) { - req.destroy(); - reject(new Error('Request body too large (max 10MB)')); + // Stop processing body, but avoid force-closing socket so caller can return 413 cleanly. + req.pause(); + rejectOnce(new Error('Request body too large (max 10MB)')); return; } chunks.push(chunk); @@ -73,17 +87,19 @@ function readJsonBody(req: http.IncomingMessage): Promise { req.on('end', () => { const raw = Buffer.concat(chunks).toString('utf8').trim(); if (!raw) { - resolve({}); + resolveOnce({}); return; } try { - resolve(JSON.parse(raw)); + resolveOnce(JSON.parse(raw)); } catch { - reject(new Error('Invalid JSON in request body')); + rejectOnce(new Error('Invalid JSON in request body')); } }); - req.on('error', reject); + req.on('error', (error) => { + rejectOnce(error instanceof Error ? error : new Error(String(error))); + }); }); } @@ -184,7 +200,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, @@ -229,11 +257,15 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser } const abortController = new AbortController(); - req.on('close', () => { - if (!res.writableEnded) { + const abortOnDisconnect = () => { + if (!abortController.signal.aborted && !res.writableEnded) { abortController.abort(); } - }); + }; + + req.on('aborted', abortOnDisconnect); + req.on('close', abortOnDisconnect); + res.on('close', abortOnDisconnect); const result = await executor.execute({ model, @@ -257,7 +289,8 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser await pipeWebResponseToNode(result.response, res); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; - writeJson(res, 400, { + const isPayloadTooLarge = message.includes('Request body too large'); + writeJson(res, isPayloadTooLarge ? 413 : 400, { error: { type: 'invalid_request_error', message, 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..7f46ec42 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 type { CursorTool, CursorApiCredentials } from './cursor-protobuf-schema.js'; +import { buildCursorConnectHeaders, generateCursorChecksum } from './cursor-client-policy.js'; import { StreamingFrameParser, decompressPayload } from './cursor-stream-parser.js'; @@ -30,7 +30,7 @@ interface ExecutorParams { reasoning_effort?: string; }; stream: boolean; - credentials: CursorCredentials; + credentials: CursorApiCredentials; signal?: AbortSignal; } @@ -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,94 +101,18 @@ 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), - }; + buildHeaders(credentials: CursorApiCredentials): Record { + return buildCursorConnectHeaders(credentials); } transformRequest( model: string, body: ExecutorParams['body'], stream: boolean, - credentials: CursorCredentials + credentials: CursorApiCredentials ): Uint8Array { const translatedBody = buildCursorRequest(model, body, stream, credentials); const messages = translatedBody.messages || []; @@ -398,12 +320,24 @@ export class CursorExecutor { const created = Math.floor(Date.now() / 1000); return new Promise((resolve, reject) => { + let settled = false; + const resolveOnce = (response: Response) => { + if (settled) return; + settled = true; + resolve(response); + }; + const rejectOnce = (error: Error) => { + if (settled) return; + settled = true; + reject(error); + }; + const urlObj = new URL(url); const client = http2.connect(`https://${urlObj.host}`); client.on('error', (err) => { client.close(); - reject(err); + rejectOnce(err instanceof Error ? err : new Error(String(err))); }); const req = client.request({ @@ -420,7 +354,8 @@ export class CursorExecutor { if (signal) { const onAbort = () => { streamClosed = true; - // Close the ReadableStream controller so consumers don't hang on reader.read() + + // If stream already started, close readable to unblock consumers. if (streamController) { try { streamController.close(); @@ -428,9 +363,12 @@ export class CursorExecutor { /* already closed */ } } + req.close(); client.close(); + rejectOnce(new Error('Request aborted')); }; + signal.addEventListener('abort', onAbort, { once: true }); const cleanup = () => signal.removeEventListener('abort', onAbort); req.on('end', cleanup); @@ -446,12 +384,12 @@ export class CursorExecutor { req.on('end', () => { client.close(); const errorText = Buffer.concat(errorChunks).toString(); - resolve( + resolveOnce( new Response( JSON.stringify({ error: { message: `[${status}]: ${errorText}`, - type: 'invalid_request_error', + type: status === 429 ? 'rate_limit_error' : 'invalid_request_error', code: '', }, }), @@ -589,6 +527,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++; + } } }); @@ -630,7 +577,7 @@ export class CursorExecutor { }, }); - resolve( + resolveOnce( new Response(readable, { status: 200, headers: { @@ -644,7 +591,7 @@ export class CursorExecutor { req.on('error', (err) => { client.close(); - reject(err); + rejectOnce(err instanceof Error ? err : new Error(String(err))); }); req.write(body); @@ -659,6 +606,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 +680,10 @@ export class CursorExecutor { if (result.text) { yield { type: 'text', text: result.text }; } + + if (result.thinking) { + yield { type: 'thinking', text: result.thinking }; + } } } @@ -740,6 +692,7 @@ export class CursorExecutor { const created = Math.floor(Date.now() / 1000); let totalContent = ''; + let totalReasoning = ''; const toolCalls: Array<{ id: string; type: string; @@ -793,6 +746,10 @@ export class CursorExecutor { if (frame.type === 'text') { totalContent += frame.text; } + + if (frame.type === 'thinking') { + totalReasoning += frame.text; + } } // Finalize remaining tool calls @@ -814,6 +771,7 @@ export class CursorExecutor { const message: { role: string; content: string | null; + reasoning_content?: string | null; tool_calls?: Array<{ id: string; type: string; @@ -828,6 +786,10 @@ export class CursorExecutor { message.tool_calls = toolCalls; } + if (totalReasoning) { + message.reasoning_content = totalReasoning; + } + const completion = { id: responseId, object: 'chat.completion', @@ -992,6 +954,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..12e7ad58 100644 --- a/src/cursor/cursor-models.ts +++ b/src/cursor/cursor-models.ts @@ -2,156 +2,176 @@ * 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 { CursorApiCredentials } 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 debugLog(message: string, error?: unknown): void { + if (!process.env.CCS_DEBUG) return; + if (error) { + console.error(`[cursor] ${message}`, error); + return; + } + console.error(`[cursor] ${message}`); +} - // xAI Models - { - id: 'grok-code', - name: 'Grok Code', - provider: 'xai', - }, -]; +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; +} + +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: CursorApiCredentials, + 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) { + if (response.status === 401 || response.status === 403) { + clearCursorModelsCache(); + } + debugLog(`Cursor models API returned ${response.status} (${endpoint})`); + return null; + } + + const payload = (await response.json()) as unknown; + const parsed = parseApiModelsResponse(payload); + if (!parsed) { + debugLog(`Cursor models API payload shape invalid (${endpoint})`); + } + return parsed; + } catch (error) { + debugLog(`Cursor models API fetch failed (${endpoint})`, error); + return null; + } finally { + clearTimeout(timeout); + } +} + +export async function getModelsForDaemon( + options: { + credentials?: CursorApiCredentials | 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. @@ -184,6 +204,7 @@ export async function fetchModelsFromDaemon(port: number): Promise { data += chunk; if (data.length > MAX_BODY_SIZE) { + debugLog('Cursor daemon /v1/models body exceeded 1MB; falling back to defaults'); req.destroy(); safeResolve(DEFAULT_CURSOR_MODELS); } @@ -191,30 +212,39 @@ export async function fetchModelsFromDaemon(port: number): Promise { try { - const response = JSON.parse(data) as { data?: Array<{ id: string }> }; - if (response.data && Array.isArray(response.data)) { - const models: CursorModel[] = response.data.map((m) => ({ - id: m.id, - name: formatModelName(m.id), - provider: detectProvider(m.id), - isDefault: m.id === DEFAULT_CURSOR_MODEL, - })); + const response = JSON.parse(data) as { data?: Array<{ id?: unknown }> }; + if (Array.isArray(response.data)) { + const models: CursorModel[] = response.data + .filter((m) => m && typeof m.id === 'string' && m.id.length > 0) + .map((m) => ({ + id: m.id as string, + name: formatModelName(m.id as string), + provider: detectProvider(m.id as string), + isDefault: m.id === DEFAULT_CURSOR_MODEL, + })); safeResolve(models.length > 0 ? models : DEFAULT_CURSOR_MODELS); } else { + debugLog('Cursor daemon /v1/models payload missing data[]; falling back to defaults'); safeResolve(DEFAULT_CURSOR_MODELS); } - } catch { + } catch (error) { + debugLog( + 'Cursor daemon /v1/models returned invalid JSON; falling back to defaults', + error + ); safeResolve(DEFAULT_CURSOR_MODELS); } }); } ); - req.on('error', () => { + req.on('error', (error) => { + debugLog('Cursor daemon /v1/models request failed; falling back to defaults', error); safeResolve(DEFAULT_CURSOR_MODELS); }); req.on('timeout', () => { + debugLog('Cursor daemon /v1/models request timed out; falling back to defaults'); req.destroy(); safeResolve(DEFAULT_CURSOR_MODELS); }); @@ -241,32 +271,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/cursor-protobuf-decoder.ts b/src/cursor/cursor-protobuf-decoder.ts index 1dde8dd6..a5b5fe8d 100644 --- a/src/cursor/cursor-protobuf-decoder.ts +++ b/src/cursor/cursor-protobuf-decoder.ts @@ -326,11 +326,15 @@ export function extractTextFromResponse(payload: Uint8Array): { } } + if (payload.length > 0) { + return { text: null, error: 'Malformed protobuf response', 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 }; + return { text: null, error: 'Malformed protobuf response', toolCall: null, thinking: null }; } } diff --git a/src/cursor/cursor-protobuf-schema.ts b/src/cursor/cursor-protobuf-schema.ts index 01a78fdf..296cfd49 100644 --- a/src/cursor/cursor-protobuf-schema.ts +++ b/src/cursor/cursor-protobuf-schema.ts @@ -135,7 +135,7 @@ export type UnifiedModeType = (typeof UNIFIED_MODE)[keyof typeof UNIFIED_MODE]; export type ThinkingLevelType = (typeof THINKING_LEVEL)[keyof typeof THINKING_LEVEL]; /** Cursor credentials structure */ -export interface CursorCredentials { +export interface CursorApiCredentials { accessToken: string; machineId: string; ghostMode?: boolean; 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-auth.test.ts b/tests/unit/cursor/cursor-auth.test.ts index 8743fbe6..b2aaa6bf 100644 --- a/tests/unit/cursor/cursor-auth.test.ts +++ b/tests/unit/cursor/cursor-auth.test.ts @@ -431,11 +431,23 @@ describe('autoDetectTokens', () => { return; } - const result = autoDetectTokens(); + const originalHome = process.env.HOME; + const isolatedHome = path.join(tempDir, 'no-cursor-home'); + process.env.HOME = isolatedHome; - // Should fail because Cursor database doesn't exist in test environment - expect(result.found).toBe(false); - expect(result.error).toBeDefined(); + try { + const result = autoDetectTokens(); + + // Should fail because isolated test home has no Cursor database + expect(result.found).toBe(false); + expect(result.error).toBeDefined(); + } finally { + if (originalHome !== undefined) { + process.env.HOME = originalHome; + } else { + delete process.env.HOME; + } + } }); it('should have found property in return type', () => { diff --git a/tests/unit/cursor/cursor-daemon.test.ts b/tests/unit/cursor/cursor-daemon.test.ts index b46fa317..c2f64ce7 100644 --- a/tests/unit/cursor/cursor-daemon.test.ts +++ b/tests/unit/cursor/cursor-daemon.test.ts @@ -19,7 +19,7 @@ import { } from '../../../src/cursor/cursor-daemon'; import { getCcsDir } from '../../../src/utils/config-manager'; import { handleCursorCommand } from '../../../src/commands/cursor-command'; -import { loadCredentials } from '../../../src/cursor/cursor-auth'; +import { loadCredentials, saveCredentials } from '../../../src/cursor/cursor-auth'; // Test isolation let originalCcsHome: string | undefined; @@ -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', @@ -174,6 +181,93 @@ describe('startDaemon', () => { }, 35000 ); + + it('returns 404 for unknown routes', async () => { + const port = 10000 + Math.floor(Math.random() * 50000); + const result = await startDaemon({ port, ghost_mode: true }); + expect(result.success).toBe(true); + + try { + const response = await fetch(`http://127.0.0.1:${port}/unknown`); + expect(response.status).toBe(404); + } finally { + await stopDaemon(); + } + }); + + it('returns 401 when credentials are expired', async () => { + const port = 10000 + Math.floor(Math.random() * 50000); + const expiredAt = new Date(Date.now() - 26 * 60 * 60 * 1000).toISOString(); + + saveCredentials({ + accessToken: 'a'.repeat(60), + machineId: '1234567890abcdef1234567890abcdef', + authMethod: 'manual', + importedAt: expiredAt, + }); + + const result = await startDaemon({ port, ghost_mode: true }); + expect(result.success).toBe(true); + + try { + const response = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'gpt-4.1', + messages: [{ role: 'user', content: 'hello' }], + }), + }); + + expect(response.status).toBe(401); + const body = (await response.json()) as { error?: { message?: string } }; + expect(body.error?.message).toContain('expired'); + } finally { + await stopDaemon(); + } + }); + + it('validates invalid JSON, invalid message schema, and oversized body', async () => { + const port = 10000 + Math.floor(Math.random() * 50000); + const result = await startDaemon({ port, ghost_mode: true }); + expect(result.success).toBe(true); + + try { + const invalidJson = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{invalid-json', + }); + expect(invalidJson.status).toBe(400); + + const invalidSchema = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'gpt-4.1', + messages: { role: 'user', content: 'hello' }, + }), + }); + expect(invalidSchema.status).toBe(400); + + const oversized = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'gpt-4.1', + messages: [ + { + role: 'user', + content: 'x'.repeat(10 * 1024 * 1024 + 1024), + }, + ], + }), + }); + expect(oversized.status).toBe(413); + } finally { + await stopDaemon(); + } + }); }); describe('isDaemonRunning', () => { diff --git a/tests/unit/cursor/cursor-models.test.ts b/tests/unit/cursor/cursor-models.test.ts index e5127f02..7d2dc8d0 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', () => { @@ -95,10 +99,322 @@ describe('formatModelName', () => { describe('fetchModelsFromDaemon', () => { it('falls back to DEFAULT_CURSOR_MODELS when daemon is unreachable', async () => { - // Use a port that nothing is listening on const unreachablePort = 9999; const models = await fetchModelsFromDaemon(unreachablePort); expect(models).toEqual(DEFAULT_CURSOR_MODELS); }); + + it('falls back to defaults when daemon returns invalid JSON', async () => { + const server = http.createServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{not-valid-json'); + }); + + 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 fetchModelsFromDaemon(address.port); + expect(models).toEqual(DEFAULT_CURSOR_MODELS); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it('falls back to defaults when daemon response exceeds max body size', async () => { + const oversizedPayload = 'x'.repeat(1024 * 1024 + 1024); + const server = http.createServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(oversizedPayload); + }); + + 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 fetchModelsFromDaemon(address.port); + expect(models).toEqual(DEFAULT_CURSOR_MODELS); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); +}); + +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())); + } + }); + + it('parses response.models and filters invalid records', async () => { + const server = http.createServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + models: [ + { id: 'gpt-5.3-codex', name: 'GPT-5.3 Codex' }, + { id: '', name: 'invalid-empty-id' }, + { id: 123, name: 'invalid-type-id' }, + ], + }) + ); + }); + + 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).toHaveLength(1); + expect(models?.[0].id).toBe('gpt-5.3-codex'); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it('returns null when required credentials are missing', async () => { + const models = await fetchModelsFromCursorApi( + { + accessToken: '', + machineId: '1234567890abcdef1234567890abcdef', + }, + { + endpoint: 'http://127.0.0.1:9/v1/models', + timeoutMs: 50, + } + ); + + expect(models).toBeNull(); + }); + + it('returns null on timeout/abort', async () => { + const server = http.createServer((_req, _res) => { + // Intentionally no response within timeout. + }); + + 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: 25, + } + ); + + 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); + }); + + it('clears cache after auth failures and falls back to defaults', async () => { + const liveModelId = 'test-live-model-auth-cache'; + const okServer = 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) => okServer.listen(0, '127.0.0.1', resolve)); + const okAddress = okServer.address(); + if (!okAddress || typeof okAddress === 'string') { + throw new Error('Unable to resolve test server port'); + } + + try { + const first = await getModelsForDaemon({ + credentials: { + accessToken: 'test-token-123', + machineId: '1234567890abcdef1234567890abcdef', + }, + endpoint: `http://127.0.0.1:${okAddress.port}/v1/models`, + timeoutMs: 2000, + }); + + expect(first[0]?.id).toBe(liveModelId); + } finally { + await new Promise((resolve) => okServer.close(() => resolve())); + } + + const forbiddenServer = http.createServer((_req, res) => { + res.writeHead(403, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'forbidden' })); + }); + + await new Promise((resolve) => forbiddenServer.listen(0, '127.0.0.1', resolve)); + const forbiddenAddress = forbiddenServer.address(); + if (!forbiddenAddress || typeof forbiddenAddress === 'string') { + throw new Error('Unable to resolve test server port'); + } + + try { + const forbidden = await fetchModelsFromCursorApi( + { + accessToken: 'test-token-123', + machineId: '1234567890abcdef1234567890abcdef', + }, + { + endpoint: `http://127.0.0.1:${forbiddenAddress.port}/v1/models`, + timeoutMs: 2000, + } + ); + + expect(forbidden).toBeNull(); + } finally { + await new Promise((resolve) => forbiddenServer.close(() => resolve())); + } + + const afterAuthFailure = await getModelsForDaemon(); + expect(afterAuthFailure).toEqual(DEFAULT_CURSOR_MODELS); + }); }); diff --git a/tests/unit/cursor/cursor-protobuf.test.ts b/tests/unit/cursor/cursor-protobuf.test.ts index 0ea16a7c..5831fda2 100644 --- a/tests/unit/cursor/cursor-protobuf.test.ts +++ b/tests/unit/cursor/cursor-protobuf.test.ts @@ -371,18 +371,12 @@ describe('Request Encoding', () => { it('should handle multi-frame buffer', () => { const executor = new CursorExecutor(); - // Create two simple frames - const frame1 = wrapConnectRPCFrame( - encodeField(FIELD.ChatResponse.TEXT, WIRE_TYPE.LEN, 'Frame 1'), - false - ); - const frame2 = wrapConnectRPCFrame( - encodeField(FIELD.ChatResponse.TEXT, WIRE_TYPE.LEN, ' Frame 2'), - false - ); + // Build two valid response frames (top-level Response.RESPONSE wrapper). + const frame1 = buildTextFrame('Frame 1'); + const frame2 = buildTextFrame(' Frame 2'); // Concatenate them - const multiFrame = Buffer.concat([Buffer.from(frame1), Buffer.from(frame2)]); + const multiFrame = Buffer.concat([frame1, frame2]); const result = executor.transformProtobufToJSON(multiFrame, 'gpt-4', { messages: [], @@ -457,6 +451,29 @@ describe('CursorExecutor', () => { expect(headers.authorization).toBe('Bearer actual-token'); }); + it('should throw when token becomes empty after delimiter parsing', () => { + const credentials = { + accessToken: 'prefix::', + machineId: 'test-machine-id', + }; + + expect(() => executor.buildHeaders(credentials)).toThrow('Access token is empty'); + }); + + it('should include normalized platform and timezone headers', () => { + const credentials = { + accessToken: 'test-token', + machineId: 'test-machine-id', + }; + + const headers = executor.buildHeaders(credentials); + + expect(['windows', 'macos', 'linux']).toContain(headers['x-cursor-client-os']); + expect(['aarch64', 'x64']).toContain(headers['x-cursor-client-arch']); + expect(typeof headers['x-cursor-timezone']).toBe('string'); + expect(headers['x-cursor-timezone'].length).toBeGreaterThan(0); + }); + it('should respect ghostMode flag', () => { const credentialsGhost = { accessToken: 'test-token', @@ -531,6 +548,55 @@ 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); + }); + + it('should merge fragmented tool call arguments and set tool_calls finish reason', async () => { + const frame1 = buildToolCallFrame({ + id: 'call_123', + name: 'search_docs', + args: '{"q":"hel', + isLast: false, + }); + const frame2 = buildToolCallFrame({ + id: 'call_123', + name: 'search_docs', + args: 'lo"}', + isLast: true, + }); + const combined = Buffer.concat([frame1, frame2]); + + const result = executor.transformProtobufToJSON(combined, 'gpt-4', { + messages: [], + }); + + expect(result.status).toBe(200); + const body = JSON.parse(await result.text()); + expect(body.choices[0].finish_reason).toBe('tool_calls'); + expect(body.choices[0].message.tool_calls[0].id).toBe('call_123'); + expect(body.choices[0].message.tool_calls[0].function.name).toBe('search_docs'); + expect(body.choices[0].message.tool_calls[0].function.arguments).toBe('{"q":"hello"}'); + }); }); describe('transformProtobufToSSE', () => { @@ -572,6 +638,49 @@ 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); + }); + + it('should emit tool call deltas and end with finish_reason tool_calls', async () => { + const frame1 = buildToolCallFrame({ + id: 'call_abc', + name: 'search_docs', + args: '{"q":"foo', + isLast: false, + }); + const frame2 = buildToolCallFrame({ + id: 'call_abc', + name: 'search_docs', + args: '"}', + isLast: true, + }); + const combined = Buffer.concat([frame1, frame2]); + + const result = executor.transformProtobufToSSE(combined, 'gpt-4', { + messages: [], + }); + + expect(result.status).toBe(200); + const bodyText = await result.text(); + expect(bodyText).toContain('tool_calls'); + expect(bodyText).toContain('search_docs'); + expect(bodyText).toContain('"finish_reason":"tool_calls"'); + }); }); describe('decompressPayload error handling', () => { @@ -676,6 +785,35 @@ 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); +} + +/** + * Helper: build a protobuf tool call response frame + */ +function buildToolCallFrame(options: { + id: string; + name: string; + args: string; + isLast: boolean; +}): Buffer { + const toolCallPayload = concatArrays( + encodeField(FIELD.ToolCall.ID, WIRE_TYPE.LEN, options.id), + encodeField(FIELD.ToolCall.NAME, WIRE_TYPE.LEN, options.name), + encodeField(FIELD.ToolCall.RAW_ARGS, WIRE_TYPE.LEN, options.args), + encodeField(FIELD.ToolCall.IS_LAST, WIRE_TYPE.VARINT, options.isLast ? 1 : 0) + ); + const responseMsg = encodeField(FIELD.Response.TOOL_CALL, WIRE_TYPE.LEN, toolCallPayload); + return buildFrame(responseMsg); +} + describe('StreamingFrameParser', () => { it('should parse a complete single frame', () => { const parser = new StreamingFrameParser(); @@ -784,6 +922,51 @@ 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 parse tool call frames', () => { + const parser = new StreamingFrameParser(); + const frame = buildToolCallFrame({ + id: 'call_parser', + name: 'search_docs', + args: '{"q":"docs"}', + isLast: true, + }); + const results = parser.push(frame); + + expect(results.length).toBe(1); + expect(results[0].type).toBe('toolCall'); + if (results[0].type === 'toolCall') { + expect(results[0].toolCall.id).toBe('call_parser'); + expect(results[0].toolCall.function.name).toBe('search_docs'); + expect(results[0].toolCall.function.arguments).toBe('{"q":"docs"}'); + expect(results[0].toolCall.isLast).toBe(true); + } + }); + + it('should classify malformed protobuf payload as server error', () => { + const parser = new StreamingFrameParser(); + const malformedFrame = buildFrame(new Uint8Array([0xff, 0xff, 0xff])); + const results = parser.push(malformedFrame); + + expect(results.length).toBe(1); + expect(results[0].type).toBe('error'); + if (results[0].type === 'error') { + expect(results[0].errorType).toBe('server_error'); + expect(results[0].message).toContain('Malformed protobuf response'); + } + }); + it('should report hasPartial() correctly', () => { const parser = new StreamingFrameParser(); expect(parser.hasPartial()).toBe(false); diff --git a/tests/unit/utils/process-utils.test.ts b/tests/unit/utils/process-utils.test.ts index cfae232a..29d62ac9 100644 --- a/tests/unit/utils/process-utils.test.ts +++ b/tests/unit/utils/process-utils.test.ts @@ -1,7 +1,7 @@ /** * Unit tests for process-utils.ts */ -import { describe, it, expect, beforeEach, afterEach, jest } from 'bun:test'; +import { describe, it, expect, jest } from 'bun:test'; import { EventEmitter } from 'events'; import { killWithEscalation } from '../../../src/utils/process-utils'; import type { ChildProcess } from 'child_process'; @@ -20,15 +20,11 @@ function createMockProcess(exitCode: number | null = null): ChildProcess { return proc as ChildProcess; } +function wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + describe('killWithEscalation', () => { - beforeEach(() => { - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - it('should send SIGTERM immediately', () => { const proc = createMockProcess(); killWithEscalation(proc); @@ -37,36 +33,34 @@ describe('killWithEscalation', () => { expect(proc.kill).toHaveBeenCalledTimes(1); }); - it('should send SIGKILL after grace period if process still running', () => { + it('should send SIGKILL after grace period if process still running', async () => { const proc = createMockProcess(null); // exitCode null = still running - killWithEscalation(proc, 3000); + killWithEscalation(proc, 10); // SIGTERM sent immediately expect(proc.kill).toHaveBeenCalledWith('SIGTERM'); expect(proc.kill).toHaveBeenCalledTimes(1); - // Advance time by grace period - jest.advanceTimersByTime(3000); + await wait(40); // SIGKILL sent after grace period expect(proc.kill).toHaveBeenCalledWith('SIGKILL'); expect(proc.kill).toHaveBeenCalledTimes(2); }); - it('should NOT send SIGKILL if process exits before grace period', () => { + it('should NOT send SIGKILL if process exits before grace period', async () => { const proc = createMockProcess(null); - killWithEscalation(proc, 3000); + killWithEscalation(proc, 40); expect(proc.kill).toHaveBeenCalledWith('SIGTERM'); expect(proc.kill).toHaveBeenCalledTimes(1); - // Simulate process exit after 1 second - jest.advanceTimersByTime(1000); - proc.exitCode = 0; // Process exited + // Simulate process exit before grace timeout + await wait(10); + proc.exitCode = 0; proc.emit('exit', 0); - // Advance remaining time - jest.advanceTimersByTime(2000); + await wait(60); // SIGKILL should NOT have been sent expect(proc.kill).toHaveBeenCalledTimes(1); @@ -75,56 +69,95 @@ describe('killWithEscalation', () => { it('should use default grace period of 3000ms', () => { const proc = createMockProcess(null); - killWithEscalation(proc); // No grace period argument + const originalSetTimeout = globalThis.setTimeout; + const fakeTimer = { + unref: () => fakeTimer, + ref: () => fakeTimer, + hasRef: () => false, + refresh: () => fakeTimer, + } as unknown as ReturnType; - expect(proc.kill).toHaveBeenCalledWith('SIGTERM'); + let observedDelay: number | undefined; - // Advance by default 3000ms - jest.advanceTimersByTime(3000); + globalThis.setTimeout = ((handler: TimerHandler, timeout?: number) => { + observedDelay = timeout; + void handler; // avoid executing callback in this assertion-only test + return fakeTimer; + }) as typeof globalThis.setTimeout; - expect(proc.kill).toHaveBeenCalledWith('SIGKILL'); + try { + killWithEscalation(proc); + expect(observedDelay).toBe(3000); + expect(proc.kill).toHaveBeenCalledWith('SIGTERM'); + expect(proc.kill).toHaveBeenCalledTimes(1); + } finally { + globalThis.setTimeout = originalSetTimeout; + } }); it('should respect custom grace period', () => { const proc = createMockProcess(null); - killWithEscalation(proc, 5000); // Custom 5 second grace period + const originalSetTimeout = globalThis.setTimeout; + const fakeTimer = { + unref: () => fakeTimer, + ref: () => fakeTimer, + hasRef: () => false, + refresh: () => fakeTimer, + } as unknown as ReturnType; - expect(proc.kill).toHaveBeenCalledWith('SIGTERM'); + let observedDelay: number | undefined; - // Advance by less than grace period - jest.advanceTimersByTime(4999); - expect(proc.kill).toHaveBeenCalledTimes(1); // Still only SIGTERM + globalThis.setTimeout = ((handler: TimerHandler, timeout?: number) => { + observedDelay = timeout; + void handler; + return fakeTimer; + }) as typeof globalThis.setTimeout; - // Advance to grace period - jest.advanceTimersByTime(1); - expect(proc.kill).toHaveBeenCalledWith('SIGKILL'); + try { + killWithEscalation(proc, 5000); + expect(observedDelay).toBe(5000); + expect(proc.kill).toHaveBeenCalledWith('SIGTERM'); + expect(proc.kill).toHaveBeenCalledTimes(1); + } finally { + globalThis.setTimeout = originalSetTimeout; + } }); - it('should clear timer when process exits', () => { + it('should clear timer when process exits', async () => { const proc = createMockProcess(null); - killWithEscalation(proc, 3000); + const originalClearTimeout = globalThis.clearTimeout; + let clearCalled = false; - // Simulate immediate exit - proc.exitCode = 0; - proc.emit('exit', 0); + globalThis.clearTimeout = ((id: ReturnType) => { + clearCalled = true; + return originalClearTimeout(id); + }) as typeof globalThis.clearTimeout; - // Advance way past grace period - jest.advanceTimersByTime(10000); + try { + killWithEscalation(proc, 50); - // Should only have SIGTERM, timer was cleared - expect(proc.kill).toHaveBeenCalledTimes(1); - expect(proc.kill).toHaveBeenCalledWith('SIGTERM'); + // Simulate immediate exit + proc.exitCode = 0; + proc.emit('exit', 0); + + await wait(70); + + // Should only have SIGTERM, timer was cleared + expect(clearCalled).toBe(true); + expect(proc.kill).toHaveBeenCalledTimes(1); + expect(proc.kill).toHaveBeenCalledWith('SIGTERM'); + } finally { + globalThis.clearTimeout = originalClearTimeout; + } }); - it('should handle process that already exited', () => { + it('should handle process that already exited', async () => { const proc = createMockProcess(0); // Already exited - killWithEscalation(proc, 3000); + killWithEscalation(proc, 10); expect(proc.kill).toHaveBeenCalledWith('SIGTERM'); - // Even though exitCode is not null, timer still fires - // (because we check exitCode at timer callback time) - jest.advanceTimersByTime(3000); + await wait(30); // SIGKILL should NOT be sent because exitCode is not null expect(proc.kill).toHaveBeenCalledTimes(1); diff --git a/tests/unit/web-server/cursor-routes.test.ts b/tests/unit/web-server/cursor-routes.test.ts index a54bd2c6..fda059b0 100644 --- a/tests/unit/web-server/cursor-routes.test.ts +++ b/tests/unit/web-server/cursor-routes.test.ts @@ -1,10 +1,158 @@ /** * 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'; +import { execFileSync, spawnSync } from 'child_process'; + +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; + credentials?: { + authMethod?: 'manual' | 'auto-detect'; + machineId?: string; + }; +}; +let getTokenStoragePath: () => string; +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; + getTokenStoragePath = cursorAuth.getTokenStoragePath; + + 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 +205,212 @@ 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 originalHome = process.env.HOME; + const isolatedHome = path.join(tempDir, 'auto-detect-empty-home'); + process.env.HOME = isolatedHome; + fs.mkdirSync(isolatedHome, { recursive: true }); + + try { + 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); + } finally { + if (originalHome !== undefined) { + process.env.HOME = originalHome; + } else { + delete process.env.HOME; + } + } + }); + + it('POST /api/cursor/auth/auto-detect persists credentials on success', async () => { + if (process.platform === 'win32') { + return; + } + + const sqliteCheck = spawnSync('sqlite3', ['--version'], { stdio: 'ignore' }); + if (sqliteCheck.status !== 0) { + return; + } + + const originalHome = process.env.HOME; + const fakeHome = path.join(tempDir, 'fake-home'); + process.env.HOME = fakeHome; + fs.mkdirSync(fakeHome, { recursive: true }); + + const token = 'a'.repeat(60); + const machineId = '1234567890abcdef1234567890abcdef'; + + try { + const dbPath = getTokenStoragePath(); + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + execFileSync('sqlite3', [dbPath, 'CREATE TABLE IF NOT EXISTS itemTable (key TEXT PRIMARY KEY, value TEXT);']); + execFileSync('sqlite3', [ + dbPath, + `INSERT OR REPLACE INTO itemTable (key, value) VALUES ('cursorAuth/accessToken', '${token}');`, + ]); + execFileSync('sqlite3', [ + dbPath, + `INSERT OR REPLACE INTO itemTable (key, value) VALUES ('storage.serviceMachineId', '${machineId}');`, + ]); + + const res = await fetch(`${baseUrl}/api/cursor/auth/auto-detect`, { + method: 'POST', + }); + + expect(res.status).toBe(200); + const json = (await res.json()) as { success?: boolean }; + expect(json.success).toBe(true); + + const auth = checkAuthStatus(); + expect(auth.authenticated).toBe(true); + expect(auth.credentials?.authMethod).toBe('auto-detect'); + expect(auth.credentials?.machineId).toBe(machineId); + } finally { + if (originalHome !== undefined) { + process.env.HOME = originalHome; + } else { + delete process.env.HOME; + } + } + }); + + 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( + 'POST /api/cursor/daemon/start starts daemon and /daemon/stop stops it', + async () => { + const port = 15000 + Math.floor(Math.random() * 20000); + seedCursorConfig({ enabled: true, port }); + seedCredentials(false); + + const startRes = await fetch(`${baseUrl}/api/cursor/daemon/start`, { method: 'POST' }); + expect(startRes.status).toBe(200); + + const startJson = (await startRes.json()) as { success?: boolean; pid?: number }; + expect(startJson.success).toBe(true); + expect(typeof startJson.pid).toBe('number'); + + const stopRes = await fetch(`${baseUrl}/api/cursor/daemon/stop`, { method: 'POST' }); + expect(stopRes.status).toBe(200); + const stopJson = (await stopRes.json()) as { success?: boolean }; + expect(stopJson.success).toBe(true); + }, + 35000 + ); + + it('POST /api/cursor/daemon/stop returns success when daemon is not running', async () => { + const res = await fetch(`${baseUrl}/api/cursor/daemon/stop`, { method: 'POST' }); + expect(res.status).toBe(200); + const json = (await res.json()) as { success?: boolean }; + expect(json.success).toBe(true); + }); + + 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'); + }); + }); });