9router/open-sse/executors/cursor.js
triadmoko 137f315bec feat(cursor): Integrate Cursor IDE support with OAuth import token flow
- Add CursorExecutor for handling requests to the Cursor API using protobuf over HTTP/2.
- Implement CursorAuthModal for user token import from local SQLite database.
- Update provider models and constants to include Cursor as a supported provider.
- Enhance API service with token validation and user info extraction from Cursor tokens.
- Introduce utility functions for checksum generation and protobuf encoding/decoding for Cursor API interactions.
2026-02-04 12:07:29 +07:00

516 lines
15 KiB
JavaScript

/**
* CursorExecutor - Executor for Cursor AI IDE
* Uses ConnectRPC/protobuf protocol with HTTP/2 for streaming chat
*/
import { BaseExecutor } from "./base.js";
import { PROVIDERS } from "../config/constants.js";
import {
generateCursorBody,
parseConnectRPCFrame,
extractTextFromResponse
} from "../utils/cursorProtobuf.js";
import crypto from "crypto";
import { v5 as uuidv5 } from "uuid";
import http2 from "http2";
export class CursorExecutor extends BaseExecutor {
constructor() {
super("cursor", PROVIDERS.cursor);
}
/**
* Build URL for Cursor API
*/
buildUrl() {
return `${this.config.baseUrl}${this.config.chatPath}`;
}
/**
* Generate Cursor checksum (jyh cipher) - timestamp integer version
* This is the format that works with Cursor API
*/
generateChecksum(machineId) {
// Use timestamp / 1e6 format (same as Python demo that works)
const timestamp = Math.floor(Date.now() / 1000000);
// Create 6-byte big-endian array
const byteArray = new Uint8Array([
(timestamp >> 40) & 0xFF,
(timestamp >> 32) & 0xFF,
(timestamp >> 24) & 0xFF,
(timestamp >> 16) & 0xFF,
(timestamp >> 8) & 0xFF,
timestamp & 0xFF
]);
// Jyh cipher obfuscation
let t = 165;
for (let i = 0; i < byteArray.length; i++) {
byteArray[i] = ((byteArray[i] ^ t) + (i % 256)) & 0xFF;
t = byteArray[i];
}
// URL-safe base64 encode (without padding)
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}`;
}
/**
* Generate client key from token
*/
generateClientKey(token) {
return crypto.createHash("sha256").update(token).digest("hex");
}
/**
* Generate session ID
*/
generateSessionId(token) {
return uuidv5(token, uuidv5.DNS);
}
/**
* Build headers with Cursor checksum authentication
*/
buildHeaders(credentials) {
const accessToken = credentials.accessToken;
const machineId = credentials.providerSpecificData?.machineId;
const ghostMode = credentials.providerSpecificData?.ghostMode !== false;
if (!machineId) {
throw new Error("Machine ID is required for Cursor API");
}
const cleanToken = accessToken.includes("::")
? accessToken.split("::")[1]
: accessToken;
return {
"authorization": `Bearer ${cleanToken}`,
"connect-accept-encoding": "gzip",
"connect-protocol-version": "1",
"content-type": "application/connect+proto",
"user-agent": "connect-es/1.6.1",
"x-amzn-trace-id": `Root=${crypto.randomUUID()}`,
"x-client-key": this.generateClientKey(cleanToken),
"x-cursor-checksum": this.generateChecksum(machineId),
"x-cursor-client-version": "2.3.41",
"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": this.generateSessionId(cleanToken),
};
}
/**
* Convert OpenAI-format messages to Cursor format
*/
convertMessages(body) {
const messages = body.messages || [];
const result = [];
for (const msg of messages) {
if (msg.role === "system") {
result.push({
role: "user",
content: `[System Instructions]\n${msg.content}`
});
continue;
}
if (msg.role === "user" || msg.role === "assistant") {
let content = "";
if (typeof msg.content === "string") {
content = msg.content;
} else if (Array.isArray(msg.content)) {
for (const part of msg.content) {
if (part.type === "text") {
content += part.text;
}
}
}
if (content) {
result.push({ role: msg.role, content });
}
}
}
return result;
}
/**
* Make HTTP/2 request to Cursor API
*/
makeHttp2Request(url, headers, body, signal) {
return new Promise((resolve, reject) => {
const urlObj = new URL(url);
const client = http2.connect(`https://${urlObj.host}`);
const chunks = [];
let responseHeaders = {};
client.on("error", (err) => {
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) => {
chunks.push(chunk);
});
req.on("end", () => {
client.close();
const data = Buffer.concat(chunks);
resolve({
status: responseHeaders[":status"],
headers: responseHeaders,
body: data
});
});
req.on("error", (err) => {
client.close();
reject(err);
});
if (signal) {
signal.addEventListener("abort", () => {
req.close();
client.close();
reject(new Error("Request aborted"));
});
}
req.write(body);
req.end();
});
}
/**
* Custom execute for Cursor - handles protobuf binary protocol with HTTP/2
*/
async execute({ model, body, stream, credentials, signal, log }) {
const url = this.buildUrl();
const headers = this.buildHeaders(credentials);
// Convert messages and build protobuf body
const messages = this.convertMessages(body);
const cursorBody = generateCursorBody(messages, model);
log?.debug?.("CURSOR", `Sending ${messages.length} messages to ${model}, stream=${stream}`);
try {
// Use HTTP/2 for Cursor API (required)
const response = await this.makeHttp2Request(url, headers, cursorBody, signal);
if (response.status !== 200) {
// Create error response
const errorResponse = new Response(JSON.stringify({
error: {
message: `[${response.status}]: ${response.body.toString() || "Unknown error"}`,
type: "invalid_request_error",
code: ""
}
}), {
status: response.status,
headers: { "Content-Type": "application/json" }
});
return { response: errorResponse, url, headers, transformedBody: body };
}
// Transform based on stream parameter
const transformedResponse = stream !== false
? this.transformProtobufToSSE(response.body, model)
: this.transformProtobufToJSON(response.body, model);
return { response: transformedResponse, url, headers, transformedBody: body };
} catch (error) {
log?.error?.("CURSOR", `Request failed: ${error.message}`);
const errorResponse = new Response(JSON.stringify({
error: {
message: error.message,
type: "connection_error",
code: ""
}
}), {
status: 500,
headers: { "Content-Type": "application/json" }
});
return { response: errorResponse, url, headers, transformedBody: body };
}
}
/**
* Transform ConnectRPC protobuf buffer to JSON Response (non-streaming)
*/
transformProtobufToJSON(buffer, model) {
const responseId = `chatcmpl-cursor-${Date.now()}`;
const created = Math.floor(Date.now() / 1000);
// Parse all frames and collect content
let offset = 0;
let totalContent = "";
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;
// Decompress if gzip (flags 0x01 or 0x03)
if (flags === 0x01 || flags === 0x03) {
try {
const zlib = require("zlib");
payload = zlib.gunzipSync(payload);
} catch {
continue;
}
}
// Check if payload is JSON error (ConnectRPC error format)
try {
const text = payload.toString("utf-8");
if (text.startsWith("{") && text.includes('"error"')) {
const jsonError = JSON.parse(text);
const errorMsg = jsonError?.error?.details?.[0]?.debug?.details?.title
|| jsonError?.error?.details?.[0]?.debug?.details?.detail
|| jsonError?.error?.message
|| "API Error";
return new Response(JSON.stringify({
error: {
message: errorMsg,
type: jsonError?.error?.code === "resource_exhausted" ? "rate_limit_error" : "api_error",
code: jsonError?.error?.details?.[0]?.debug?.error || "unknown"
}
}), {
status: jsonError?.error?.code === "resource_exhausted" ? 429 : 400,
headers: { "Content-Type": "application/json" }
});
}
} catch {}
// Extract text or error from protobuf
const result = extractTextFromResponse(new Uint8Array(payload));
if (result.error) {
// Return error response
return new Response(JSON.stringify({
error: {
message: result.error,
type: "rate_limit_error",
code: "rate_limited"
}
}), {
status: 429,
headers: { "Content-Type": "application/json" }
});
}
if (result.text) {
totalContent += result.text;
}
}
// Build non-streaming response
const estimatedPromptTokens = 10;
const estimatedCompletionTokens = Math.max(1, Math.floor(totalContent.length / 4));
const completion = {
id: responseId,
object: "chat.completion",
created,
model,
choices: [{
index: 0,
message: {
role: "assistant",
content: totalContent
},
finish_reason: "stop"
}],
usage: {
prompt_tokens: estimatedPromptTokens,
completion_tokens: estimatedCompletionTokens,
total_tokens: estimatedPromptTokens + estimatedCompletionTokens
}
};
return new Response(JSON.stringify(completion), {
status: 200,
headers: { "Content-Type": "application/json" }
});
}
/**
* Transform ConnectRPC protobuf buffer to SSE Response
*/
transformProtobufToSSE(buffer, model) {
const responseId = `chatcmpl-cursor-${Date.now()}`;
const created = Math.floor(Date.now() / 1000);
// Parse all frames from buffer
const chunks = [];
let offset = 0;
let totalContent = "";
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;
// Decompress if gzip (flags 0x01 or 0x03)
if (flags === 0x01 || flags === 0x03) {
try {
const zlib = require("zlib");
payload = zlib.gunzipSync(payload);
} catch {
continue;
}
}
// Check if payload is JSON error (ConnectRPC error format)
try {
const text = payload.toString("utf-8");
if (text.startsWith("{") && text.includes('"error"')) {
const jsonError = JSON.parse(text);
const errorMsg = jsonError?.error?.details?.[0]?.debug?.details?.title
|| jsonError?.error?.details?.[0]?.debug?.details?.detail
|| jsonError?.error?.message
|| "API Error";
return new Response(JSON.stringify({
error: {
message: errorMsg,
type: jsonError?.error?.code === "resource_exhausted" ? "rate_limit_error" : "api_error",
code: jsonError?.error?.details?.[0]?.debug?.error || "unknown"
}
}), {
status: jsonError?.error?.code === "resource_exhausted" ? 429 : 400,
headers: { "Content-Type": "application/json" }
});
}
} catch {}
// Extract text or error from protobuf
const result = extractTextFromResponse(new Uint8Array(payload));
if (result.error) {
// Return error response
return new Response(JSON.stringify({
error: {
message: result.error,
type: "rate_limit_error",
code: "rate_limited"
}
}), {
status: 429,
headers: { "Content-Type": "application/json" }
});
}
if (result.text) {
totalContent += result.text;
const chunk = {
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [{
index: 0,
delta: chunks.length === 0
? { role: "assistant", content: result.text }
: { content: result.text },
finish_reason: null
}]
};
chunks.push(`data: ${JSON.stringify(chunk)}\n\n`);
}
}
// Add finish chunk
const estimatedTokens = Math.max(1, Math.floor(totalContent.length / 4));
const finishChunk = {
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [{
index: 0,
delta: {},
finish_reason: "stop"
}],
usage: {
prompt_tokens: 0,
completion_tokens: estimatedTokens,
total_tokens: estimatedTokens
}
};
chunks.push(`data: ${JSON.stringify(finishChunk)}\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"
}
});
}
/**
* Cursor doesn't support standard OAuth refresh
*/
async refreshCredentials() {
return null;
}
}
export default CursorExecutor;