mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 06:17:27 +00:00
Merge pull request #971 from kaitranntt/kai/feat/966-cursor-integration-usable
feat: make Cursor integration usable and supportable
This commit is contained in:
25 files changed
+2455
-319
No files matched your search
@@ -1,4 +1,5 @@
|
||||
import type { CursorAuthStatus, CursorDaemonStatus, CursorModel } from '../cursor/types';
|
||||
import type { CursorProbeResult } from '../cursor/cursor-runtime-probe';
|
||||
import type { CursorConfig } from '../config/unified-config-types';
|
||||
import { getCcsDirDisplay } from '../utils/config-manager';
|
||||
import { color } from '../utils/ui';
|
||||
@@ -18,6 +19,7 @@ export function renderCursorHelp(): number {
|
||||
'Subcommands:',
|
||||
' auth Import Cursor IDE authentication token',
|
||||
' status Show integration, authentication, and daemon status',
|
||||
' probe Run a live authenticated runtime probe',
|
||||
' models List available models',
|
||||
' start Start cursor daemon',
|
||||
' stop Stop cursor daemon',
|
||||
@@ -36,8 +38,9 @@ export function renderCursorHelp(): number {
|
||||
' 1. ccs cursor enable # Enable integration',
|
||||
' 2. ccs cursor auth # Import Cursor IDE token',
|
||||
' 3. ccs cursor start # Start daemon',
|
||||
' 4. ccs cursor "task" # Run Claude through Cursor',
|
||||
' 5. ccs cursor status # Inspect auth/daemon wiring',
|
||||
' 4. ccs cursor probe # Verify live runtime health',
|
||||
' 5. ccs cursor "task" # Run Claude through Cursor',
|
||||
' 6. ccs cursor status # Inspect auth/daemon wiring',
|
||||
'',
|
||||
'Or use the web UI: ccs config -> Cursor page',
|
||||
'',
|
||||
@@ -98,6 +101,7 @@ export function renderCursorStatus(
|
||||
console.log('Client setup:');
|
||||
console.log(` Raw settings: ${dirDisplay}/cursor.settings.json`);
|
||||
console.log(' Runtime entry: ccs cursor [claude args]');
|
||||
console.log(' Live probe: ccs cursor probe');
|
||||
console.log(' Status command: ccs cursor status');
|
||||
console.log(' Help command: ccs cursor help');
|
||||
|
||||
@@ -135,3 +139,21 @@ export function renderCursorModels(models: CursorModel[], defaultModel: string):
|
||||
console.log('Model selection is request-driven by the calling client.');
|
||||
console.log('Dashboard: ccs config -> Cursor page');
|
||||
}
|
||||
|
||||
export function renderCursorProbe(result: CursorProbeResult): void {
|
||||
const statusIcon = result.ok ? color('[OK]', 'success') : color('[X]', 'error');
|
||||
console.log('Cursor Live Probe');
|
||||
console.log('─────────────────');
|
||||
console.log('');
|
||||
console.log(`Result: ${statusIcon} ${result.ok ? 'Success' : 'Failure'}`);
|
||||
console.log(`Stage: ${result.stage}`);
|
||||
console.log(`HTTP status: ${result.status}`);
|
||||
console.log(`Duration: ${result.duration_ms} ms`);
|
||||
if (result.model) {
|
||||
console.log(`Model: ${result.model}`);
|
||||
}
|
||||
if (result.error_type) {
|
||||
console.log(`Error type: ${result.error_type}`);
|
||||
}
|
||||
console.log(`Message: ${result.message}`);
|
||||
}
|
||||
@@ -14,10 +14,16 @@ import {
|
||||
getDaemonStatus,
|
||||
getAvailableModels,
|
||||
getDefaultModel,
|
||||
probeCursorRuntime,
|
||||
} from '../cursor';
|
||||
import { getCursorConfig, mutateUnifiedConfig } from '../config/unified-config-loader';
|
||||
import { DEFAULT_CURSOR_CONFIG } from '../config/unified-config-types';
|
||||
import { renderCursorHelp, renderCursorModels, renderCursorStatus } from './cursor-command-display';
|
||||
import {
|
||||
renderCursorHelp,
|
||||
renderCursorModels,
|
||||
renderCursorProbe,
|
||||
renderCursorStatus,
|
||||
} from './cursor-command-display';
|
||||
import { ok, fail, info } from '../utils/ui';
|
||||
|
||||
/**
|
||||
@@ -31,6 +37,8 @@ export async function handleCursorCommand(args: string[]): Promise<number> {
|
||||
return handleAuth(args.slice(1));
|
||||
case 'status':
|
||||
return handleStatus();
|
||||
case 'probe':
|
||||
return handleProbe();
|
||||
case 'models':
|
||||
return handleModels();
|
||||
case 'start':
|
||||
@@ -74,6 +82,34 @@ function parseOptionValue(args: string[], key: string): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function printAutoDetectFailure(result: {
|
||||
error?: string;
|
||||
checkedPaths?: string[];
|
||||
reason?: string;
|
||||
}): void {
|
||||
console.error(fail(`Auto-detection failed: ${result.error ?? 'Unknown error'}`));
|
||||
|
||||
if (result.checkedPaths?.length && result.reason === 'db_not_found') {
|
||||
console.log('');
|
||||
console.log('Checked paths:');
|
||||
for (const candidate of result.checkedPaths) {
|
||||
console.log(` - ${candidate}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.reason === 'sqlite_unavailable') {
|
||||
console.log('');
|
||||
console.log('Recommended next steps:');
|
||||
console.log(' 1. Install sqlite3 so CCS can read Cursor state automatically');
|
||||
console.log(' 2. Or use manual import immediately');
|
||||
} else if (result.reason === 'db_query_failed') {
|
||||
console.log('');
|
||||
console.log('Recommended next steps:');
|
||||
console.log(' 1. Close Cursor IDE and retry auto-detect');
|
||||
console.log(' 2. If the database remains unreadable, use manual import');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle auth subcommand.
|
||||
*/
|
||||
@@ -139,7 +175,7 @@ async function handleAuth(args: string[]): Promise<number> {
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.error(fail(`Auto-detection failed: ${autoResult.error ?? 'Unknown error'}`));
|
||||
printAutoDetectFailure(autoResult);
|
||||
console.log('');
|
||||
console.log('Manual fallback:');
|
||||
console.log(' ccs cursor auth --manual --token <token> --machine-id <machine-id>');
|
||||
@@ -156,6 +192,13 @@ async function handleStatus(): Promise<number> {
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function handleProbe(): Promise<number> {
|
||||
const cursorConfig = getCursorConfig();
|
||||
const result = await probeCursorRuntime(cursorConfig);
|
||||
renderCursorProbe(result);
|
||||
return result.ok ? 0 : 1;
|
||||
}
|
||||
|
||||
async function handleModels(): Promise<number> {
|
||||
const cursorConfig = getCursorConfig();
|
||||
const models = await getAvailableModels(cursorConfig.port);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export const CURSOR_SUBCOMMANDS = [
|
||||
'auth',
|
||||
'status',
|
||||
'probe',
|
||||
'models',
|
||||
'start',
|
||||
'stop',
|
||||
|
||||
+197
-41
@@ -21,6 +21,13 @@ import * as os from 'os';
|
||||
import type { CursorCredentials, CursorAuthStatus, AutoDetectResult } from './types';
|
||||
import { getCcsDir } from '../utils/config-manager';
|
||||
|
||||
const ACCESS_TOKEN_KEYS = ['cursorAuth/accessToken', 'cursorAuth/token'] as const;
|
||||
const MACHINE_ID_KEYS = [
|
||||
'storage.serviceMachineId',
|
||||
'storage.machineId',
|
||||
'telemetry.machineId',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Resolve home directory from environment first for deterministic testability,
|
||||
* then fall back to os.homedir() when env vars are unavailable.
|
||||
@@ -36,98 +43,247 @@ function resolveHomeDir(): string {
|
||||
/**
|
||||
* Get platform-specific path to Cursor's state.vscdb
|
||||
*/
|
||||
export function getTokenStoragePath(): string {
|
||||
export function getTokenStorageCandidates(): string[] {
|
||||
const platform = process.platform;
|
||||
const home = resolveHomeDir();
|
||||
|
||||
if (platform === 'win32') {
|
||||
const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
|
||||
return path.join(appData, 'Cursor', 'User', 'globalStorage', 'state.vscdb');
|
||||
} else if (platform === 'darwin') {
|
||||
return path.join(
|
||||
home,
|
||||
'Library',
|
||||
'Application Support',
|
||||
'Cursor',
|
||||
'User',
|
||||
'globalStorage',
|
||||
'state.vscdb'
|
||||
);
|
||||
} else {
|
||||
// Linux
|
||||
return path.join(home, '.config', 'Cursor', 'User', 'globalStorage', 'state.vscdb');
|
||||
const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
||||
|
||||
return [
|
||||
path.join(appData, 'Cursor', 'User', 'globalStorage', 'state.vscdb'),
|
||||
path.join(appData, 'Cursor - Insiders', 'User', 'globalStorage', 'state.vscdb'),
|
||||
path.join(localAppData, 'Cursor', 'User', 'globalStorage', 'state.vscdb'),
|
||||
path.join(localAppData, 'Programs', 'Cursor', 'User', 'globalStorage', 'state.vscdb'),
|
||||
];
|
||||
}
|
||||
|
||||
if (platform === 'darwin') {
|
||||
return [
|
||||
path.join(
|
||||
home,
|
||||
'Library',
|
||||
'Application Support',
|
||||
'Cursor',
|
||||
'User',
|
||||
'globalStorage',
|
||||
'state.vscdb'
|
||||
),
|
||||
path.join(
|
||||
home,
|
||||
'Library',
|
||||
'Application Support',
|
||||
'Cursor - Insiders',
|
||||
'User',
|
||||
'globalStorage',
|
||||
'state.vscdb'
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
path.join(home, '.config', 'Cursor', 'User', 'globalStorage', 'state.vscdb'),
|
||||
path.join(home, '.config', 'cursor', 'User', 'globalStorage', 'state.vscdb'),
|
||||
];
|
||||
}
|
||||
|
||||
export function getTokenStoragePath(): string {
|
||||
return getTokenStorageCandidates()[0];
|
||||
}
|
||||
|
||||
function normalizeStateValue(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return trimmed;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
return typeof parsed === 'string' ? parsed : trimmed;
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
function getSqliteBinary(): string {
|
||||
return process.env.CCS_CURSOR_SQLITE_BIN || 'sqlite3';
|
||||
}
|
||||
|
||||
/**
|
||||
* Query Cursor's SQLite database using sqlite3 CLI
|
||||
*/
|
||||
function queryStateDb(dbPath: string, key: string): string | null {
|
||||
function queryStateDb(
|
||||
dbPath: string,
|
||||
key: string
|
||||
): { value: string | null; sqliteAvailable: boolean; queryFailed: boolean } {
|
||||
try {
|
||||
// Escape single quotes to prevent SQL injection
|
||||
const sanitizedKey = key.replace(/'/g, "''");
|
||||
const result = execFileSync(
|
||||
'sqlite3',
|
||||
getSqliteBinary(),
|
||||
[dbPath, `SELECT value FROM itemTable WHERE key='${sanitizedKey}'`],
|
||||
{ encoding: 'utf8', timeout: 5000, stdio: ['pipe', 'pipe', 'ignore'] }
|
||||
).trim();
|
||||
return result || null;
|
||||
);
|
||||
|
||||
return {
|
||||
value: normalizeStateValue(result) || null,
|
||||
sqliteAvailable: true,
|
||||
queryFailed: false,
|
||||
};
|
||||
} catch (err) {
|
||||
// Check if sqlite3 is not installed
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
// sqlite3 not found - could log this if needed
|
||||
return null;
|
||||
return { value: null, sqliteAvailable: false, queryFailed: false };
|
||||
}
|
||||
return null;
|
||||
return { value: null, sqliteAvailable: true, queryFailed: true };
|
||||
}
|
||||
}
|
||||
|
||||
function queryStateDbKeys(
|
||||
dbPath: string,
|
||||
keys: readonly string[]
|
||||
): { value: string | null; sqliteAvailable: boolean; queryFailed: boolean } {
|
||||
for (const key of keys) {
|
||||
const result = queryStateDb(dbPath, key);
|
||||
if (!result.sqliteAvailable || result.queryFailed) return result;
|
||||
if (result.value) return result;
|
||||
}
|
||||
|
||||
return { value: null, sqliteAvailable: true, queryFailed: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-detect tokens from Cursor's SQLite database
|
||||
*/
|
||||
export function autoDetectTokens(): AutoDetectResult {
|
||||
// sqlite3 CLI is not bundled with Windows
|
||||
if (process.platform === 'win32') {
|
||||
const checkedPaths = getTokenStorageCandidates();
|
||||
const existingPaths = checkedPaths.filter((candidate) => fs.existsSync(candidate));
|
||||
|
||||
if (existingPaths.length === 0) {
|
||||
return {
|
||||
found: false,
|
||||
checkedPaths,
|
||||
reason: 'db_not_found',
|
||||
error: `Cursor state database not found. Checked:\n${checkedPaths.join('\n')}`,
|
||||
};
|
||||
}
|
||||
|
||||
let sawSqliteUnavailable = false;
|
||||
let sawQueryFailure = false;
|
||||
let sawTokenMissing = false;
|
||||
let sawMachineIdMissing = false;
|
||||
let sawInvalidCredentials = false;
|
||||
let firstQueryFailurePath: string | undefined;
|
||||
let firstInvalidCredentialsPath: string | undefined;
|
||||
|
||||
for (const dbPath of existingPaths) {
|
||||
const accessTokenResult = queryStateDbKeys(dbPath, ACCESS_TOKEN_KEYS);
|
||||
if (!accessTokenResult.sqliteAvailable) {
|
||||
sawSqliteUnavailable = true;
|
||||
continue;
|
||||
}
|
||||
if (accessTokenResult.queryFailed) {
|
||||
sawQueryFailure = true;
|
||||
firstQueryFailurePath ??= dbPath;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!accessTokenResult.value) {
|
||||
sawTokenMissing = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const machineIdResult = queryStateDbKeys(dbPath, MACHINE_ID_KEYS);
|
||||
if (!machineIdResult.sqliteAvailable) {
|
||||
sawSqliteUnavailable = true;
|
||||
continue;
|
||||
}
|
||||
if (machineIdResult.queryFailed) {
|
||||
sawQueryFailure = true;
|
||||
firstQueryFailurePath ??= dbPath;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!machineIdResult.value) {
|
||||
sawMachineIdMissing = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!validateToken(accessTokenResult.value, machineIdResult.value)) {
|
||||
sawInvalidCredentials = true;
|
||||
firstInvalidCredentialsPath ??= dbPath;
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
found: true,
|
||||
accessToken: accessTokenResult.value,
|
||||
machineId: machineIdResult.value,
|
||||
dbPath,
|
||||
checkedPaths,
|
||||
};
|
||||
}
|
||||
|
||||
if (sawSqliteUnavailable) {
|
||||
return {
|
||||
found: false,
|
||||
checkedPaths,
|
||||
dbPath: existingPaths[0],
|
||||
reason: 'sqlite_unavailable',
|
||||
error:
|
||||
'Auto-detection is not supported on Windows. Please import tokens manually using ccs cursor auth --manual.',
|
||||
'Cursor state database was found, but sqlite3 is not available in PATH. Install sqlite3 or use manual import.',
|
||||
};
|
||||
}
|
||||
|
||||
const dbPath = getTokenStoragePath();
|
||||
|
||||
// Check if database exists
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
if (sawQueryFailure) {
|
||||
return {
|
||||
found: false,
|
||||
checkedPaths,
|
||||
dbPath: firstQueryFailurePath ?? existingPaths[0],
|
||||
reason: 'db_query_failed',
|
||||
error:
|
||||
'Cursor state database not found. Make sure Cursor IDE is installed and you are logged in.',
|
||||
'Cursor state database was found, but CCS could not query it. The database may be locked, corrupted, or use an unexpected schema.',
|
||||
};
|
||||
}
|
||||
|
||||
// Try to query access token
|
||||
const accessToken = queryStateDb(dbPath, 'cursorAuth/accessToken');
|
||||
if (!accessToken) {
|
||||
if (sawInvalidCredentials) {
|
||||
return {
|
||||
found: false,
|
||||
error: 'Access token not found in database. Please log in to Cursor IDE first.',
|
||||
checkedPaths,
|
||||
dbPath: firstInvalidCredentialsPath ?? existingPaths[0],
|
||||
reason: 'invalid_token_format',
|
||||
error:
|
||||
'Cursor credentials were found, but the access token or machine ID format was invalid. Re-authenticate in Cursor IDE or use manual import.',
|
||||
};
|
||||
}
|
||||
|
||||
// Try to query machine ID
|
||||
const machineId = queryStateDb(dbPath, 'storage.serviceMachineId');
|
||||
if (!machineId) {
|
||||
if (sawMachineIdMissing) {
|
||||
return {
|
||||
found: false,
|
||||
error: 'Machine ID not found in database.',
|
||||
checkedPaths,
|
||||
dbPath: existingPaths[0],
|
||||
reason: 'machine_id_not_found',
|
||||
error:
|
||||
'Cursor access token was found, but the machine ID was not present in the database. Re-open Cursor IDE or use manual import.',
|
||||
};
|
||||
}
|
||||
|
||||
if (sawTokenMissing) {
|
||||
return {
|
||||
found: false,
|
||||
checkedPaths,
|
||||
dbPath: existingPaths[0],
|
||||
reason: 'access_token_not_found',
|
||||
error:
|
||||
'Access token not found in Cursor state database. Make sure you are logged in to Cursor IDE first.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
found: true,
|
||||
accessToken,
|
||||
machineId,
|
||||
found: false,
|
||||
checkedPaths,
|
||||
dbPath: existingPaths[0],
|
||||
reason: 'db_not_found',
|
||||
error: 'Cursor credentials could not be detected from the discovered database paths.',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+368
-201
@@ -6,10 +6,20 @@
|
||||
import type { IncomingHttpHeaders } from 'http';
|
||||
import { generateCursorBody, extractTextFromResponse } from './cursor-protobuf.js';
|
||||
import { buildCursorRequest } from './cursor-translator.js';
|
||||
import type { CursorTool, CursorApiCredentials } from './cursor-protobuf-schema.js';
|
||||
import {
|
||||
isEndStreamConnectFrame,
|
||||
type CursorTool,
|
||||
type CursorApiCredentials,
|
||||
} from './cursor-protobuf-schema.js';
|
||||
import { buildCursorConnectHeaders, generateCursorChecksum } from './cursor-client-policy.js';
|
||||
|
||||
import { StreamingFrameParser, decompressPayload } from './cursor-stream-parser.js';
|
||||
import {
|
||||
CursorConnectFrameError,
|
||||
type FrameResult,
|
||||
StreamingFrameParser,
|
||||
decompressPayload,
|
||||
mapCursorConnectError,
|
||||
} from './cursor-stream-parser.js';
|
||||
|
||||
/** Executor parameters */
|
||||
interface ExecutorParams {
|
||||
@@ -41,6 +51,13 @@ interface Http2Response {
|
||||
body: Buffer;
|
||||
}
|
||||
|
||||
interface CursorExecutorErrorPayload {
|
||||
message: string;
|
||||
status: number;
|
||||
errorType: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
/** Lazy import http2 */
|
||||
let http2Module: typeof import('http2') | null = null;
|
||||
async function getHttp2() {
|
||||
@@ -59,34 +76,63 @@ async function getHttp2() {
|
||||
/**
|
||||
* Create error response from JSON error
|
||||
*/
|
||||
function createErrorResponse(jsonError: {
|
||||
function toCursorErrorPayloadFromJson(jsonError: {
|
||||
error?: {
|
||||
code?: string;
|
||||
message?: string;
|
||||
details?: Array<{ debug?: { details?: { title?: string; detail?: string }; error?: string } }>;
|
||||
};
|
||||
}): Response {
|
||||
}): CursorExecutorErrorPayload {
|
||||
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';
|
||||
const mappedError = mapCursorConnectError(jsonError?.error?.code);
|
||||
|
||||
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' },
|
||||
}
|
||||
);
|
||||
return {
|
||||
message: errorMsg,
|
||||
status: mappedError.status,
|
||||
errorType: mappedError.errorType,
|
||||
code: jsonError?.error?.details?.[0]?.debug?.error || 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
function buildCursorErrorEnvelope(error: CursorExecutorErrorPayload): string {
|
||||
return JSON.stringify({
|
||||
error: {
|
||||
message: error.message,
|
||||
type: error.errorType,
|
||||
code: error.code,
|
||||
status: error.status,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createCursorErrorResponse(error: CursorExecutorErrorPayload): Response {
|
||||
return new Response(buildCursorErrorEnvelope(error), {
|
||||
status: error.status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
function toCursorExecutorErrorPayload(error: unknown): CursorExecutorErrorPayload {
|
||||
if (error instanceof CursorConnectFrameError) {
|
||||
return {
|
||||
message: error.message,
|
||||
status: error.status,
|
||||
errorType: error.errorType,
|
||||
code: 'cursor_protocol_error',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
message: error instanceof Error ? error.message : 'Cursor streaming failed.',
|
||||
status: 502,
|
||||
errorType: 'server_error',
|
||||
code: 'cursor_error',
|
||||
};
|
||||
}
|
||||
|
||||
export class CursorExecutor {
|
||||
@@ -413,180 +459,243 @@ export class CursorExecutor {
|
||||
index: number;
|
||||
}
|
||||
>();
|
||||
const pendingPackets: string[] = [];
|
||||
let chunkCount = 0;
|
||||
let toolCallCount = 0;
|
||||
let streamResponseResolved = false;
|
||||
|
||||
const flushPendingPackets = () => {
|
||||
if (!streamController || pendingPackets.length === 0 || streamClosed) return;
|
||||
for (const packet of pendingPackets.splice(0)) {
|
||||
streamController.enqueue(enc.encode(packet));
|
||||
}
|
||||
};
|
||||
|
||||
const queuePacket = (packet: string) => {
|
||||
if (streamClosed) return;
|
||||
if (streamController) {
|
||||
streamController.enqueue(enc.encode(packet));
|
||||
return;
|
||||
}
|
||||
pendingPackets.push(packet);
|
||||
};
|
||||
|
||||
const emitSSE = (data: string) => {
|
||||
queuePacket(`data: ${data}\n\n`);
|
||||
};
|
||||
|
||||
const emitSSEEvent = (event: string, data: string) => {
|
||||
queuePacket(`event: ${event}\ndata: ${data}\n\n`);
|
||||
};
|
||||
|
||||
const readable = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
streamController = controller;
|
||||
|
||||
const emitSSE = (data: string) => {
|
||||
if (streamClosed) return;
|
||||
controller.enqueue(enc.encode(`data: ${data}\n\n`));
|
||||
};
|
||||
|
||||
const closeStream = () => {
|
||||
if (streamClosed) return;
|
||||
streamClosed = true;
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
client.close();
|
||||
};
|
||||
|
||||
const buildChunk = (delta: Record<string, unknown>, finishReason: string | null) =>
|
||||
JSON.stringify({
|
||||
id: responseId,
|
||||
object: 'chat.completion.chunk',
|
||||
created,
|
||||
model,
|
||||
choices: [{ index: 0, delta, finish_reason: finishReason }],
|
||||
});
|
||||
|
||||
req.on('data', (chunk: Buffer) => {
|
||||
if (streamClosed) return;
|
||||
for (const frame of parser.push(chunk)) {
|
||||
if (frame.type === 'error') {
|
||||
emitSSE(
|
||||
JSON.stringify({
|
||||
error: { message: frame.message, type: frame.errorType, code: '' },
|
||||
})
|
||||
);
|
||||
emitSSE('[DONE]');
|
||||
closeStream();
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.type === 'toolCall') {
|
||||
const tc = frame.toolCall;
|
||||
|
||||
// Emit role chunk on first content
|
||||
if (chunkCount === 0) {
|
||||
emitSSE(buildChunk({ role: 'assistant', content: '' }, null));
|
||||
chunkCount++;
|
||||
}
|
||||
|
||||
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) {
|
||||
emitSSE(
|
||||
buildChunk(
|
||||
{
|
||||
tool_calls: [
|
||||
{
|
||||
index: existing.index,
|
||||
id: tc.id,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: tc.function.name,
|
||||
arguments: tc.function.arguments,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
null
|
||||
)
|
||||
);
|
||||
chunkCount++;
|
||||
}
|
||||
} else {
|
||||
const idx = toolCallCount++;
|
||||
toolCallsMap.set(tc.id, { ...tc, index: idx });
|
||||
emitSSE(
|
||||
buildChunk(
|
||||
{
|
||||
tool_calls: [
|
||||
{
|
||||
index: idx,
|
||||
id: tc.id,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: tc.function.name,
|
||||
arguments: tc.function.arguments,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
null
|
||||
)
|
||||
);
|
||||
chunkCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (frame.type === 'text') {
|
||||
const delta =
|
||||
chunkCount === 0 && toolCallCount === 0
|
||||
? { role: 'assistant', content: frame.text }
|
||||
: { content: frame.text };
|
||||
emitSSE(buildChunk(delta, null));
|
||||
chunkCount++;
|
||||
}
|
||||
|
||||
if (frame.type === 'thinking') {
|
||||
const delta =
|
||||
chunkCount === 0 && toolCallCount === 0
|
||||
? { role: 'assistant', reasoning_content: frame.text }
|
||||
: { reasoning_content: frame.text };
|
||||
emitSSE(buildChunk(delta, null));
|
||||
chunkCount++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
req.on('end', () => {
|
||||
if (streamClosed) return;
|
||||
if (chunkCount === 0 && toolCallCount === 0) {
|
||||
emitSSE(buildChunk({ role: 'assistant', content: '' }, null));
|
||||
}
|
||||
emitSSE(
|
||||
JSON.stringify({
|
||||
id: responseId,
|
||||
object: 'chat.completion.chunk',
|
||||
created,
|
||||
model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {},
|
||||
finish_reason: toolCallCount > 0 ? 'tool_calls' : 'stop',
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
||||
})
|
||||
);
|
||||
emitSSE('[DONE]');
|
||||
closeStream();
|
||||
});
|
||||
|
||||
req.on('error', (err) => {
|
||||
if (!streamClosed) {
|
||||
try {
|
||||
controller.error(err);
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
}
|
||||
client.close();
|
||||
});
|
||||
flushPendingPackets();
|
||||
},
|
||||
});
|
||||
|
||||
resolveOnce(
|
||||
new Response(readable, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
})
|
||||
);
|
||||
const resolveStreamingResponse = () => {
|
||||
if (streamResponseResolved || settled) return;
|
||||
streamResponseResolved = true;
|
||||
resolveOnce(
|
||||
new Response(readable, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
})
|
||||
);
|
||||
flushPendingPackets();
|
||||
};
|
||||
|
||||
const closeStream = () => {
|
||||
if (streamClosed) return;
|
||||
streamClosed = true;
|
||||
if (streamController) {
|
||||
try {
|
||||
streamController.close();
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
}
|
||||
client.close();
|
||||
};
|
||||
|
||||
const buildChunk = (delta: Record<string, unknown>, finishReason: string | null) =>
|
||||
JSON.stringify({
|
||||
id: responseId,
|
||||
object: 'chat.completion.chunk',
|
||||
created,
|
||||
model,
|
||||
choices: [{ index: 0, delta, finish_reason: finishReason }],
|
||||
});
|
||||
|
||||
const handleFrameError = (frame: Extract<FrameResult, { type: 'error' }>) => {
|
||||
const errorPayload = buildCursorErrorEnvelope({
|
||||
message: frame.message,
|
||||
status: frame.status,
|
||||
errorType: frame.errorType,
|
||||
code: frame.errorType === 'rate_limit_error' ? 'rate_limited' : 'cursor_error',
|
||||
});
|
||||
|
||||
if (!streamResponseResolved && chunkCount === 0 && toolCallCount === 0) {
|
||||
streamClosed = true;
|
||||
req.close();
|
||||
client.close();
|
||||
resolveOnce(
|
||||
new Response(errorPayload, {
|
||||
status: frame.status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
resolveStreamingResponse();
|
||||
emitSSEEvent('error', errorPayload);
|
||||
closeStream();
|
||||
};
|
||||
|
||||
req.on('data', (chunk: Buffer) => {
|
||||
if (streamClosed) return;
|
||||
for (const frame of parser.push(chunk)) {
|
||||
if (frame.type === 'error') {
|
||||
handleFrameError(frame);
|
||||
return;
|
||||
}
|
||||
|
||||
resolveStreamingResponse();
|
||||
|
||||
if (frame.type === 'toolCall') {
|
||||
const tc = frame.toolCall;
|
||||
|
||||
// Emit role chunk on first content
|
||||
if (chunkCount === 0) {
|
||||
emitSSE(buildChunk({ role: 'assistant', content: '' }, null));
|
||||
chunkCount++;
|
||||
}
|
||||
|
||||
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) {
|
||||
emitSSE(
|
||||
buildChunk(
|
||||
{
|
||||
tool_calls: [
|
||||
{
|
||||
index: existing.index,
|
||||
id: tc.id,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: tc.function.name,
|
||||
arguments: tc.function.arguments,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
null
|
||||
)
|
||||
);
|
||||
chunkCount++;
|
||||
}
|
||||
} else {
|
||||
const idx = toolCallCount++;
|
||||
toolCallsMap.set(tc.id, { ...tc, index: idx });
|
||||
emitSSE(
|
||||
buildChunk(
|
||||
{
|
||||
tool_calls: [
|
||||
{
|
||||
index: idx,
|
||||
id: tc.id,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: tc.function.name,
|
||||
arguments: tc.function.arguments,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
null
|
||||
)
|
||||
);
|
||||
chunkCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (frame.type === 'text') {
|
||||
const delta =
|
||||
chunkCount === 0 && toolCallCount === 0
|
||||
? { role: 'assistant', content: frame.text }
|
||||
: { content: frame.text };
|
||||
emitSSE(buildChunk(delta, null));
|
||||
chunkCount++;
|
||||
}
|
||||
|
||||
if (frame.type === 'thinking') {
|
||||
const delta =
|
||||
chunkCount === 0 && toolCallCount === 0
|
||||
? { role: 'assistant', reasoning_content: frame.text }
|
||||
: { reasoning_content: frame.text };
|
||||
emitSSE(buildChunk(delta, null));
|
||||
chunkCount++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
req.on('end', () => {
|
||||
if (streamClosed) return;
|
||||
for (const frame of parser.finish()) {
|
||||
if (frame.type === 'error') {
|
||||
handleFrameError(frame);
|
||||
return;
|
||||
}
|
||||
}
|
||||
resolveStreamingResponse();
|
||||
if (chunkCount === 0 && toolCallCount === 0) {
|
||||
emitSSE(buildChunk({ role: 'assistant', content: '' }, null));
|
||||
}
|
||||
emitSSE(
|
||||
JSON.stringify({
|
||||
id: responseId,
|
||||
object: 'chat.completion.chunk',
|
||||
created,
|
||||
model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {},
|
||||
finish_reason: toolCallCount > 0 ? 'tool_calls' : 'stop',
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
||||
})
|
||||
);
|
||||
emitSSE('[DONE]');
|
||||
closeStream();
|
||||
});
|
||||
|
||||
req.on('error', (err) => {
|
||||
client.close();
|
||||
if (!streamResponseResolved) {
|
||||
rejectOnce(err instanceof Error ? err : new Error(String(err)));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!streamClosed) {
|
||||
try {
|
||||
streamController?.error(err);
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (err) => {
|
||||
@@ -604,7 +713,7 @@ export class CursorExecutor {
|
||||
* Shared logic between JSON and SSE transformers.
|
||||
*/
|
||||
private *parseProtobufFrames(buffer: Buffer): Generator<
|
||||
| { type: 'error'; response: Response }
|
||||
| { type: 'error'; error: CursorExecutorErrorPayload }
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'thinking'; text: string }
|
||||
| {
|
||||
@@ -630,13 +739,54 @@ export class CursorExecutor {
|
||||
let payload = buffer.slice(offset + 5, offset + 5 + length);
|
||||
offset += 5 + length;
|
||||
|
||||
payload = decompressPayload(payload, flags);
|
||||
try {
|
||||
payload = decompressPayload(payload, flags);
|
||||
} catch (error) {
|
||||
yield { type: 'error', error: toCursorExecutorErrorPayload(error) };
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEndStreamConnectFrame(flags)) {
|
||||
try {
|
||||
const json = JSON.parse(payload.toString('utf-8')) as {
|
||||
error?: {
|
||||
code?: string;
|
||||
message?: string;
|
||||
details?: Array<{
|
||||
debug?: { details?: { title?: string; detail?: string }; error?: string };
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
const msg =
|
||||
json?.error?.details?.[0]?.debug?.details?.title ||
|
||||
json?.error?.details?.[0]?.debug?.details?.detail ||
|
||||
json?.error?.message;
|
||||
|
||||
if (msg) {
|
||||
const mappedError = mapCursorConnectError(json?.error?.code);
|
||||
yield {
|
||||
type: 'error',
|
||||
error: {
|
||||
message: msg,
|
||||
status: mappedError.status,
|
||||
errorType: mappedError.errorType,
|
||||
code: json?.error?.code || 'cursor_error',
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Ignore successful end-stream metadata trailers.
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 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)) };
|
||||
yield { type: 'error', error: toCursorErrorPayloadFromJson(JSON.parse(text)) };
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -656,19 +806,12 @@ export class CursorExecutor {
|
||||
errorLower.includes('too many requests');
|
||||
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' },
|
||||
}
|
||||
),
|
||||
error: {
|
||||
message: result.error,
|
||||
status: isRateLimit ? 429 : 502,
|
||||
errorType: isRateLimit ? 'rate_limit_error' : 'server_error',
|
||||
code: isRateLimit ? 'rate_limited' : 'cursor_error',
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
@@ -685,6 +828,18 @@ export class CursorExecutor {
|
||||
yield { type: 'thinking', text: result.thinking };
|
||||
}
|
||||
}
|
||||
|
||||
if (offset !== buffer.length) {
|
||||
yield {
|
||||
type: 'error',
|
||||
error: {
|
||||
message: 'Truncated Cursor ConnectRPC frame.',
|
||||
status: 502,
|
||||
errorType: 'server_error',
|
||||
code: 'cursor_protocol_error',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
transformProtobufToJSON(buffer: Buffer, model: string, _body: ExecutorParams['body']): Response {
|
||||
@@ -711,7 +866,7 @@ export class CursorExecutor {
|
||||
|
||||
for (const frame of this.parseProtobufFrames(buffer)) {
|
||||
if (frame.type === 'error') {
|
||||
return frame.response;
|
||||
return createCursorErrorResponse(frame.error);
|
||||
}
|
||||
|
||||
if (frame.type === 'toolCall') {
|
||||
@@ -840,7 +995,19 @@ export class CursorExecutor {
|
||||
|
||||
for (const frame of this.parseProtobufFrames(buffer)) {
|
||||
if (frame.type === 'error') {
|
||||
return frame.response;
|
||||
if (chunks.length === 0 && toolCalls.length === 0) {
|
||||
return createCursorErrorResponse(frame.error);
|
||||
}
|
||||
|
||||
chunks.push(`event: error\ndata: ${buildCursorErrorEnvelope(frame.error)}\n\n`);
|
||||
return new Response(chunks.join(''), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (frame.type === 'toolCall') {
|
||||
|
||||
@@ -4,7 +4,12 @@
|
||||
*/
|
||||
|
||||
import * as zlib from 'zlib';
|
||||
import { WIRE_TYPE, FIELD, COMPRESS_FLAG, type WireType } from './cursor-protobuf-schema.js';
|
||||
import {
|
||||
WIRE_TYPE,
|
||||
FIELD,
|
||||
isCompressedConnectFrame,
|
||||
type WireType,
|
||||
} from './cursor-protobuf-schema.js';
|
||||
|
||||
/**
|
||||
* Decode a varint from buffer
|
||||
@@ -121,11 +126,7 @@ export function parseConnectRPCFrame(buffer: Buffer): {
|
||||
let payload = buffer.slice(5, 5 + length);
|
||||
|
||||
// Decompress if gzip
|
||||
if (
|
||||
flags === COMPRESS_FLAG.GZIP ||
|
||||
flags === COMPRESS_FLAG.GZIP_ALT ||
|
||||
flags === COMPRESS_FLAG.GZIP_BOTH
|
||||
) {
|
||||
if (isCompressedConnectFrame(flags)) {
|
||||
try {
|
||||
payload = Buffer.from(zlib.gunzipSync(payload));
|
||||
} catch (err) {
|
||||
|
||||
@@ -192,10 +192,31 @@ export interface MessageId {
|
||||
role: RoleType;
|
||||
}
|
||||
|
||||
/** Compression flags for ConnectRPC frames */
|
||||
/** Bit flags for ConnectRPC envelopes. */
|
||||
export const CONNECT_FRAME_FLAG = {
|
||||
COMPRESSED: 0x01,
|
||||
END_STREAM: 0x02,
|
||||
} as const;
|
||||
|
||||
/** ConnectRPC frame flag presets. */
|
||||
export const COMPRESS_FLAG = {
|
||||
NONE: 0x00,
|
||||
GZIP: 0x01,
|
||||
GZIP_ALT: 0x02,
|
||||
GZIP_BOTH: 0x03,
|
||||
GZIP: CONNECT_FRAME_FLAG.COMPRESSED,
|
||||
END_STREAM: CONNECT_FRAME_FLAG.END_STREAM,
|
||||
GZIP_END_STREAM: CONNECT_FRAME_FLAG.COMPRESSED | CONNECT_FRAME_FLAG.END_STREAM,
|
||||
} as const;
|
||||
|
||||
export const CONNECT_FRAME_FLAG_MASK =
|
||||
CONNECT_FRAME_FLAG.COMPRESSED | CONNECT_FRAME_FLAG.END_STREAM;
|
||||
|
||||
export function isCompressedConnectFrame(flags: number): boolean {
|
||||
return (flags & CONNECT_FRAME_FLAG.COMPRESSED) === CONNECT_FRAME_FLAG.COMPRESSED;
|
||||
}
|
||||
|
||||
export function isEndStreamConnectFrame(flags: number): boolean {
|
||||
return (flags & CONNECT_FRAME_FLAG.END_STREAM) === CONNECT_FRAME_FLAG.END_STREAM;
|
||||
}
|
||||
|
||||
export function hasUnknownConnectFrameFlags(flags: number): boolean {
|
||||
return (flags & ~CONNECT_FRAME_FLAG_MASK) !== 0;
|
||||
}
|
||||
@@ -153,7 +153,7 @@ export function buildChatRequest(
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate complete Cursor request body with ConnectRPC framing
|
||||
* Generate the raw top-level protobuf request body expected by Cursor upstream.
|
||||
*/
|
||||
export function generateCursorBody(
|
||||
messages: CursorMessage[],
|
||||
@@ -161,9 +161,7 @@ export function generateCursorBody(
|
||||
tools: CursorTool[] = [],
|
||||
reasoningEffort: string | null = null
|
||||
): Uint8Array {
|
||||
const protobuf = buildChatRequest(messages, modelName, tools, reasoningEffort);
|
||||
const framed = wrapConnectRPCFrame(protobuf, false); // Cursor doesn't support compressed requests
|
||||
return framed;
|
||||
return buildChatRequest(messages, modelName, tools, reasoningEffort);
|
||||
}
|
||||
|
||||
// Re-export all functions
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
import type { CursorConfig } from '../config/unified-config-types';
|
||||
import { checkAuthStatus } from './cursor-auth';
|
||||
import { isDaemonRunning, startDaemon } from './cursor-daemon';
|
||||
import { getModelsForDaemon, resolveCursorRequestModel } from './cursor-models';
|
||||
|
||||
export interface CursorProbeResult {
|
||||
ok: boolean;
|
||||
stage: 'config' | 'auth' | 'daemon' | 'runtime';
|
||||
status: number;
|
||||
duration_ms: number;
|
||||
model?: string;
|
||||
error_type?: string | null;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const PROBE_PROMPT = 'Reply with OK only.';
|
||||
const PROBE_TIMEOUT_MS = 15_000;
|
||||
const PROBE_SUCCESS_PATTERN = /^ok[.!]?$/i;
|
||||
|
||||
function isDaemonReachabilityError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
|
||||
const message = error.message.toLowerCase();
|
||||
if (
|
||||
message.includes('fetch failed') ||
|
||||
message.includes('econnrefused') ||
|
||||
message.includes('econnreset') ||
|
||||
message.includes('socket hang up') ||
|
||||
message.includes('connection refused')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const cause = (error as Error & { cause?: unknown }).cause;
|
||||
if (!cause || typeof cause !== 'object' || !('code' in cause)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const code = String((cause as { code?: unknown }).code ?? '').toUpperCase();
|
||||
return ['ECONNREFUSED', 'ECONNRESET', 'ECONNABORTED', 'EPIPE', 'UND_ERR_SOCKET'].includes(code);
|
||||
}
|
||||
|
||||
function parseProbeError(text: string): { errorType: string | null; message: string } {
|
||||
try {
|
||||
const parsed = JSON.parse(text) as {
|
||||
error?: { type?: string; message?: string };
|
||||
type?: string;
|
||||
};
|
||||
|
||||
if (parsed.error?.message) {
|
||||
return {
|
||||
errorType: parsed.error.type ?? null,
|
||||
message: parsed.error.message,
|
||||
};
|
||||
}
|
||||
|
||||
if (parsed.type === 'error') {
|
||||
return {
|
||||
errorType: null,
|
||||
message: text,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// fall through to raw text
|
||||
}
|
||||
|
||||
return {
|
||||
errorType: null,
|
||||
message: text || 'Unknown probe failure',
|
||||
};
|
||||
}
|
||||
|
||||
function parseProbeSuccess(text: string): { ok: boolean; message: string } {
|
||||
try {
|
||||
const parsed = JSON.parse(text) as {
|
||||
choices?: Array<{
|
||||
message?: { content?: string | null };
|
||||
}>;
|
||||
error?: { message?: string };
|
||||
};
|
||||
|
||||
if (parsed.error?.message) {
|
||||
return { ok: false, message: parsed.error.message };
|
||||
}
|
||||
|
||||
const content = parsed.choices?.[0]?.message?.content;
|
||||
if (typeof content !== 'string' || !content.trim()) {
|
||||
return { ok: false, message: 'Probe response was missing assistant content.' };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: PROBE_SUCCESS_PATTERN.test(content.trim()),
|
||||
message: PROBE_SUCCESS_PATTERN.test(content.trim())
|
||||
? 'Live probe succeeded.'
|
||||
: `Probe returned unexpected assistant content: ${content.trim()}`,
|
||||
};
|
||||
} catch {
|
||||
return { ok: false, message: 'Probe response was not valid JSON.' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function probeCursorRuntime(config: CursorConfig): Promise<CursorProbeResult> {
|
||||
const startedAt = Date.now();
|
||||
|
||||
if (!config.enabled) {
|
||||
return {
|
||||
ok: false,
|
||||
stage: 'config',
|
||||
status: 400,
|
||||
duration_ms: Date.now() - startedAt,
|
||||
message: 'Cursor integration is disabled.',
|
||||
error_type: 'configuration_error',
|
||||
};
|
||||
}
|
||||
|
||||
const authStatus = checkAuthStatus();
|
||||
if (!authStatus.authenticated || !authStatus.credentials) {
|
||||
return {
|
||||
ok: false,
|
||||
stage: 'auth',
|
||||
status: 401,
|
||||
duration_ms: Date.now() - startedAt,
|
||||
message: 'Cursor credentials not found. Run `ccs cursor auth` first.',
|
||||
error_type: 'authentication_error',
|
||||
};
|
||||
}
|
||||
|
||||
if (authStatus.expired) {
|
||||
return {
|
||||
ok: false,
|
||||
stage: 'auth',
|
||||
status: 401,
|
||||
duration_ms: Date.now() - startedAt,
|
||||
message: 'Cursor credentials expired. Run `ccs cursor auth` again.',
|
||||
error_type: 'authentication_error',
|
||||
};
|
||||
}
|
||||
|
||||
let daemonRunning = await isDaemonRunning(config.port);
|
||||
if (!daemonRunning && config.auto_start) {
|
||||
const startResult = await startDaemon({
|
||||
port: config.port,
|
||||
ghost_mode: config.ghost_mode,
|
||||
});
|
||||
|
||||
if (!startResult.success) {
|
||||
daemonRunning = await isDaemonRunning(config.port);
|
||||
} else {
|
||||
daemonRunning = true;
|
||||
}
|
||||
|
||||
if (!daemonRunning) {
|
||||
return {
|
||||
ok: false,
|
||||
stage: 'daemon',
|
||||
status: 503,
|
||||
duration_ms: Date.now() - startedAt,
|
||||
message: startResult.error || 'Failed to start Cursor daemon for live probe.',
|
||||
error_type: 'daemon_start_failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!daemonRunning) {
|
||||
return {
|
||||
ok: false,
|
||||
stage: 'daemon',
|
||||
status: 503,
|
||||
duration_ms: Date.now() - startedAt,
|
||||
message:
|
||||
'Cursor daemon is not running. Start it with `ccs cursor start` or enable auto_start.',
|
||||
error_type: 'daemon_not_running',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const credentials = {
|
||||
accessToken: authStatus.credentials.accessToken,
|
||||
machineId: authStatus.credentials.machineId,
|
||||
ghostMode: config.ghost_mode,
|
||||
};
|
||||
const availableModels = await getModelsForDaemon({ credentials });
|
||||
const model = resolveCursorRequestModel(config.model, availableModels);
|
||||
|
||||
const abortController = new AbortController();
|
||||
const timeout = setTimeout(() => abortController.abort(), PROBE_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${config.port}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
max_tokens: 8,
|
||||
messages: [{ role: 'user', content: PROBE_PROMPT }],
|
||||
}),
|
||||
signal: abortController.signal,
|
||||
});
|
||||
const text = await response.text();
|
||||
const duration = Date.now() - startedAt;
|
||||
|
||||
if (!response.ok) {
|
||||
const error = parseProbeError(text);
|
||||
return {
|
||||
ok: false,
|
||||
stage: 'runtime',
|
||||
status: response.status,
|
||||
duration_ms: duration,
|
||||
model,
|
||||
error_type: error.errorType,
|
||||
message: error.message,
|
||||
};
|
||||
}
|
||||
|
||||
const success = parseProbeSuccess(text);
|
||||
return {
|
||||
ok: success.ok,
|
||||
stage: 'runtime',
|
||||
status: success.ok ? response.status : 502,
|
||||
duration_ms: duration,
|
||||
model,
|
||||
error_type: success.ok ? null : 'probe_validation_failed',
|
||||
message: success.message,
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
} catch (error) {
|
||||
const daemonReachabilityError = isDaemonReachabilityError(error);
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
stage:
|
||||
error instanceof Error && error.name === 'AbortError'
|
||||
? 'runtime'
|
||||
: daemonReachabilityError
|
||||
? 'daemon'
|
||||
: 'runtime',
|
||||
status:
|
||||
error instanceof Error && error.name === 'AbortError'
|
||||
? 504
|
||||
: daemonReachabilityError
|
||||
? 503
|
||||
: 500,
|
||||
duration_ms: Date.now() - startedAt,
|
||||
error_type:
|
||||
error instanceof Error && error.name === 'AbortError'
|
||||
? 'probe_timeout'
|
||||
: daemonReachabilityError
|
||||
? 'daemon_unreachable'
|
||||
: 'runtime_error',
|
||||
message:
|
||||
error instanceof Error && error.name === 'AbortError'
|
||||
? `Live probe timed out after ${PROBE_TIMEOUT_MS}ms.`
|
||||
: daemonReachabilityError
|
||||
? 'Cursor daemon became unreachable during the live probe. Start it again and retry.'
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: 'Unknown runtime probe failure.',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,11 @@
|
||||
*/
|
||||
|
||||
import * as zlib from 'zlib';
|
||||
import { COMPRESS_FLAG } from './cursor-protobuf-schema.js';
|
||||
import {
|
||||
hasUnknownConnectFrameFlags,
|
||||
isCompressedConnectFrame,
|
||||
isEndStreamConnectFrame,
|
||||
} from './cursor-protobuf-schema.js';
|
||||
import { extractTextFromResponse } from './cursor-protobuf-decoder.js';
|
||||
|
||||
/** Frame parsing result types */
|
||||
@@ -22,12 +26,85 @@ export type FrameResult =
|
||||
};
|
||||
};
|
||||
|
||||
export class CursorConnectFrameError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status = 502,
|
||||
readonly errorType = 'server_error'
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'CursorConnectFrameError';
|
||||
}
|
||||
}
|
||||
|
||||
export function mapCursorConnectError(code?: string): { status: number; errorType: string } {
|
||||
switch (code?.toLowerCase()) {
|
||||
case 'resource_exhausted':
|
||||
return { status: 429, errorType: 'rate_limit_error' };
|
||||
case 'unauthenticated':
|
||||
return { status: 401, errorType: 'authentication_error' };
|
||||
case 'permission_denied':
|
||||
return { status: 403, errorType: 'permission_error' };
|
||||
case 'not_found':
|
||||
return { status: 404, errorType: 'api_error' };
|
||||
case 'already_exists':
|
||||
case 'aborted':
|
||||
return { status: 409, errorType: 'api_error' };
|
||||
case 'deadline_exceeded':
|
||||
return { status: 504, errorType: 'api_error' };
|
||||
case 'unimplemented':
|
||||
return { status: 501, errorType: 'api_error' };
|
||||
case 'unavailable':
|
||||
return { status: 503, errorType: 'api_error' };
|
||||
case 'invalid_argument':
|
||||
case 'failed_precondition':
|
||||
case 'out_of_range':
|
||||
return { status: 400, errorType: 'api_error' };
|
||||
default:
|
||||
return { status: 500, errorType: 'api_error' };
|
||||
}
|
||||
}
|
||||
|
||||
function formatConnectFrameFlags(flags: number): string {
|
||||
return `0x${flags.toString(16).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function toFrameErrorResult(error: unknown): Extract<FrameResult, { type: 'error' }> {
|
||||
if (error instanceof CursorConnectFrameError) {
|
||||
return {
|
||||
type: 'error',
|
||||
message: error.message,
|
||||
status: error.status,
|
||||
errorType: error.errorType,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'error',
|
||||
message: error instanceof Error ? error.message : 'Cursor stream parsing failed.',
|
||||
status: 502,
|
||||
errorType: 'server_error',
|
||||
};
|
||||
}
|
||||
|
||||
function createTruncatedFrameError(): CursorConnectFrameError {
|
||||
return new CursorConnectFrameError('Truncated Cursor ConnectRPC frame.', 502, 'server_error');
|
||||
}
|
||||
|
||||
/**
|
||||
* Decompress payload if gzip-compressed.
|
||||
* Skips decompression for JSON error payloads.
|
||||
* NOTE: Uses synchronous gzip for single-request CLI tool. Async not warranted for small payloads.
|
||||
*/
|
||||
export function decompressPayload(payload: Buffer, flags: number): Buffer {
|
||||
if (hasUnknownConnectFrameFlags(flags)) {
|
||||
throw new CursorConnectFrameError(
|
||||
`Unsupported ConnectRPC frame flags: ${formatConnectFrameFlags(flags)}`,
|
||||
502,
|
||||
'server_error'
|
||||
);
|
||||
}
|
||||
|
||||
if (payload.length > 10 && payload[0] === 0x7b && payload[1] === 0x22) {
|
||||
try {
|
||||
const text = payload.toString('utf-8');
|
||||
@@ -37,18 +114,18 @@ export function decompressPayload(payload: Buffer, flags: number): Buffer {
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
flags === COMPRESS_FLAG.GZIP ||
|
||||
flags === COMPRESS_FLAG.GZIP_ALT ||
|
||||
flags === COMPRESS_FLAG.GZIP_BOTH
|
||||
) {
|
||||
if (isCompressedConnectFrame(flags)) {
|
||||
try {
|
||||
return zlib.gunzipSync(payload);
|
||||
} catch (err) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error('[cursor] gzip decompression failed:', err);
|
||||
}
|
||||
return Buffer.alloc(0);
|
||||
throw new CursorConnectFrameError(
|
||||
'Failed to decompress Cursor ConnectRPC frame.',
|
||||
502,
|
||||
'server_error'
|
||||
);
|
||||
}
|
||||
}
|
||||
return payload;
|
||||
@@ -80,7 +157,46 @@ export class StreamingFrameParser {
|
||||
let payload = this.buffer.slice(5, frameSize);
|
||||
this.buffer = this.buffer.slice(frameSize);
|
||||
|
||||
payload = decompressPayload(payload, flags);
|
||||
try {
|
||||
payload = decompressPayload(payload, flags);
|
||||
} catch (error) {
|
||||
results.push(toFrameErrorResult(error));
|
||||
return results;
|
||||
}
|
||||
|
||||
if (isEndStreamConnectFrame(flags)) {
|
||||
try {
|
||||
const text = payload.toString('utf-8');
|
||||
const json = JSON.parse(text) as {
|
||||
error?: {
|
||||
code?: string;
|
||||
message?: string;
|
||||
details?: Array<{
|
||||
debug?: { details?: { title?: string; detail?: string }; error?: string };
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
const msg =
|
||||
json?.error?.details?.[0]?.debug?.details?.title ||
|
||||
json?.error?.details?.[0]?.debug?.details?.detail ||
|
||||
json?.error?.message;
|
||||
|
||||
if (msg) {
|
||||
const mappedError = mapCursorConnectError(json?.error?.code);
|
||||
results.push({
|
||||
type: 'error',
|
||||
message: msg,
|
||||
status: mappedError.status,
|
||||
errorType: mappedError.errorType,
|
||||
});
|
||||
return results;
|
||||
}
|
||||
} catch {
|
||||
// Ignore successful end-stream metadata trailers.
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for JSON error
|
||||
try {
|
||||
@@ -92,12 +208,12 @@ export class StreamingFrameParser {
|
||||
json?.error?.details?.[0]?.debug?.details?.detail ||
|
||||
json?.error?.message ||
|
||||
'API Error';
|
||||
const isRateLimit = json?.error?.code === 'resource_exhausted';
|
||||
const mappedError = mapCursorConnectError(json?.error?.code);
|
||||
results.push({
|
||||
type: 'error',
|
||||
message: msg,
|
||||
status: isRateLimit ? 429 : 400,
|
||||
errorType: isRateLimit ? 'rate_limit_error' : 'api_error',
|
||||
status: mappedError.status,
|
||||
errorType: mappedError.errorType,
|
||||
});
|
||||
return results;
|
||||
}
|
||||
@@ -116,7 +232,7 @@ export class StreamingFrameParser {
|
||||
results.push({
|
||||
type: 'error',
|
||||
message: result.error,
|
||||
status: isRateLimit ? 429 : 400,
|
||||
status: isRateLimit ? 429 : 502,
|
||||
errorType: isRateLimit ? 'rate_limit_error' : 'server_error',
|
||||
});
|
||||
return results;
|
||||
@@ -133,4 +249,13 @@ export class StreamingFrameParser {
|
||||
hasPartial(): boolean {
|
||||
return this.buffer.length > 0;
|
||||
}
|
||||
|
||||
finish(): FrameResult[] {
|
||||
if (this.buffer.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
this.buffer = Buffer.alloc(0);
|
||||
return [toFrameErrorResult(createTruncatedFrameError())];
|
||||
}
|
||||
}
|
||||
@@ -46,3 +46,4 @@ export {
|
||||
// Executor
|
||||
export { CursorExecutor } from './cursor-executor';
|
||||
export { executeCursorProfile, generateCursorEnv } from './cursor-profile-executor';
|
||||
export { probeCursorRuntime } from './cursor-runtime-probe';
|
||||
@@ -54,6 +54,18 @@ export interface AutoDetectResult {
|
||||
accessToken?: string;
|
||||
/** Machine ID (if found) */
|
||||
machineId?: string;
|
||||
/** SQLite database path used during detection */
|
||||
dbPath?: string;
|
||||
/** Paths checked while looking for Cursor state */
|
||||
checkedPaths?: string[];
|
||||
/** Structured failure reason when detection fails */
|
||||
reason?:
|
||||
| 'db_not_found'
|
||||
| 'sqlite_unavailable'
|
||||
| 'db_query_failed'
|
||||
| 'access_token_not_found'
|
||||
| 'machine_id_not_found'
|
||||
| 'invalid_token_format';
|
||||
/** Error message (if detection failed) */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
stopDaemon,
|
||||
checkAuthStatus,
|
||||
autoDetectTokens,
|
||||
probeCursorRuntime,
|
||||
saveCredentials,
|
||||
validateToken,
|
||||
} from '../../cursor';
|
||||
@@ -30,6 +31,34 @@ interface DaemonStartPreconditionError {
|
||||
error: string;
|
||||
}
|
||||
|
||||
export function getAutoDetectFailureStatus(
|
||||
reason?: ReturnType<typeof autoDetectTokens>['reason']
|
||||
): number {
|
||||
switch (reason) {
|
||||
case 'sqlite_unavailable':
|
||||
return 503;
|
||||
case 'db_query_failed':
|
||||
return 500;
|
||||
case 'invalid_token_format':
|
||||
return 400;
|
||||
default:
|
||||
return 404;
|
||||
}
|
||||
}
|
||||
|
||||
function getPublicAutoDetectError(result: ReturnType<typeof autoDetectTokens>): string {
|
||||
switch (result.reason) {
|
||||
case 'db_not_found':
|
||||
return 'Cursor state database not found on this host.';
|
||||
case 'sqlite_unavailable':
|
||||
return 'Cursor state database was found, but sqlite3 is not available in PATH.';
|
||||
case 'db_query_failed':
|
||||
return 'Cursor state database was found, but CCS could not query it.';
|
||||
default:
|
||||
return result.error ?? 'Token not found';
|
||||
}
|
||||
}
|
||||
|
||||
export function getDaemonStartPreconditionError(
|
||||
input: DaemonStartPreconditionInput
|
||||
): DaemonStartPreconditionError | null {
|
||||
@@ -124,7 +153,11 @@ router.post('/auth/auto-detect', async (_req: Request, res: Response): Promise<v
|
||||
const result = autoDetectTokens();
|
||||
|
||||
if (!result.found || !result.accessToken || !result.machineId) {
|
||||
res.status(404).json({ error: result.error ?? 'Token not found' });
|
||||
const status = getAutoDetectFailureStatus(result.reason);
|
||||
res.status(status).json({
|
||||
error: getPublicAutoDetectError(result),
|
||||
reason: result.reason ?? null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -155,6 +188,19 @@ router.get('/models', async (_req: Request, res: Response): Promise<void> => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/cursor/probe - Run a live authenticated runtime probe
|
||||
*/
|
||||
router.post('/probe', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const cursorConfig = getCursorConfig();
|
||||
const result = await probeCursorRuntime(cursorConfig);
|
||||
res.status(result.status).json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/cursor/daemon/start - Start cursor proxy daemon
|
||||
* Path matches copilot convention: /api/{provider}/daemon/{action}
|
||||
|
||||
Reference in new issue
Block a user