Files
ccs/src/cursor/cursor-executor.ts
T

787 lines
22 KiB
TypeScript

/**
* Cursor Executor
* Handles HTTP/2 requests to Cursor API with protobuf encoding/decoding
*/
import * as crypto from 'crypto';
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, CursorCredentials } from './cursor-protobuf-schema.js';
import { COMPRESS_FLAG } from './cursor-protobuf-schema.js';
/** Executor parameters */
interface ExecutorParams {
model: string;
body: {
messages: Array<{
role: string;
content: string | Array<{ type: string; text?: string }>;
name?: string;
tool_call_id?: string;
tool_calls?: Array<{
id: string;
type: string;
function: { name: string; arguments: string };
}>;
}>;
tools?: CursorTool[];
reasoning_effort?: string;
};
stream: boolean;
credentials: CursorCredentials;
signal?: AbortSignal;
}
/** HTTP/2 response structure */
interface Http2Response {
status: number;
headers: IncomingHttpHeaders;
body: Buffer;
}
/** Lazy import http2 */
let http2Module: typeof import('http2') | null = null;
async function getHttp2() {
if (http2Module) return http2Module;
try {
http2Module = await import('http2');
return http2Module;
} catch (err) {
if (process.env.CCS_DEBUG) {
console.error('[cursor] http2 module not available, falling back to fetch:', err);
}
return null;
}
}
/**
* Decompress payload if needed
* NOTE: Uses synchronous gzip for single-request CLI tool. Async not warranted for small payloads.
*/
function decompressPayload(payload: Buffer, flags: number): Buffer {
// Check if payload is JSON error
if (payload.length > 10 && payload[0] === 0x7b && payload[1] === 0x22) {
try {
const text = payload.toString('utf-8');
if (text.startsWith('{"error"')) {
return payload;
}
} catch (err) {
if (process.env.CCS_DEBUG) {
console.error('[cursor] JSON error detection failed:', err);
}
}
}
if (
flags === COMPRESS_FLAG.GZIP ||
flags === COMPRESS_FLAG.GZIP_ALT ||
flags === COMPRESS_FLAG.GZIP_BOTH
) {
try {
return zlib.gunzipSync(payload);
} catch (err) {
if (process.env.CCS_DEBUG) {
console.error('[cursor] gzip decompression failed:', err);
}
return Buffer.alloc(0);
}
}
return payload;
}
/**
* Create error response from JSON error
*/
function createErrorResponse(jsonError: {
error?: {
code?: string;
message?: string;
details?: Array<{ debug?: { details?: { title?: string; detail?: string }; error?: string } }>;
};
}): Response {
const errorMsg =
jsonError?.error?.details?.[0]?.debug?.details?.title ||
jsonError?.error?.details?.[0]?.debug?.details?.detail ||
jsonError?.error?.message ||
'API Error';
const isRateLimit = jsonError?.error?.code === 'resource_exhausted';
return new Response(
JSON.stringify({
error: {
message: errorMsg,
type: isRateLimit ? 'rate_limit_error' : 'api_error',
code: jsonError?.error?.details?.[0]?.debug?.error || 'unknown',
},
}),
{
status: isRateLimit ? 429 : 400,
headers: { 'Content-Type': 'application/json' },
}
);
}
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}`;
}
/**
* 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}`;
}
buildHeaders(credentials: CursorCredentials): Record<string, string> {
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),
};
}
transformRequest(
model: string,
body: ExecutorParams['body'],
stream: boolean,
credentials: CursorCredentials
): Uint8Array {
const translatedBody = buildCursorRequest(model, body, stream, credentials);
const messages = translatedBody.messages || [];
const tools = (translatedBody.tools || body.tools || []) as CursorTool[];
const reasoningEffort = body.reasoning_effort || null;
return generateCursorBody(messages, model, tools, reasoningEffort);
}
async makeFetchRequest(
url: string,
headers: Record<string, string>,
body: Uint8Array,
signal?: AbortSignal
): Promise<Http2Response> {
const response = await fetch(url, {
method: 'POST',
headers,
body,
signal,
});
const responseHeaders: Record<string, string> = {};
response.headers.forEach((value, key) => {
responseHeaders[key] = value;
});
return {
status: response.status,
headers: responseHeaders,
body: Buffer.from(await response.arrayBuffer()),
};
}
async makeHttp2Request(
url: string,
headers: Record<string, string>,
body: Uint8Array,
signal?: AbortSignal
): Promise<Http2Response> {
const http2 = await getHttp2();
if (!http2) {
throw new Error('http2 module not available');
}
return new Promise((resolve, reject) => {
const urlObj = new URL(url);
const client = http2.connect(`https://${urlObj.host}`);
const chunks: Buffer[] = [];
let responseHeaders: IncomingHttpHeaders = {};
client.on('error', (err) => {
client.close();
reject(err);
});
const req = client.request({
':method': 'POST',
':path': urlObj.pathname,
':authority': urlObj.host,
':scheme': 'https',
...headers,
});
req.on('response', (hdrs) => {
responseHeaders = hdrs;
});
req.on('data', (chunk: Buffer) => {
chunks.push(chunk);
});
req.on('end', () => {
client.close();
resolve({
status: Number(responseHeaders[':status']) || 500,
headers: responseHeaders,
body: Buffer.concat(chunks),
});
});
req.on('error', (err) => {
client.close();
reject(err);
});
if (signal) {
const onAbort = () => {
req.close();
client.close();
reject(new Error('Request aborted'));
};
signal.addEventListener('abort', onAbort, { once: true });
const cleanup = () => {
signal.removeEventListener('abort', onAbort);
};
req.on('end', cleanup);
req.on('error', cleanup);
}
req.write(body);
req.end();
});
}
async execute(params: ExecutorParams): Promise<{
response: Response;
url: string;
headers: Record<string, string>;
transformedBody: ExecutorParams['body'];
}> {
const { model, body, stream, credentials, signal } = params;
const url = this.buildUrl();
const headers = this.buildHeaders(credentials);
const transformedBody = this.transformRequest(model, body, stream, credentials);
try {
const http2 = await getHttp2();
const response = http2
? await this.makeHttp2Request(url, headers, transformedBody, signal)
: await this.makeFetchRequest(url, headers, transformedBody, signal);
if (response.status !== 200) {
const errorText = response.body?.toString() || 'Unknown error';
const errorResponse = new Response(
JSON.stringify({
error: {
message: `[${response.status}]: ${errorText}`,
type: 'invalid_request_error',
code: '',
},
}),
{
status: response.status,
headers: { 'Content-Type': 'application/json' },
}
);
return { response: errorResponse, url, headers, transformedBody: body };
}
const transformedResponse =
stream === true
? this.transformProtobufToSSE(response.body, model, body)
: this.transformProtobufToJSON(response.body, model, body);
return { response: transformedResponse, url, headers, transformedBody: body };
} catch (error) {
const errorResponse = new Response(
JSON.stringify({
error: {
message: (error as Error).message,
type: 'connection_error',
code: '',
},
}),
{
status: 500,
headers: { 'Content-Type': 'application/json' },
}
);
return { response: errorResponse, url, headers, transformedBody: body };
}
}
/**
* Parse protobuf buffer into frames and extract text/toolcalls.
* Shared logic between JSON and SSE transformers.
*/
private *parseProtobufFrames(buffer: Buffer): Generator<
| { type: 'error'; response: Response }
| { type: 'text'; text: string }
| {
type: 'toolCall';
toolCall: {
id: string;
type: string;
function: { name: string; arguments: string };
isLast: boolean;
};
}
> {
let offset = 0;
while (offset < buffer.length) {
if (offset + 5 > buffer.length) break;
const flags = buffer[offset];
const length = buffer.readUInt32BE(offset + 1);
if (offset + 5 + length > buffer.length) break;
let payload = buffer.slice(offset + 5, offset + 5 + length);
offset += 5 + length;
payload = decompressPayload(payload, flags);
// Check for JSON error format
try {
const text = payload.toString('utf-8');
if (text.startsWith('{') && text.includes('"error"')) {
yield { type: 'error', response: createErrorResponse(JSON.parse(text)) };
return;
}
} catch (err) {
if (process.env.CCS_DEBUG) {
console.error('[cursor] parseProtobufFrames error parsing failed:', err);
}
}
const result = extractTextFromResponse(new Uint8Array(payload));
// Check for protobuf-decoded error
if (result.error) {
const isRateLimit =
result.error.toLowerCase().includes('rate') ||
result.error.toLowerCase().includes('limit');
yield {
type: 'error',
response: new Response(
JSON.stringify({
error: {
message: result.error,
type: isRateLimit ? 'rate_limit_error' : 'server_error',
code: isRateLimit ? 'rate_limited' : 'cursor_error',
},
}),
{
status: isRateLimit ? 429 : 400,
headers: { 'Content-Type': 'application/json' },
}
),
};
return;
}
if (result.toolCall) {
yield { type: 'toolCall', toolCall: result.toolCall };
}
if (result.text) {
yield { type: 'text', text: result.text };
}
}
}
transformProtobufToJSON(buffer: Buffer, model: string, _body: ExecutorParams['body']): Response {
const responseId = `chatcmpl-cursor-${Date.now()}`;
const created = Math.floor(Date.now() / 1000);
let totalContent = '';
const toolCalls: Array<{
id: string;
type: string;
function: { name: string; arguments: string };
}> = [];
const toolCallsMap = new Map<
string,
{
id: string;
type: string;
function: { name: string; arguments: string };
isLast: boolean;
index: number;
}
>();
for (const frame of this.parseProtobufFrames(buffer)) {
if (frame.type === 'error') {
return frame.response;
}
if (frame.type === 'toolCall') {
const tc = frame.toolCall;
if (toolCallsMap.has(tc.id)) {
const existing = toolCallsMap.get(tc.id);
if (!existing) continue;
existing.function.arguments += tc.function.arguments;
existing.isLast = tc.isLast;
} else {
toolCallsMap.set(tc.id, {
...tc,
index: toolCallsMap.size,
});
}
if (tc.isLast) {
const finalToolCall = toolCallsMap.get(tc.id);
if (!finalToolCall) continue;
toolCalls.push({
id: finalToolCall.id,
type: finalToolCall.type,
function: {
name: finalToolCall.function.name,
arguments: finalToolCall.function.arguments,
},
});
}
}
if (frame.type === 'text') {
totalContent += frame.text;
}
}
// Finalize remaining tool calls
for (const id of Array.from(toolCallsMap.keys())) {
const tc = toolCallsMap.get(id);
if (!tc) continue;
if (!toolCalls.find((t) => t.id === id)) {
toolCalls.push({
id: tc.id,
type: tc.type,
function: {
name: tc.function.name,
arguments: tc.function.arguments,
},
});
}
}
const message: {
role: string;
content: string | null;
tool_calls?: Array<{
id: string;
type: string;
function: { name: string; arguments: string };
}>;
} = {
role: 'assistant',
content: totalContent || null,
};
if (toolCalls.length > 0) {
message.tool_calls = toolCalls;
}
const completion = {
id: responseId,
object: 'chat.completion',
created,
model,
choices: [
{
index: 0,
message,
finish_reason: toolCalls.length > 0 ? 'tool_calls' : 'stop',
},
],
usage: {
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
},
};
return new Response(JSON.stringify(completion), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
transformProtobufToSSE(buffer: Buffer, model: string, _body: ExecutorParams['body']): Response {
// 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);
const chunks: string[] = [];
const toolCalls: Array<{
id: string;
type: string;
function: { name: string; arguments: string };
index: number;
}> = [];
const toolCallsMap = new Map<
string,
{
id: string;
type: string;
function: { name: string; arguments: string };
isLast: boolean;
index: number;
}
>();
for (const frame of this.parseProtobufFrames(buffer)) {
if (frame.type === 'error') {
return frame.response;
}
if (frame.type === 'toolCall') {
const tc = frame.toolCall;
if (chunks.length === 0) {
chunks.push(
`data: ${JSON.stringify({
id: responseId,
object: 'chat.completion.chunk',
created,
model,
choices: [
{
index: 0,
delta: { role: 'assistant', content: '' },
finish_reason: null,
},
],
})}\n\n`
);
}
if (toolCallsMap.has(tc.id)) {
const existing = toolCallsMap.get(tc.id);
if (!existing) continue;
existing.function.arguments += tc.function.arguments;
existing.isLast = tc.isLast;
if (tc.function.arguments) {
chunks.push(
`data: ${JSON.stringify({
id: responseId,
object: 'chat.completion.chunk',
created,
model,
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: existing.index,
id: tc.id,
type: 'function',
function: {
name: tc.function.name,
arguments: tc.function.arguments,
},
},
],
},
finish_reason: null,
},
],
})}\n\n`
);
}
} else {
const toolCallIndex = toolCalls.length;
toolCalls.push({ ...tc, index: toolCallIndex });
toolCallsMap.set(tc.id, { ...tc, index: toolCallIndex });
chunks.push(
`data: ${JSON.stringify({
id: responseId,
object: 'chat.completion.chunk',
created,
model,
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: toolCallIndex,
id: tc.id,
type: 'function',
function: {
name: tc.function.name,
arguments: tc.function.arguments,
},
},
],
},
finish_reason: null,
},
],
})}\n\n`
);
}
}
if (frame.type === 'text') {
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', content: frame.text }
: { content: frame.text },
finish_reason: null,
},
],
})}\n\n`
);
}
}
if (chunks.length === 0 && toolCalls.length === 0) {
chunks.push(
`data: ${JSON.stringify({
id: responseId,
object: 'chat.completion.chunk',
created,
model,
choices: [
{
index: 0,
delta: { role: 'assistant', content: '' },
finish_reason: null,
},
],
})}\n\n`
);
}
chunks.push(
`data: ${JSON.stringify({
id: responseId,
object: 'chat.completion.chunk',
created,
model,
choices: [
{
index: 0,
delta: {},
finish_reason: toolCalls.length > 0 ? 'tool_calls' : 'stop',
},
],
usage: {
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
},
})}\n\n`
);
chunks.push('data: [DONE]\n\n');
return new Response(chunks.join(''), {
status: 200,
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
});
}
}
export default CursorExecutor;