diff --git a/src/cursor/cursor-executor.ts b/src/cursor/cursor-executor.ts index 72e299a6..0919c7f4 100644 --- a/src/cursor/cursor-executor.ts +++ b/src/cursor/cursor-executor.ts @@ -8,17 +8,10 @@ import * as zlib from 'zlib'; import type { IncomingHttpHeaders } from 'http'; import { generateCursorBody, extractTextFromResponse } from './cursor-protobuf.js'; import { buildCursorRequest } from './cursor-translator.js'; -import type { CursorTool } from './cursor-protobuf-schema.js'; +import type { CursorTool, CursorCredentials } from './cursor-protobuf-schema.js'; import { COMPRESS_FLAG } from './cursor-protobuf-schema.js'; -/** Cursor credentials structure */ -interface CursorCredentials { - accessToken: string; - machineId: string; - ghostMode?: boolean; -} - /** Executor parameters */ interface ExecutorParams { model: string; @@ -60,8 +53,10 @@ function isCloudEnv(): boolean { try { // Check for EdgeRuntime without causing compilation error if (typeof (globalThis as { EdgeRuntime?: string }).EdgeRuntime !== 'undefined') return true; - } catch { - // Continue + } catch (err) { + if (process.env.CCS_DEBUG) { + console.error('[cursor] EdgeRuntime detection failed:', err); + } } return false; } @@ -74,7 +69,10 @@ async function getHttp2() { try { http2Module = await import('http2'); return http2Module; - } catch { + } catch (err) { + if (process.env.CCS_DEBUG) { + console.error('[cursor] http2 import failed:', err); + } return null; } } @@ -93,8 +91,10 @@ function decompressPayload(payload: Buffer, flags: number): Buffer { if (text.startsWith('{"error"')) { return payload; } - } catch { - // Continue + } catch (err) { + if (process.env.CCS_DEBUG) { + console.error('[cursor] JSON error detection failed:', err); + } } } @@ -105,7 +105,10 @@ function decompressPayload(payload: Buffer, flags: number): Buffer { ) { try { return zlib.gunzipSync(payload); - } catch { + } catch (err) { + if (process.env.CCS_DEBUG) { + console.error('[cursor] gzip decompression failed:', err); + } return payload; } } @@ -148,6 +151,8 @@ 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}`; @@ -214,11 +219,11 @@ export class CursorExecutor { 'connect-accept-encoding': 'gzip', 'connect-protocol-version': '1', 'content-type': 'application/connect+proto', - 'user-agent': 'connect-es/1.6.1', + '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': '2.3.41', + 'x-cursor-client-version': this.CURSOR_CLIENT_VERSION, 'x-cursor-client-type': 'ide', 'x-cursor-client-os': process.platform === 'win32' @@ -379,7 +384,7 @@ export class CursorExecutor { } const transformedResponse = - stream !== false + stream === true ? this.transformProtobufToSSE(response.body, model, body) : this.transformProtobufToJSON(response.body, model, body); @@ -442,8 +447,10 @@ export class CursorExecutor { if (text.startsWith('{') && text.includes('"error"')) { return createErrorResponse(JSON.parse(text)); } - } catch { - // Continue + } catch (err) { + if (process.env.CCS_DEBUG) { + console.error('[cursor] transformProtobufToJSON error parsing failed:', err); + } } const result = extractTextFromResponse(new Uint8Array(payload)); @@ -555,8 +562,9 @@ export class CursorExecutor { } transformProtobufToSSE(buffer: Buffer, model: string, _body: ExecutorParams['body']): Response { - // TODO: Implement true streaming — currently buffers entire response before transforming. + // TODO(#531): Implement true streaming — currently buffers entire response before transforming. // This should pipe HTTP/2 data events through a TransformStream for incremental SSE output. + // See: https://github.com/kaitranntt/ccs/issues/531 // NOTE: Chunk boundary splits may emit duplicate SSE messages if a frame spans multiple chunks. const responseId = `chatcmpl-cursor-${Date.now()}`; const created = Math.floor(Date.now() / 1000); @@ -598,8 +606,10 @@ export class CursorExecutor { if (text.startsWith('{') && text.includes('"error"')) { return createErrorResponse(JSON.parse(text)); } - } catch { - // Continue + } catch (err) { + if (process.env.CCS_DEBUG) { + console.error('[cursor] transformProtobufToJSON error parsing failed:', err); + } } const result = extractTextFromResponse(new Uint8Array(payload)); diff --git a/src/cursor/cursor-protobuf-decoder.ts b/src/cursor/cursor-protobuf-decoder.ts index 6111dd19..7345ab82 100644 --- a/src/cursor/cursor-protobuf-decoder.ts +++ b/src/cursor/cursor-protobuf-decoder.ts @@ -24,7 +24,7 @@ export function decodeVarint(buffer: Uint8Array, offset: number): [number, numbe shift += 7; } - return [result, pos]; + return [result >>> 0, pos]; // Ensure unsigned } /** @@ -124,7 +124,10 @@ export function parseConnectRPCFrame(buffer: Buffer): { if (flags === 0x01 || flags === 0x02 || flags === 0x03) { try { payload = Buffer.from(zlib.gunzipSync(payload)); - } catch { + } catch (err) { + if (process.env.CCS_DEBUG) { + console.error('[cursor] parseConnectRPCFrame decompression failed:', err); + } // Decompression failed, use raw payload } } @@ -205,7 +208,10 @@ function extractToolCall(toolCallData: Uint8Array): { } } } - } catch { + } catch (err) { + if (process.env.CCS_DEBUG) { + console.error('[cursor] extractToolCall MCP parsing failed:', err); + } // MCP parse error, continue } } @@ -265,7 +271,10 @@ function extractTextAndThinking(responseData: Uint8Array): { } } } - } catch { + } catch (err) { + if (process.env.CCS_DEBUG) { + console.error('[cursor] extractTextAndThinking parsing failed:', err); + } // Thinking parse error, continue } } @@ -314,7 +323,10 @@ export function extractTextFromResponse(payload: Uint8Array): { } return { text: null, error: null, toolCall: null, thinking: null }; - } catch { + } catch (err) { + if (process.env.CCS_DEBUG) { + console.error('[cursor] extractTextFromResponse parsing failed:', err); + } return { text: null, error: null, toolCall: null, thinking: null }; } } diff --git a/src/cursor/cursor-protobuf-schema.ts b/src/cursor/cursor-protobuf-schema.ts index 53183bb7..4dbc32a0 100644 --- a/src/cursor/cursor-protobuf-schema.ts +++ b/src/cursor/cursor-protobuf-schema.ts @@ -32,10 +32,10 @@ export const THINKING_LEVEL = { /** Field numbers for all protobuf messages */ export const FIELD = { - // StreamUnifiedChatRequestWithTools (top level) + // ===== StreamUnifiedChatRequestWithTools (top level) ===== REQUEST: 1, - // StreamUnifiedChatRequest + // ===== StreamUnifiedChatRequest ===== MESSAGES: 1, UNKNOWN_2: 2, INSTRUCTION: 3, @@ -61,7 +61,7 @@ export const FIELD = { UNKNOWN_53: 53, UNIFIED_MODE_NAME: 54, - // ConversationMessage + // ===== ConversationMessage ===== MSG_CONTENT: 1, MSG_ROLE: 2, MSG_ID: 13, @@ -70,72 +70,72 @@ export const FIELD = { MSG_UNIFIED_MODE: 47, MSG_SUPPORTED_TOOLS: 51, - // ConversationMessage.ToolResult + // ===== ConversationMessage.ToolResult ===== TOOL_RESULT_CALL_ID: 1, TOOL_RESULT_NAME: 2, TOOL_RESULT_INDEX: 3, TOOL_RESULT_RAW_ARGS: 5, TOOL_RESULT_RESULT: 8, - // Model + // ===== Model ===== MODEL_NAME: 1, MODEL_EMPTY: 4, - // Instruction + // ===== Instruction ===== INSTRUCTION_TEXT: 1, - // CursorSetting + // ===== CursorSetting ===== SETTING_PATH: 1, SETTING_UNKNOWN_3: 3, SETTING_UNKNOWN_6: 6, SETTING_UNKNOWN_8: 8, SETTING_UNKNOWN_9: 9, - // CursorSetting.Unknown6 + // ===== CursorSetting.Unknown6 ===== SETTING6_FIELD_1: 1, SETTING6_FIELD_2: 2, - // Metadata + // ===== Metadata ===== META_PLATFORM: 1, META_ARCH: 2, META_VERSION: 3, META_CWD: 4, META_TIMESTAMP: 5, - // MessageId + // ===== MessageId ===== MSGID_ID: 1, MSGID_SUMMARY: 2, MSGID_ROLE: 3, - // MCPTool + // ===== MCPTool ===== MCP_TOOL_NAME: 1, MCP_TOOL_DESC: 2, MCP_TOOL_PARAMS: 3, MCP_TOOL_SERVER: 4, - // StreamUnifiedChatResponseWithTools (response) + // ===== StreamUnifiedChatResponseWithTools (response) ===== TOOL_CALL: 1, RESPONSE: 2, - // ClientSideToolV2Call + // ===== ClientSideToolV2Call ===== TOOL_ID: 3, TOOL_NAME: 9, TOOL_RAW_ARGS: 10, TOOL_IS_LAST: 11, TOOL_MCP_PARAMS: 27, - // MCPParams + // ===== MCPParams ===== MCP_TOOLS_LIST: 1, - // MCPParams.Tool (nested) + // ===== MCPParams.Tool (nested) ===== MCP_NESTED_NAME: 1, MCP_NESTED_PARAMS: 3, - // StreamUnifiedChatResponse + // ===== StreamUnifiedChatResponse ===== RESPONSE_TEXT: 1, THINKING: 25, - // Thinking + // ===== Thinking ===== THINKING_TEXT: 1, } as const; @@ -146,6 +146,13 @@ export type UnifiedModeType = (typeof UNIFIED_MODE)[keyof typeof UNIFIED_MODE]; export type ThinkingLevelType = (typeof THINKING_LEVEL)[keyof typeof THINKING_LEVEL]; export type FieldNumber = (typeof FIELD)[keyof typeof FIELD]; +/** Cursor credentials structure */ +export interface CursorCredentials { + accessToken: string; + machineId: string; + ghostMode?: boolean; +} + /** Cursor tool definition */ export interface CursorTool { function?: { diff --git a/tests/unit/cursor/cursor-protobuf.test.ts b/tests/unit/cursor/cursor-protobuf.test.ts new file mode 100644 index 00000000..ca4f5207 --- /dev/null +++ b/tests/unit/cursor/cursor-protobuf.test.ts @@ -0,0 +1,405 @@ +/** + * Cursor Protobuf Module Unit Tests + * Tests encoder, decoder, translator, and executor components + */ + +import { describe, it, expect } from 'bun:test'; +import { + encodeVarint, + encodeField, + wrapConnectRPCFrame, + concatArrays, +} from '../../../src/cursor/cursor-protobuf-encoder'; +import { + decodeVarint, + decodeField, + parseConnectRPCFrame, +} from '../../../src/cursor/cursor-protobuf-decoder'; +import { buildCursorRequest } from '../../../src/cursor/cursor-translator'; +import { CursorExecutor } from '../../../src/cursor/cursor-executor'; +import { WIRE_TYPE, FIELD } from '../../../src/cursor/cursor-protobuf-schema'; + +describe('Protobuf Encoding/Decoding', () => { + describe('encodeVarint / decodeVarint round-trip', () => { + it('should encode and decode 0', () => { + const encoded = encodeVarint(0); + const [decoded, offset] = decodeVarint(encoded, 0); + expect(decoded).toBe(0); + expect(offset).toBe(1); + }); + + it('should encode and decode 1', () => { + const encoded = encodeVarint(1); + const [decoded, offset] = decodeVarint(encoded, 0); + expect(decoded).toBe(1); + expect(offset).toBe(1); + }); + + it('should encode and decode 127', () => { + const encoded = encodeVarint(127); + const [decoded, offset] = decodeVarint(encoded, 0); + expect(decoded).toBe(127); + expect(offset).toBe(1); + }); + + it('should encode and decode 128', () => { + const encoded = encodeVarint(128); + const [decoded, offset] = decodeVarint(encoded, 0); + expect(decoded).toBe(128); + expect(offset).toBe(2); + }); + + it('should encode and decode 16383', () => { + const encoded = encodeVarint(16383); + const [decoded, offset] = decodeVarint(encoded, 0); + expect(decoded).toBe(16383); + expect(offset).toBe(2); + }); + + it('should encode and decode 0xFFFFFFFF', () => { + const encoded = encodeVarint(0xffffffff); + const [decoded, offset] = decodeVarint(encoded, 0); + expect(decoded).toBe(0xffffffff); + expect(offset).toBe(5); + }); + }); + + describe('encodeField / decodeField round-trip', () => { + it('should encode and decode VARINT field', () => { + const fieldNum = 5; + const value = 42; + const encoded = encodeField(fieldNum, WIRE_TYPE.VARINT, value); + + const [decodedFieldNum, wireType, decodedValue, offset] = decodeField(encoded, 0); + expect(decodedFieldNum).toBe(fieldNum); + expect(wireType).toBe(WIRE_TYPE.VARINT); + expect(decodedValue).toBe(value); + expect(offset).toBe(encoded.length); + }); + + it('should encode and decode LEN field with string', () => { + const fieldNum = 10; + const value = 'Hello, World!'; + const encoded = encodeField(fieldNum, WIRE_TYPE.LEN, value); + + const [decodedFieldNum, wireType, decodedValue, offset] = decodeField(encoded, 0); + expect(decodedFieldNum).toBe(fieldNum); + expect(wireType).toBe(WIRE_TYPE.LEN); + expect(new TextDecoder().decode(decodedValue as Uint8Array)).toBe(value); + expect(offset).toBe(encoded.length); + }); + + it('should encode and decode LEN field with binary data', () => { + const fieldNum = 15; + const value = new Uint8Array([1, 2, 3, 4, 5]); + const encoded = encodeField(fieldNum, WIRE_TYPE.LEN, value); + + const [decodedFieldNum, wireType, decodedValue, offset] = decodeField(encoded, 0); + expect(decodedFieldNum).toBe(fieldNum); + expect(wireType).toBe(WIRE_TYPE.LEN); + expect(decodedValue).toEqual(value); + expect(offset).toBe(encoded.length); + }); + }); + + describe('wrapConnectRPCFrame / parseConnectRPCFrame round-trip', () => { + it('should wrap and parse uncompressed frame', () => { + const payload = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + const frame = wrapConnectRPCFrame(payload, false); + + const parsed = parseConnectRPCFrame(Buffer.from(frame)); + expect(parsed).not.toBeNull(); + expect(parsed!.flags).toBe(0x00); + expect(parsed!.length).toBe(payload.length); + expect(parsed!.payload).toEqual(payload); + expect(parsed!.consumed).toBe(5 + payload.length); + }); + + it('should wrap and parse compressed frame', () => { + const payload = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + const frame = wrapConnectRPCFrame(payload, true); + + const parsed = parseConnectRPCFrame(Buffer.from(frame)); + expect(parsed).not.toBeNull(); + expect(parsed!.flags).toBe(0x01); // GZIP flag + expect(parsed!.payload).toEqual(payload); // Should be decompressed + }); + + it('should handle incomplete frame', () => { + const partial = new Uint8Array([0x00, 0x00, 0x00]); // Only 3 bytes + const parsed = parseConnectRPCFrame(Buffer.from(partial)); + expect(parsed).toBeNull(); + }); + }); + + describe('concatArrays', () => { + it('should concatenate multiple arrays', () => { + const arr1 = new Uint8Array([1, 2, 3]); + const arr2 = new Uint8Array([4, 5]); + const arr3 = new Uint8Array([6, 7, 8, 9]); + + const result = concatArrays(arr1, arr2, arr3); + expect(result).toEqual(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9])); + }); + + it('should handle empty arrays', () => { + const arr1 = new Uint8Array([1, 2]); + const arr2 = new Uint8Array([]); + const arr3 = new Uint8Array([3, 4]); + + const result = concatArrays(arr1, arr2, arr3); + expect(result).toEqual(new Uint8Array([1, 2, 3, 4])); + }); + }); +}); + +describe('Message Translation', () => { + describe('buildCursorRequest', () => { + it('should convert system message to user with prefix', () => { + const result = buildCursorRequest( + 'gpt-4', + { + messages: [{ role: 'system', content: 'You are a helpful assistant.' }], + }, + false, + {} + ); + + expect(result.messages).toHaveLength(1); + expect(result.messages[0].role).toBe('user'); + expect(result.messages[0].content).toContain('[System Instructions]'); + expect(result.messages[0].content).toContain('You are a helpful assistant.'); + }); + + it('should keep user and assistant messages', () => { + const result = buildCursorRequest( + 'gpt-4', + { + messages: [ + { role: 'user', content: 'Hello' }, + { role: 'assistant', content: 'Hi there!' }, + ], + }, + false, + {} + ); + + expect(result.messages).toHaveLength(2); + expect(result.messages[0].role).toBe('user'); + expect(result.messages[0].content).toBe('Hello'); + expect(result.messages[1].role).toBe('assistant'); + expect(result.messages[1].content).toBe('Hi there!'); + }); + + it('should handle assistant messages with tool_calls', () => { + const result = buildCursorRequest( + 'gpt-4', + { + messages: [ + { + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'call_123', + type: 'function', + function: { name: 'get_weather', arguments: '{"city":"NYC"}' }, + }, + ], + }, + ], + }, + false, + {} + ); + + expect(result.messages).toHaveLength(1); + expect(result.messages[0].role).toBe('assistant'); + expect(result.messages[0].tool_calls).toHaveLength(1); + expect(result.messages[0].tool_calls![0].id).toBe('call_123'); + expect(result.messages[0].tool_calls![0].function.name).toBe('get_weather'); + }); + + it('should accumulate tool results', () => { + const result = buildCursorRequest( + 'gpt-4', + { + messages: [ + { + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'call_123', + type: 'function', + function: { name: 'get_weather', arguments: '{"city":"NYC"}' }, + }, + ], + }, + { + role: 'tool', + content: '{"temperature": 72}', + name: 'get_weather', + tool_call_id: 'call_123', + }, + { role: 'user', content: 'What is the weather?' }, + ], + }, + false, + {} + ); + + expect(result.messages).toHaveLength(2); + // Tool result should be attached to next message + expect(result.messages[1].tool_results).toBeDefined(); + expect(result.messages[1].tool_results).toHaveLength(1); + expect(result.messages[1].tool_results![0].tool_call_id).toBe('call_123'); + }); + + it('should handle array content format', () => { + const result = buildCursorRequest( + 'gpt-4', + { + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: 'Hello' }, + { type: 'text', text: ' World' }, + ], + }, + ], + }, + false, + {} + ); + + expect(result.messages).toHaveLength(1); + expect(result.messages[0].content).toBe('Hello World'); + }); + }); +}); + +describe('CursorExecutor', () => { + const executor = new CursorExecutor(); + + describe('generateChecksum', () => { + it('should generate valid checksum format', () => { + const machineId = 'test-machine-id'; + const checksum = executor.generateChecksum(machineId); + + // Should end with machine ID + expect(checksum.endsWith(machineId)).toBe(true); + + // Should have base64url-like prefix (8 chars from 6 bytes) + const prefix = checksum.slice(0, -machineId.length); + expect(prefix.length).toBe(8); + expect(/^[A-Za-z0-9_-]+$/.test(prefix)).toBe(true); + }); + + it('should generate different checksums over time', async () => { + const machineId = 'test-machine-id'; + const checksum1 = executor.generateChecksum(machineId); + + // Wait longer to ensure timestamp changes (microsecond precision) + await new Promise((resolve) => setTimeout(resolve, 10)); + + const checksum2 = executor.generateChecksum(machineId); + + // Different timestamps should produce different checksums + // If they're still the same, it's extremely rare but acceptable + // Just verify format is correct + expect(checksum1.endsWith(machineId)).toBe(true); + expect(checksum2.endsWith(machineId)).toBe(true); + }); + }); + + describe('buildHeaders', () => { + it('should generate all required headers', () => { + const credentials = { + accessToken: 'test-token', + machineId: 'test-machine-id', + }; + + const headers = executor.buildHeaders(credentials); + + expect(headers).toHaveProperty('authorization'); + expect(headers.authorization).toContain('Bearer'); + expect(headers).toHaveProperty('connect-accept-encoding', 'gzip'); + expect(headers).toHaveProperty('connect-protocol-version', '1'); + expect(headers).toHaveProperty('content-type', 'application/connect+proto'); + expect(headers).toHaveProperty('user-agent', 'connect-es/1.6.1'); + expect(headers).toHaveProperty('x-cursor-checksum'); + expect(headers).toHaveProperty('x-cursor-client-version', '2.3.41'); + expect(headers).toHaveProperty('x-cursor-client-type', 'ide'); + expect(headers).toHaveProperty('x-ghost-mode', 'true'); + }); + + it('should handle token with :: delimiter', () => { + const credentials = { + accessToken: 'prefix::actual-token', + machineId: 'test-machine-id', + }; + + const headers = executor.buildHeaders(credentials); + + expect(headers.authorization).toBe('Bearer actual-token'); + }); + + it('should respect ghostMode flag', () => { + const credentialsGhost = { + accessToken: 'test-token', + machineId: 'test-machine-id', + ghostMode: true, + }; + + const credentialsNoGhost = { + accessToken: 'test-token', + machineId: 'test-machine-id', + ghostMode: false, + }; + + const headersGhost = executor.buildHeaders(credentialsGhost); + const headersNoGhost = executor.buildHeaders(credentialsNoGhost); + + expect(headersGhost['x-ghost-mode']).toBe('true'); + expect(headersNoGhost['x-ghost-mode']).toBe('false'); + }); + + it('should throw error if machineId missing', () => { + const credentials = { + accessToken: 'test-token', + machineId: '', + }; + + expect(() => executor.buildHeaders(credentials)).toThrow('Machine ID is required'); + }); + }); + + describe('buildUrl', () => { + it('should return correct API endpoint', () => { + const url = executor.buildUrl(); + expect(url).toBe('https://api2.cursor.sh/aiserver.v1.AiService/StreamChat'); + }); + }); + + describe('transformProtobufToJSON', () => { + it('should handle basic text response', async () => { + // Create minimal protobuf response with text + const textContent = 'Hello, world!'; + const responseField = encodeField(FIELD.RESPONSE_TEXT, WIRE_TYPE.LEN, textContent); + const responseMsg = encodeField(FIELD.RESPONSE, WIRE_TYPE.LEN, responseField); + 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].finish_reason).toBe('stop'); + }); + }); +});