From 4798741a99eb60190c6ba09215f50d3bc67db6e4 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 17 Feb 2026 16:33:21 +0700 Subject: [PATCH] fix(cursor): resolve review feedback and harden edge cases --- src/cursor/cursor-auth.ts | 14 +- src/cursor/cursor-client-policy.ts | 13 +- src/cursor/cursor-daemon-entry.ts | 41 +++-- src/cursor/cursor-executor.ts | 36 ++-- src/cursor/cursor-models.ts | 58 ++++-- src/cursor/cursor-protobuf-decoder.ts | 6 +- src/cursor/cursor-protobuf-schema.ts | 2 +- tests/unit/cursor/cursor-auth.test.ts | 20 ++- tests/unit/cursor/cursor-daemon.test.ts | 89 ++++++++- tests/unit/cursor/cursor-models.test.ts | 190 +++++++++++++++++++- tests/unit/cursor/cursor-protobuf.test.ts | 142 +++++++++++++-- tests/unit/utils/process-utils.test.ts | 129 ++++++++----- tests/unit/web-server/cursor-routes.test.ts | 120 ++++++++++++- 13 files changed, 746 insertions(+), 114 deletions(-) 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 index 157b46d2..e5a5fe75 100644 --- a/src/cursor/cursor-client-policy.ts +++ b/src/cursor/cursor-client-policy.ts @@ -5,7 +5,7 @@ */ import * as crypto from 'crypto'; -import type { CursorCredentials } from './cursor-protobuf-schema'; +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'; @@ -33,6 +33,7 @@ export function generateCursorChecksum(machineId: string, nowMs: number = Date.n 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. @@ -73,7 +74,7 @@ export function generateCursorChecksum(machineId: string, nowMs: number = Date.n return `${encoded}${machineId}`; } -function buildCursorBaseHeaders(credentials: CursorCredentials): Record { +function buildCursorBaseHeaders(credentials: CursorApiCredentials): Record { const cleanToken = normalizeCursorAccessToken(credentials.accessToken); if (!cleanToken) { @@ -105,7 +106,9 @@ function buildCursorBaseHeaders(credentials: CursorCredentials): Record { +export function buildCursorConnectHeaders( + credentials: CursorApiCredentials +): Record { return { ...buildCursorBaseHeaders(credentials), 'connect-accept-encoding': 'gzip', @@ -115,7 +118,9 @@ export function buildCursorConnectHeaders(credentials: CursorCredentials): Recor }; } -export function buildCursorModelsHeaders(credentials: CursorCredentials): Record { +export function buildCursorModelsHeaders( + credentials: CursorApiCredentials +): Record { return { ...buildCursorBaseHeaders(credentials), accept: 'application/json', diff --git a/src/cursor/cursor-daemon-entry.ts b/src/cursor/cursor-daemon-entry.ts index 841de56d..355cedfc 100644 --- a/src/cursor/cursor-daemon-entry.ts +++ b/src/cursor/cursor-daemon-entry.ts @@ -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))); + }); }); } @@ -241,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, @@ -269,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-executor.ts b/src/cursor/cursor-executor.ts index 1aa7e48a..7f46ec42 100644 --- a/src/cursor/cursor-executor.ts +++ b/src/cursor/cursor-executor.ts @@ -6,7 +6,7 @@ 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; } @@ -104,7 +104,7 @@ export class CursorExecutor { return generateCursorChecksum(machineId); } - buildHeaders(credentials: CursorCredentials): Record { + buildHeaders(credentials: CursorApiCredentials): Record { return buildCursorConnectHeaders(credentials); } @@ -112,7 +112,7 @@ export class CursorExecutor { model: string, body: ExecutorParams['body'], stream: boolean, - credentials: CursorCredentials + credentials: CursorApiCredentials ): Uint8Array { const translatedBody = buildCursorRequest(model, body, stream, credentials); const messages = translatedBody.messages || []; @@ -320,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({ @@ -342,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(); @@ -350,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); @@ -368,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: '', }, }), @@ -561,7 +577,7 @@ export class CursorExecutor { }, }); - resolve( + resolveOnce( new Response(readable, { status: 200, headers: { @@ -575,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); diff --git a/src/cursor/cursor-models.ts b/src/cursor/cursor-models.ts index 460c0a83..12e7ad58 100644 --- a/src/cursor/cursor-models.ts +++ b/src/cursor/cursor-models.ts @@ -6,7 +6,7 @@ import * as http from 'http'; import type { CursorModel } from './types'; -import type { CursorCredentials } from './cursor-protobuf-schema'; +import type { CursorApiCredentials } from './cursor-protobuf-schema'; import { isDaemonRunning } from './cursor-daemon'; import { buildCursorModelsHeaders } from './cursor-client-policy'; import { @@ -34,6 +34,15 @@ interface CursorModelsApiResponse { models?: Array<{ id?: unknown; name?: unknown; provider?: unknown }>; } +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}`); +} + function normalizeModelRecords( records: Array<{ id?: unknown; name?: unknown; provider?: unknown }> ): CursorModel[] { @@ -93,7 +102,7 @@ export function clearCursorModelsCache(): void { } export async function fetchModelsFromCursorApi( - credentials: CursorCredentials, + credentials: CursorApiCredentials, options: { endpoint?: string; timeoutMs?: number; @@ -116,12 +125,21 @@ export async function fetchModelsFromCursorApi( }); 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; - return parseApiModelsResponse(payload); - } catch { + 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); @@ -130,7 +148,7 @@ export async function fetchModelsFromCursorApi( export async function getModelsForDaemon( options: { - credentials?: CursorCredentials | null; + credentials?: CursorApiCredentials | null; endpoint?: string; timeoutMs?: number; } = {} @@ -186,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); } @@ -193,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); }); 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/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 bccd17c3..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; @@ -181,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 3221e7f8..7d2dc8d0 100644 --- a/tests/unit/cursor/cursor-models.test.ts +++ b/tests/unit/cursor/cursor-models.test.ts @@ -99,12 +99,52 @@ 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', () => { @@ -176,6 +216,90 @@ describe('fetchModelsFromCursorApi', () => { 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', () => { @@ -229,4 +353,68 @@ describe('getModelsForDaemon', () => { 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 a4a699e1..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', @@ -553,6 +570,33 @@ describe('CursorExecutor', () => { 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', () => { @@ -611,6 +655,32 @@ describe('CursorExecutor', () => { 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', () => { @@ -725,6 +795,25 @@ function buildThinkingFrame(thinking: string): Buffer { 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(); @@ -845,6 +934,39 @@ describe('StreamingFrameParser', () => { } }); + 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 1bb76640..fda059b0 100644 --- a/tests/unit/web-server/cursor-routes.test.ts +++ b/tests/unit/web-server/cursor-routes.test.ts @@ -9,6 +9,7 @@ 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 = ''; @@ -42,7 +43,15 @@ let saveCredentials: (credentials: { importedAt: string; }) => void; let deleteCredentials: () => boolean; -let checkAuthStatus: () => { authenticated: boolean; expired?: 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; @@ -94,6 +103,7 @@ beforeAll(async () => { 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; @@ -259,14 +269,79 @@ describe('Cursor Routes Logic', () => { }); 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', - }); + const originalHome = process.env.HOME; + const isolatedHome = path.join(tempDir, 'auto-detect-empty-home'); + process.env.HOME = isolatedHome; + fs.mkdirSync(isolatedHome, { recursive: true }); - 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); + 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 () => { @@ -299,6 +374,35 @@ describe('Cursor Routes Logic', () => { 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);