diff --git a/open-sse/config/constants.js b/open-sse/config/constants.js index 7de6f05..a07af40 100644 --- a/open-sse/config/constants.js +++ b/open-sse/config/constants.js @@ -149,6 +149,18 @@ export const PROVIDERS = { // Kiro OAuth endpoints tokenUrl: "https://prod.us-east-1.auth.desktop.kiro.dev/refreshToken", authUrl: "https://prod.us-east-1.auth.desktop.kiro.dev" + }, + cursor: { + baseUrl: "https://api2.cursor.sh", + chatPath: "/aiserver.v1.ChatService/StreamUnifiedChatWithTools", + format: "cursor", + headers: { + "connect-accept-encoding": "gzip", + "connect-protocol-version": "1", + "Content-Type": "application/connect+proto", + "User-Agent": "connect-es/1.6.1" + }, + clientVersion: "1.1.3" } }; diff --git a/open-sse/config/providerModels.js b/open-sse/config/providerModels.js index 4dd45ac..f7476cc 100644 --- a/open-sse/config/providerModels.js +++ b/open-sse/config/providerModels.js @@ -74,6 +74,18 @@ export const PROVIDER_MODELS = { { id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5" }, { id: "claude-haiku-4.5", name: "Claude Haiku 4.5" }, ], + cu: [ // Cursor IDE + { id: "default", name: "Default (Server Picks)" }, + { id: "claude-4.5-opus-high-thinking", name: "Claude 4.5 Opus High Thinking" }, + { id: "claude-4.5-opus-high", name: "Claude 4.5 Opus High" }, + { id: "claude-4.5-sonnet-thinking", name: "Claude 4.5 Sonnet Thinking" }, + { id: "claude-4-sonnet", name: "Claude 4 Sonnet" }, + { id: "gpt-4o", name: "GPT-4o" }, + { id: "gpt-5.1-codex", name: "GPT 5.1 Codex" }, + { id: "claude-3.5-sonnet", name: "Claude 3.5 Sonnet" }, + { id: "gpt-4o-mini", name: "GPT-4o Mini" }, + { id: "cursor-small", name: "Cursor Small" }, + ], // API Key Providers (alias = id) openai: [ @@ -152,6 +164,7 @@ export const PROVIDER_ID_TO_ALIAS = { antigravity: "ag", github: "gh", kiro: "kr", + cursor: "cu", openai: "openai", anthropic: "anthropic", gemini: "gemini", diff --git a/open-sse/executors/cursor.js b/open-sse/executors/cursor.js new file mode 100644 index 0000000..4ad595b --- /dev/null +++ b/open-sse/executors/cursor.js @@ -0,0 +1,516 @@ +/** + * 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; diff --git a/open-sse/executors/index.js b/open-sse/executors/index.js index 6e3b82a..08440bc 100644 --- a/open-sse/executors/index.js +++ b/open-sse/executors/index.js @@ -3,6 +3,7 @@ import { GeminiCLIExecutor } from "./gemini-cli.js"; import { GithubExecutor } from "./github.js"; import { KiroExecutor } from "./kiro.js"; import { CodexExecutor } from "./codex.js"; +import { CursorExecutor } from "./cursor.js"; import { DefaultExecutor } from "./default.js"; const executors = { @@ -10,7 +11,9 @@ const executors = { "gemini-cli": new GeminiCLIExecutor(), github: new GithubExecutor(), kiro: new KiroExecutor(), - codex: new CodexExecutor() + codex: new CodexExecutor(), + cursor: new CursorExecutor(), + cu: new CursorExecutor() // Alias for cursor }; const defaultCache = new Map(); @@ -31,4 +34,5 @@ export { GeminiCLIExecutor } from "./gemini-cli.js"; export { GithubExecutor } from "./github.js"; export { KiroExecutor } from "./kiro.js"; export { CodexExecutor } from "./codex.js"; +export { CursorExecutor } from "./cursor.js"; export { DefaultExecutor } from "./default.js"; diff --git a/open-sse/services/model.js b/open-sse/services/model.js index 4db265b..4057470 100644 --- a/open-sse/services/model.js +++ b/open-sse/services/model.js @@ -8,11 +8,13 @@ const ALIAS_TO_PROVIDER_ID = { ag: "antigravity", gh: "github", kr: "kiro", + cu: "cursor", // API Key providers (alias = id) openai: "openai", anthropic: "anthropic", gemini: "gemini", openrouter: "openrouter", + cursor: "cursor", }; /** diff --git a/open-sse/utils/cursorChecksum.js b/open-sse/utils/cursorChecksum.js new file mode 100644 index 0000000..e9585f9 --- /dev/null +++ b/open-sse/utils/cursorChecksum.js @@ -0,0 +1,133 @@ +/** + * Cursor Checksum Utility (Jyh Cipher) + * + * Generates the x-cursor-checksum header required for Cursor API authentication. + * Based on the JavaScript implementation from Cursor IDE. + */ + +import crypto from "crypto"; +import { v5 as uuidv5 } from "uuid"; + +/** + * Generate SHA-256 hash like generateHashed64Hex + * @param {string} input - Input string + * @param {string} salt - Optional salt + * @returns {string} - 64-character hex string + */ +export function generateHashed64Hex(input, salt = "") { + return crypto.createHash("sha256").update(input + salt).digest("hex"); +} + +/** + * Generate session ID using UUID v5 with DNS namespace + * @param {string} authToken - Auth token + * @returns {string} - UUID string + */ +export function generateSessionId(authToken) { + return uuidv5(authToken, uuidv5.DNS); +} + +/** + * Generate cursor checksum (Jyh cipher) + * + * Algorithm: + * 1. Get Unix timestamp in specific format + * 2. XOR each byte with key (starting 165) + * 3. Update key: key = (key + byte) & 0xFF + * 4. URL-safe base64 encode + * 5. Format: {base64_encoded}{machineId} + * + * @param {string} machineId - Machine ID from Cursor storage or generated + * @returns {string} - Checksum string + */ +export function generateCursorChecksum(machineId) { + // Math.floor(Date.now() / 1e6) - same as Python implementation + const timestamp = Math.floor(Date.now() / 1000000); + + // Create byte array from timestamp (6 bytes, big-endian) + 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}`; +} + +/** + * Build all Cursor API headers + * + * @param {string} accessToken - Bearer token + * @param {string} machineId - Machine ID (or will be generated from token) + * @param {boolean} ghostMode - Enable ghost mode (privacy) + * @returns {Object} - Headers object + */ +export function buildCursorHeaders(accessToken, machineId = null, ghostMode = true) { + // Clean token if it has prefix + const cleanToken = accessToken.includes("::") + ? accessToken.split("::")[1] + : accessToken; + + // Generate machine ID if not provided + const effectiveMachineId = machineId || generateHashed64Hex(cleanToken, "machineId"); + + // Generate derived values + const sessionId = generateSessionId(cleanToken); + const clientKey = generateHashed64Hex(cleanToken); + const checksum = generateCursorChecksum(effectiveMachineId); + + 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": clientKey, + "x-cursor-checksum": checksum, + "x-cursor-client-version": "1.1.3", + "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": sessionId, + "Host": "api2.cursor.sh" + }; +} + +export default { + generateCursorChecksum, + buildCursorHeaders, + generateHashed64Hex, + generateSessionId +}; diff --git a/open-sse/utils/cursorProtobuf.js b/open-sse/utils/cursorProtobuf.js new file mode 100644 index 0000000..57a55e1 --- /dev/null +++ b/open-sse/utils/cursorProtobuf.js @@ -0,0 +1,539 @@ +/** + * Cursor Protobuf Encoding/Decoding Utility + * + * Implements protobuf wire format encoding for Cursor API requests + * and decoding for streaming responses. + * + * Wire format reference: + * - Wire type 0: Varint (int32, int64, uint32, uint64, bool, enum) + * - Wire type 2: Length-delimited (string, bytes, embedded messages) + */ + +import { v4 as uuidv4 } from "uuid"; +import zlib from "zlib"; + +// ============================================================================= +// Encoding Functions +// ============================================================================= + +/** + * Encode an integer as a varint + * @param {number} value - Integer to encode + * @returns {Uint8Array} - Encoded bytes + */ +export function encodeVarint(value) { + const bytes = []; + while (value >= 0x80) { + bytes.push((value & 0x7F) | 0x80); + value >>>= 7; + } + bytes.push(value & 0x7F); + return new Uint8Array(bytes); +} + +/** + * Encode a protobuf field + * @param {number} fieldNum - Field number + * @param {number} wireType - Wire type (0=varint, 2=length-delimited) + * @param {*} value - Value to encode + * @returns {Uint8Array} - Encoded bytes + */ +export function encodeField(fieldNum, wireType, value) { + const tag = (fieldNum << 3) | wireType; + const tagBytes = encodeVarint(tag); + + if (wireType === 0) { + // Varint + const valueBytes = encodeVarint(value); + const result = new Uint8Array(tagBytes.length + valueBytes.length); + result.set(tagBytes); + result.set(valueBytes, tagBytes.length); + return result; + } else if (wireType === 2) { + // Length-delimited (string, bytes, nested message) + let dataBytes; + if (typeof value === "string") { + dataBytes = new TextEncoder().encode(value); + } else if (value instanceof Uint8Array) { + dataBytes = value; + } else if (Buffer.isBuffer(value)) { + dataBytes = new Uint8Array(value); + } else { + dataBytes = new Uint8Array(0); + } + + const lengthBytes = encodeVarint(dataBytes.length); + const result = new Uint8Array(tagBytes.length + lengthBytes.length + dataBytes.length); + result.set(tagBytes); + result.set(lengthBytes, tagBytes.length); + result.set(dataBytes, tagBytes.length + lengthBytes.length); + return result; + } + + return new Uint8Array(0); +} + +/** + * Concatenate multiple Uint8Arrays + * @param {...Uint8Array} arrays - Arrays to concatenate + * @returns {Uint8Array} - Concatenated array + */ +function concatArrays(...arrays) { + const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0); + const result = new Uint8Array(totalLength); + let offset = 0; + for (const arr of arrays) { + result.set(arr, offset); + offset += arr.length; + } + return result; +} + +/** + * Encode a Message (conversation message) + * + * Schema: + * string content = 1; + * int32 role = 2; + * string messageId = 13; + * int32 chatModeEnum = 47; (only for user) + */ +export function encodeMessage(content, role, messageId, chatModeEnum = null) { + const parts = []; + + // Field 1: content (string) + parts.push(encodeField(1, 2, content)); + + // Field 2: role (int32) - 1=user, 2=assistant + parts.push(encodeField(2, 0, role)); + + // Field 13: messageId (string) + parts.push(encodeField(13, 2, messageId)); + + // Field 47: chatModeEnum (only for user messages) + if (chatModeEnum !== null) { + parts.push(encodeField(47, 0, chatModeEnum)); + } + + return concatArrays(...parts); +} + +/** + * Encode Instruction message + * Schema: string instruction = 1; + */ +export function encodeInstruction(instructionText) { + if (!instructionText) return new Uint8Array(0); + return encodeField(1, 2, instructionText); +} + +/** + * Encode Model message + * Schema: + * string name = 1; + * bytes empty = 4; + */ +export function encodeModel(modelName) { + return concatArrays( + encodeField(1, 2, modelName), + encodeField(4, 2, new Uint8Array(0)) + ); +} + +/** + * Encode CursorSetting message + */ +export function encodeCursorSetting() { + // Unknown6 nested message + const unknown6 = concatArrays( + encodeField(1, 2, new Uint8Array(0)), + encodeField(2, 2, new Uint8Array(0)) + ); + + return concatArrays( + encodeField(1, 2, "cursor\\aisettings"), + encodeField(3, 2, new Uint8Array(0)), + encodeField(6, 2, unknown6), + encodeField(8, 0, 1), + encodeField(9, 0, 1) + ); +} + +/** + * Encode Metadata message + */ +export function encodeMetadata() { + return concatArrays( + encodeField(1, 2, process.platform || "linux"), + encodeField(2, 2, process.arch || "x64"), + encodeField(3, 2, process.version || "v20.0.0"), + encodeField(4, 2, process.cwd?.() || "/"), + encodeField(5, 2, new Date().toISOString()) + ); +} + +/** + * Encode MessageId message + */ +export function encodeMessageId(messageId, role, summaryId = null) { + const parts = [ + encodeField(1, 2, messageId), + ]; + + if (summaryId) { + parts.push(encodeField(2, 2, summaryId)); + } + + parts.push(encodeField(3, 0, role)); + + return concatArrays(...parts); +} + +/** + * Encode the Request message (inner request) + */ +export function encodeRequest(messages, modelName) { + const parts = []; + const formattedMessages = []; + const messageIds = []; + + // Format messages + for (const msg of messages) { + const role = msg.role === "user" ? 1 : 2; + const msgId = uuidv4(); + + formattedMessages.push({ + content: msg.content, + role, + messageId: msgId, + chatModeEnum: role === 1 ? 1 : null // Only for user messages + }); + + messageIds.push({ messageId: msgId, role }); + } + + // Field 1: repeated Message messages + for (const fm of formattedMessages) { + const messageBytes = encodeMessage(fm.content, fm.role, fm.messageId, fm.chatModeEnum); + parts.push(encodeField(1, 2, messageBytes)); + } + + // Field 2: unknown2 = 1 + parts.push(encodeField(2, 0, 1)); + + // Field 3: Instruction + parts.push(encodeField(3, 2, encodeInstruction(""))); + + // Field 4: unknown4 = 1 + parts.push(encodeField(4, 0, 1)); + + // Field 5: Model - always send, even for "default" + if (modelName) { + parts.push(encodeField(5, 2, encodeModel(modelName))); + } + + // Field 8: webTool = "" + parts.push(encodeField(8, 2, "")); + + // Field 13: unknown13 = 1 + parts.push(encodeField(13, 0, 1)); + + // Field 15: CursorSetting + parts.push(encodeField(15, 2, encodeCursorSetting())); + + // Field 19: unknown19 = 1 + parts.push(encodeField(19, 0, 1)); + + // Field 23: conversationId + parts.push(encodeField(23, 2, uuidv4())); + + // Field 26: Metadata + parts.push(encodeField(26, 2, encodeMetadata())); + + // Field 27: unknown27 = 0 + parts.push(encodeField(27, 0, 0)); + + // Field 30: repeated MessageId + for (const mid of messageIds) { + parts.push(encodeField(30, 2, encodeMessageId(mid.messageId, mid.role))); + } + + // Field 35: largeContext = 0 + parts.push(encodeField(35, 0, 0)); + + // Field 38: unknown38 = 0 + parts.push(encodeField(38, 0, 0)); + + // Field 46: chatModeEnum = 1 + parts.push(encodeField(46, 0, 1)); + + // Field 47: unknown47 = "" + parts.push(encodeField(47, 2, "")); + + // Field 48-51, 53 + parts.push(encodeField(48, 0, 0)); + parts.push(encodeField(49, 0, 0)); + parts.push(encodeField(51, 0, 0)); + parts.push(encodeField(53, 0, 1)); + + // Field 54: chatMode = "Ask" + parts.push(encodeField(54, 2, "Ask")); + + return concatArrays(...parts); +} + +/** + * Build the full StreamUnifiedChatWithToolsRequest + */ +export function buildChatRequest(messages, modelName) { + // Field 1: Request request + const requestBytes = encodeRequest(messages, modelName); + return encodeField(1, 2, requestBytes); +} + +/** + * Wrap payload with ConnectRPC frame header + * + * Frame format: [flags:1][length:4][payload] + * - flags: 0x00 = uncompressed, 0x01 = gzip compressed + * - length: big-endian 32-bit length + * + * @param {Uint8Array} payload - Protobuf payload + * @param {boolean} compress - Whether to gzip compress (for messages >= 3) + * @returns {Uint8Array} - Framed data + */ +export function wrapConnectRPCFrame(payload, compress = false) { + let finalPayload = payload; + let flags = 0x00; + + if (compress) { + finalPayload = new Uint8Array(zlib.gzipSync(Buffer.from(payload))); + flags = 0x01; + } + + // Create frame: [flags:1][length:4][payload] + const frame = new Uint8Array(5 + finalPayload.length); + frame[0] = flags; + + // Big-endian length + const length = finalPayload.length; + frame[1] = (length >> 24) & 0xFF; + frame[2] = (length >> 16) & 0xFF; + frame[3] = (length >> 8) & 0xFF; + frame[4] = length & 0xFF; + + frame.set(finalPayload, 5); + return frame; +} + +/** + * Generate complete Cursor request body + * @param {Array} messages - Array of {role, content} messages + * @param {string} modelName - Model name + * @returns {Uint8Array} - Complete request body + */ +export function generateCursorBody(messages, modelName) { + const protobuf = buildChatRequest(messages, modelName); + + // Compress if >= 3 messages + const shouldCompress = messages.length >= 3; + return wrapConnectRPCFrame(protobuf, shouldCompress); +} + +// ============================================================================= +// Decoding Functions +// ============================================================================= + +/** + * Decode a varint from buffer + * @param {Uint8Array} buffer - Input buffer + * @param {number} offset - Start offset + * @returns {[number, number]} - [value, newOffset] + */ +export function decodeVarint(buffer, offset) { + let result = 0; + let shift = 0; + let pos = offset; + + while (pos < buffer.length) { + const b = buffer[pos]; + result |= (b & 0x7F) << shift; + pos++; + if (!(b & 0x80)) break; + shift += 7; + } + + return [result, pos]; +} + +/** + * Decode a single protobuf field + * @param {Uint8Array} buffer - Input buffer + * @param {number} offset - Start offset + * @returns {[number, number, any, number]} - [fieldNum, wireType, value, newOffset] + */ +export function decodeField(buffer, offset) { + if (offset >= buffer.length) { + return [null, null, null, offset]; + } + + const [tag, pos1] = decodeVarint(buffer, offset); + const fieldNum = tag >> 3; + const wireType = tag & 0x07; + + let value; + let pos = pos1; + + if (wireType === 0) { + // Varint + [value, pos] = decodeVarint(buffer, pos); + } else if (wireType === 2) { + // Length-delimited + const [length, pos2] = decodeVarint(buffer, pos); + value = buffer.slice(pos2, pos2 + length); + pos = pos2 + length; + } else if (wireType === 1) { + // Fixed64 + value = buffer.slice(pos, pos + 8); + pos += 8; + } else if (wireType === 5) { + // Fixed32 + value = buffer.slice(pos, pos + 4); + pos += 4; + } else { + value = null; + } + + return [fieldNum, wireType, value, pos]; +} + +/** + * Decode all fields from a protobuf message + * @param {Uint8Array} data - Protobuf data + * @returns {Map} - Map of fieldNum -> [{wireType, value}] + */ +export function decodeMessage(data) { + const fields = new Map(); + let pos = 0; + + while (pos < data.length) { + const [fieldNum, wireType, value, newPos] = decodeField(data, pos); + if (fieldNum === null) break; + + if (!fields.has(fieldNum)) { + fields.set(fieldNum, []); + } + fields.get(fieldNum).push({ wireType, value }); + pos = newPos; + } + + return fields; +} + +/** + * Parse ConnectRPC frame + * @param {Uint8Array} buffer - Input buffer + * @returns {{flags: number, length: number, payload: Uint8Array, consumed: number} | null} + */ +export function parseConnectRPCFrame(buffer) { + if (buffer.length < 5) return null; + + const flags = buffer[0]; + const length = (buffer[1] << 24) | (buffer[2] << 16) | (buffer[3] << 8) | buffer[4]; + + if (buffer.length < 5 + length) return null; + + let payload = buffer.slice(5, 5 + length); + + // Decompress if gzip flag is set + if (flags === 0x01) { + try { + payload = new Uint8Array(zlib.gunzipSync(Buffer.from(payload))); + } catch { + // Decompression failed, return raw + } + } + + return { + flags, + length, + payload, + consumed: 5 + length + }; +} + +/** + * Extract text content or error from response protobuf + * + * Response structure (from cursor-grpc/server_full.proto): + * + * message StreamUnifiedChatResponseWithTools { + * oneof response { + * ClientSideToolV2Call client_side_tool_v2_call = 1; + * StreamUnifiedChatResponse stream_unified_chat_response = 2; + * } + * } + * + * message StreamUnifiedChatResponse { + * string text = 1; // <-- THE TEXT WE NEED + * } + * + * @param {Uint8Array} payload - Decoded protobuf payload + * @returns {{text: string|null, error: string|null}} - Extracted content + */ +export function extractTextFromResponse(payload) { + try { + const fields = decodeMessage(payload); + + // Field 2 = StreamUnifiedChatResponse (contains the text) + if (fields.has(2)) { + for (const { wireType, value } of fields.get(2)) { + if (wireType === 2) { + // Decode nested StreamUnifiedChatResponse + try { + const nested = decodeMessage(value); + + // Field 1 = text (string) + if (nested.has(1)) { + for (const { wireType: nwt, value: nv } of nested.get(1)) { + if (nwt === 2) { + try { + const text = new TextDecoder().decode(nv); + // Return any non-empty text + if (text && text.length > 0) { + return { text, error: null }; + } + } catch {} + } + } + } + } catch {} + } + } + } + + // Field 1 could be ClientSideToolV2Call (skip for now) + // Field 3 could be ConversationSummary (skip for now) + + return { text: null, error: null }; + } catch { + return { text: null, error: null }; + } +} + +export default { + // Encoding + encodeVarint, + encodeField, + encodeMessage, + buildChatRequest, + wrapConnectRPCFrame, + generateCursorBody, + + // Decoding + decodeVarint, + decodeField, + decodeMessage, + parseConnectRPCFrame, + extractTextFromResponse +}; diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index 4bf067e..4b1eb91 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -5,7 +5,7 @@ import PropTypes from "prop-types"; import { useParams, useRouter } from "next/navigation"; import Link from "next/link"; import Image from "next/image"; -import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, Toggle, Select } from "@/shared/components"; +import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, CursorAuthModal, Toggle, Select } from "@/shared/components"; import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, getProviderAlias, isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers"; import { getModelsByProviderId } from "@/shared/constants/models"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; @@ -495,6 +495,12 @@ export default function ProviderDetailPage() { onSuccess={handleOAuthSuccess} onClose={() => setShowOAuthModal(false)} /> + ) : providerId === "cursor" ? ( + setShowOAuthModal(false)} + /> ) : ( /Library/Application Support/Cursor/User/globalStorage/state.vscdb", + windows: "%APPDATA%\\Cursor\\User\\globalStorage\\state.vscdb", + }, + // Database keys + dbKeys: { + accessToken: "cursorAuth/accessToken", + machineId: "storage.serviceMachineId", + }, +}; + // OAuth timeout (5 minutes) export const OAUTH_TIMEOUT = 300000; @@ -156,4 +184,5 @@ export const PROVIDERS = { OPENAI: "openai", GITHUB: "github", KIRO: "kiro", + CURSOR: "cursor", }; diff --git a/src/lib/oauth/providers.js b/src/lib/oauth/providers.js index fa41072..0ff356b 100644 --- a/src/lib/oauth/providers.js +++ b/src/lib/oauth/providers.js @@ -13,6 +13,7 @@ import { ANTIGRAVITY_CONFIG, GITHUB_CONFIG, KIRO_CONFIG, + CURSOR_CONFIG, } from "./constants/oauth"; // Provider configurations @@ -656,6 +657,22 @@ const PROVIDERS = { }, }), }, + + cursor: { + config: CURSOR_CONFIG, + flowType: "import_token", + // Cursor uses import token flow - tokens are extracted from local SQLite database + // No OAuth flow needed, handled by /api/oauth/cursor/import route + mapTokens: (tokens) => ({ + accessToken: tokens.accessToken, + refreshToken: null, // Cursor doesn't have public refresh endpoint + expiresIn: tokens.expiresIn || 86400, + providerSpecificData: { + machineId: tokens.machineId, + authMethod: "imported", + }, + }), + }, }; /** diff --git a/src/lib/oauth/services/cursor.js b/src/lib/oauth/services/cursor.js new file mode 100644 index 0000000..167eacd --- /dev/null +++ b/src/lib/oauth/services/cursor.js @@ -0,0 +1,179 @@ +import { CURSOR_CONFIG } from "../constants/oauth.js"; + +/** + * Cursor IDE OAuth Service + * Supports Import Token method from Cursor IDE's local SQLite database + * + * Token Location: + * - Linux: ~/.config/Cursor/User/globalStorage/state.vscdb + * - macOS: /Users//Library/Application Support/Cursor/User/globalStorage/state.vscdb + * - Windows: %APPDATA%\Cursor\User\globalStorage\state.vscdb + * + * Database Keys: + * - cursorAuth/accessToken: The access token + * - storage.serviceMachineId: Machine ID for checksum + */ + +export class CursorService { + constructor() { + this.config = CURSOR_CONFIG; + } + + /** + * Generate Cursor checksum (jyh cipher) + * Algorithm: XOR timestamp bytes with rolling key (initial 165), then base64 encode + * Format: {encoded_timestamp},{machineId} + */ + generateChecksum(machineId) { + const timestamp = Math.floor(Date.now() / 1000).toString(); + let key = 165; + const encoded = []; + + for (let i = 0; i < timestamp.length; i++) { + const charCode = timestamp.charCodeAt(i); + encoded.push(charCode ^ key); + key = (key + charCode) & 0xff; // Rolling key update + } + + const base64Encoded = Buffer.from(encoded).toString("base64"); + return `${base64Encoded},${machineId}`; + } + + /** + * Build request headers for Cursor API + */ + buildHeaders(accessToken, machineId, ghostMode = false) { + const checksum = this.generateChecksum(machineId); + + return { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/connect+proto", + "Connect-Protocol-Version": "1", + "x-cursor-client-version": this.config.clientVersion, + "x-cursor-client-type": this.config.clientType, + "x-cursor-client-os": this.detectOS(), + "x-cursor-client-arch": this.detectArch(), + "x-cursor-client-device-type": "desktop", + "x-cursor-checksum": checksum, + "x-ghost-mode": ghostMode ? "true" : "false", + }; + } + + /** + * Detect OS for headers + */ + detectOS() { + if (typeof process !== "undefined") { + const platform = process.platform; + if (platform === "win32") return "windows"; + if (platform === "darwin") return "macos"; + return "linux"; + } + return "linux"; + } + + /** + * Detect architecture for headers + */ + detectArch() { + if (typeof process !== "undefined") { + const arch = process.arch; + if (arch === "x64") return "x86_64"; + if (arch === "arm64") return "aarch64"; + return arch; + } + return "x86_64"; + } + + /** + * Validate and import token from Cursor IDE + * Note: We skip API validation because Cursor API uses complex protobuf format. + * Token will be validated when actually used for requests. + * @param {string} accessToken - Access token from state.vscdb + * @param {string} machineId - Machine ID from state.vscdb + */ + async validateImportToken(accessToken, machineId) { + // Basic validation + if (!accessToken || typeof accessToken !== "string") { + throw new Error("Access token is required"); + } + + if (!machineId || typeof machineId !== "string") { + throw new Error("Machine ID is required"); + } + + // Token format validation (Cursor tokens are typically long strings) + if (accessToken.length < 50) { + throw new Error("Invalid token format. Token appears too short."); + } + + // Machine ID format validation (should be UUID-like) + const uuidRegex = /^[a-f0-9-]{32,}$/i; + if (!uuidRegex.test(machineId.replace(/-/g, ""))) { + throw new Error("Invalid machine ID format. Expected UUID format."); + } + + // Note: We don't validate against API because Cursor uses complex protobuf. + // Token will be validated when used for actual requests. + + return { + accessToken, + machineId, + expiresIn: 86400, // Cursor tokens typically last 24 hours + authMethod: "imported", + }; + } + + /** + * Extract user info from token if possible + * Cursor tokens may contain encoded user info + */ + extractUserInfo(accessToken) { + try { + // Try to decode as JWT + const parts = accessToken.split("."); + if (parts.length === 3) { + let payload = parts[1]; + while (payload.length % 4) { + payload += "="; + } + const decoded = JSON.parse( + Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString() + ); + return { + email: decoded.email || decoded.sub, + userId: decoded.sub || decoded.user_id, + }; + } + } catch { + // Token is not a JWT, that's okay + } + + return null; + } + + /** + * Get token storage path instructions for user + */ + getTokenStorageInstructions() { + return { + title: "How to get your Cursor token", + steps: [ + "1. Open Cursor IDE and make sure you're logged in", + "2. Find the state.vscdb file:", + ` - Linux: ${this.config.tokenStoragePaths.linux}`, + ` - macOS: ${this.config.tokenStoragePaths.macos}`, + ` - Windows: ${this.config.tokenStoragePaths.windows}`, + "3. Open the database with SQLite browser or CLI:", + " sqlite3 state.vscdb \"SELECT value FROM itemTable WHERE key='cursorAuth/accessToken'\"", + "4. Also get the machine ID:", + " sqlite3 state.vscdb \"SELECT value FROM itemTable WHERE key='storage.serviceMachineId'\"", + "5. Paste both values in the form below", + ], + alternativeMethod: [ + "Or use this one-liner to get both values:", + "sqlite3 state.vscdb \"SELECT key, value FROM itemTable WHERE key IN ('cursorAuth/accessToken', 'storage.serviceMachineId')\"", + ], + }; + } +} diff --git a/src/lib/oauth/services/index.js b/src/lib/oauth/services/index.js index 3912864..352762c 100644 --- a/src/lib/oauth/services/index.js +++ b/src/lib/oauth/services/index.js @@ -12,4 +12,5 @@ export { AntigravityService } from "./antigravity.js"; export { OpenAIService } from "./openai.js"; export { GitHubService } from "./github.js"; export { KiroService } from "./kiro.js"; +export { CursorService } from "./cursor.js"; diff --git a/src/shared/components/CursorAuthModal.js b/src/shared/components/CursorAuthModal.js new file mode 100644 index 0000000..e70d217 --- /dev/null +++ b/src/shared/components/CursorAuthModal.js @@ -0,0 +1,201 @@ +"use client"; + +import { useState } from "react"; +import PropTypes from "prop-types"; +import { Modal, Button, Input } from "@/shared/components"; +import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; + +/** + * Cursor Auth Modal + * Import token from Cursor IDE's local SQLite database + * + * Token Location: + * - Linux: ~/.config/Cursor/User/globalStorage/state.vscdb + * - macOS: /Users//Library/Application Support/Cursor/User/globalStorage/state.vscdb + * - Windows: %APPDATA%\Cursor\User\globalStorage\state.vscdb + * + * Database Keys: + * - cursorAuth/accessToken: The access token + * - storage.serviceMachineId: Machine ID for checksum + */ +export default function CursorAuthModal({ isOpen, onSuccess, onClose }) { + const [accessToken, setAccessToken] = useState(""); + const [machineId, setMachineId] = useState(""); + const [error, setError] = useState(null); + const [importing, setImporting] = useState(false); + const { copied, copy } = useCopyToClipboard(); + + const handleImportToken = async () => { + if (!accessToken.trim()) { + setError("Please enter an access token"); + return; + } + + if (!machineId.trim()) { + setError("Please enter a machine ID"); + return; + } + + setImporting(true); + setError(null); + + try { + const res = await fetch("/api/oauth/cursor/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + accessToken: accessToken.trim(), + machineId: machineId.trim(), + }), + }); + + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.error || "Import failed"); + } + + // Success - close modal and trigger refresh + onSuccess?.(); + onClose(); + } catch (err) { + setError(err.message); + } finally { + setImporting(false); + } + }; + + const linuxCommand = `sqlite3 ~/.config/Cursor/User/globalStorage/state.vscdb "SELECT key, value FROM itemTable WHERE key IN ('cursorAuth/accessToken', 'storage.serviceMachineId')"`; + const macCommand = `sqlite3 "/Users/$USER/Library/Application Support/Cursor/User/globalStorage/state.vscdb" "SELECT key, value FROM itemTable WHERE key IN ('cursorAuth/accessToken', 'storage.serviceMachineId')"`; + + return ( + +
+ {/* Info Box */} +
+
+ info +
+

+ Prerequisites +

+

+ Make sure you are logged in to Cursor IDE first. Tokens are stored in the local SQLite database. +

+
+
+
+ + {/* Instructions */} +
+

How to get your tokens:

+ +
+

Linux:

+
+ + {linuxCommand} + + +
+
+ +
+

macOS:

+
+ + {macCommand} + + +
+
+ +
+

Database locations:

+
    +
  • Linux: ~/.config/Cursor/User/globalStorage/state.vscdb
  • +
  • macOS: /Users/<user>/Library/Application Support/Cursor/User/globalStorage/state.vscdb
  • +
  • Windows: %APPDATA%\Cursor\User\globalStorage\state.vscdb
  • +
+
+
+ + {/* Access Token Input */} +
+ +