diff --git a/src/cursor/cursor-models.ts b/src/cursor/cursor-models.ts index 79be4082..292ce5dc 100644 --- a/src/cursor/cursor-models.ts +++ b/src/cursor/cursor-models.ts @@ -23,6 +23,8 @@ export { DEFAULT_CURSOR_MODEL, DEFAULT_CURSOR_MODELS, detectProvider, formatMode const CURSOR_MODELS_API_ENDPOINT = 'https://api2.cursor.sh/v1/models'; const CURSOR_MODELS_CACHE_TTL_MS = 5 * 60 * 1000; +const CURSOR_MODELS_MAX_BODY_SIZE = 1024 * 1024; // 1MB limit +const CURSOR_DAEMON_MODELS_TIMEOUT_MS = 5000; let liveModelsCache: { models: CursorModel[]; @@ -81,6 +83,14 @@ function parseApiModelsResponse(payload: unknown): CursorModel[] | null { return models.length > 0 ? models : null; } +function parseContentLength(value: string | string[] | undefined): number | null { + const rawValue = Array.isArray(value) ? value[0] : value; + if (!rawValue) return null; + + const parsed = Number.parseInt(rawValue, 10); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : null; +} + function getCachedLiveModels(nowMs: number = Date.now()): CursorModel[] | null { if (!liveModelsCache) return null; if (liveModelsCache.expiresAtMs <= nowMs) { @@ -195,24 +205,39 @@ export async function fetchModelsFromDaemon(port: number): Promise { - const MAX_BODY_SIZE = 1024 * 1024; // 1MB limit + const contentLength = parseContentLength(res.headers['content-length']); + if (contentLength !== null && contentLength > CURSOR_MODELS_MAX_BODY_SIZE) { + debugLog( + 'Cursor daemon /v1/models content-length exceeded 1MB; falling back to defaults' + ); + res.destroy(); + req.destroy(); + safeResolve(DEFAULT_CURSOR_MODELS); + return; + } + let data = ''; + let bodySize = 0; res.on('data', (chunk) => { - data += chunk; - if (data.length > MAX_BODY_SIZE) { + if (resolved) return; + bodySize += typeof chunk === 'string' ? Buffer.byteLength(chunk) : chunk.length; + if (bodySize > CURSOR_MODELS_MAX_BODY_SIZE) { debugLog('Cursor daemon /v1/models body exceeded 1MB; falling back to defaults'); res.destroy(); req.destroy(); safeResolve(DEFAULT_CURSOR_MODELS); return; } + + data += chunk; }); res.on('end', () => { + if (resolved) return; try { const response = JSON.parse(data) as { data?: Array<{ id?: unknown }> }; if (Array.isArray(response.data)) { diff --git a/tests/unit/cursor/cursor-models.test.ts b/tests/unit/cursor/cursor-models.test.ts index dddea9e8..14569c52 100644 --- a/tests/unit/cursor/cursor-models.test.ts +++ b/tests/unit/cursor/cursor-models.test.ts @@ -170,7 +170,7 @@ describe('fetchModelsFromDaemon', () => { it( 'falls back to defaults when daemon response exceeds max body size', async () => { - const oversizedPayload = 'x'.repeat(1024 * 1024 + 1024); + const oversizedPayload = Buffer.alloc(1024 * 1024 + 1024, 'x'); const server = http.createServer((_req, res) => { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(oversizedPayload); @@ -187,11 +187,37 @@ describe('fetchModelsFromDaemon', () => { expect(models).toEqual(DEFAULT_CURSOR_MODELS); } finally { server.closeAllConnections?.(); + server.closeIdleConnections?.(); await new Promise((resolve) => server.close(() => resolve())); } }, 30000 ); + + it('falls back to defaults when daemon advertises an oversized response', async () => { + const server = http.createServer((_req, res) => { + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Content-Length': String(1024 * 1024 + 1), + }); + res.flushHeaders(); + }); + + 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 { + server.closeAllConnections?.(); + server.closeIdleConnections?.(); + await new Promise((resolve) => server.close(() => resolve())); + } + }); }); describe('fetchModelsFromCursorApi', () => {