From e1049e38d409ad32bf0c1ae41eb713f59dbdfe02 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 12 Apr 2026 01:50:30 -0400 Subject: [PATCH] feat(cursor): harden live probe and runtime contracts --- docs/cursor-integration.md | 39 +- src/commands/cursor-command-display.ts | 26 +- src/commands/cursor-command.ts | 47 +- src/cursor/cursor-auth.ts | 238 ++++++-- src/cursor/cursor-executor.ts | 548 +++++++++++------- src/cursor/cursor-protobuf-decoder.ts | 13 +- src/cursor/cursor-protobuf-schema.ts | 29 +- src/cursor/cursor-protobuf.ts | 6 +- src/cursor/cursor-runtime-probe.ts | 258 +++++++++ src/cursor/cursor-stream-parser.ts | 102 +++- src/cursor/index.ts | 1 + src/cursor/types.ts | 12 + src/web-server/routes/cursor-routes.ts | 25 +- tests/unit/cursor/cursor-auth.test.ts | 193 +++++- tests/unit/cursor/cursor-daemon.test.ts | 3 + tests/unit/cursor/cursor-protobuf.test.ts | 160 ++++- .../unit/cursor/cursor-runtime-probe.test.ts | 132 +++++ tests/unit/web-server/cursor-routes.test.ts | 102 +++- ui/src/hooks/use-cursor.ts | 66 +++ ui/src/lib/i18n.ts | 60 ++ ui/src/pages/cursor.tsx | 159 +++++ ui/tests/unit/hooks/use-cursor.test.tsx | 116 ++++ ui/tests/unit/ui/pages/cursor-page.test.tsx | 157 +++++ 23 files changed, 2186 insertions(+), 306 deletions(-) create mode 100644 src/cursor/cursor-runtime-probe.ts create mode 100644 tests/unit/cursor/cursor-runtime-probe.test.ts create mode 100644 ui/tests/unit/hooks/use-cursor.test.tsx create mode 100644 ui/tests/unit/ui/pages/cursor-page.test.tsx diff --git a/docs/cursor-integration.md b/docs/cursor-integration.md index 79caee5a..c49a0f3a 100644 --- a/docs/cursor-integration.md +++ b/docs/cursor-integration.md @@ -1,14 +1,26 @@ # Cursor IDE Integration -This guide covers the local Cursor integration in CCS, including CLI setup, daemon lifecycle, and dashboard controls. +This guide covers the current CCS-owned Cursor runtime, including auth import, local daemon lifecycle, live probe checks, and dashboard controls. ## What It Provides - OpenAI-compatible local endpoint powered by Cursor credentials. - Anthropic-compatible local endpoint at `/v1/messages` for Claude-native clients. -- Cursor model list and chat completions via local daemon. +- Cursor model list and chat completions via the local CCS daemon. - Dedicated dashboard page: `ccs config` -> `Cursor IDE`. +## What This Runtime Actually Does + +`ccs cursor` does not launch Cursor IDE itself. + +The current workflow is: +1. import Cursor credentials from local SQLite or manual input +2. run a local CCS daemon on `127.0.0.1:` +3. launch Claude Code against that daemon +4. have CCS translate requests to Cursor upstream + +Treat this as a CCS-managed Cursor bridge, not a generic CLIProxy-backed provider path. + ## Prerequisites - Cursor IDE installed and logged in. @@ -43,13 +55,21 @@ ccs cursor auth --manual --token --machine-id ccs cursor start ``` -### 4) Run Cursor-backed Claude +### 4) Run a live probe + +```bash +ccs cursor probe +``` + +Use this to verify that the current build can complete one real authenticated request through the local daemon. + +### 5) Run Cursor-backed Claude ```bash ccs cursor "explain this repo" ``` -### 5) Verify status +### 6) Verify status ```bash ccs cursor status @@ -62,7 +82,7 @@ The admin namespace remains available for setup and inspection: ccs cursor help ``` -### 6) Stop daemon +### 7) Stop daemon ```bash ccs cursor stop @@ -76,6 +96,7 @@ ccs cursor stop - Model list resolution: authenticated live fetch when available, with cached/default fallback. - Request model validation: if a requested model is not present in the available Cursor model catalog, daemon falls back to the resolved default model. - Daemon API surface: `POST /v1/chat/completions`, `POST /v1/messages`, and `GET /v1/models`. +- Live verification: `ccs cursor probe` or `POST /api/cursor/probe` These values are managed in unified config and can be updated from CLI or dashboard. @@ -112,10 +133,16 @@ When raw settings include a local `ANTHROPIC_BASE_URL` port override, CCS synchr - Re-run `ccs cursor auth` (or manual auth command). +### `ccs cursor probe` fails even though status is green + +- `status` proves local config/auth/daemon readiness only. +- `probe` proves the live runtime path. +- If `probe` fails with upstream protocol errors, inspect the current CCS build first rather than assuming the local daemon is healthy. + ### Auto-detect fails - Ensure Cursor is logged in. -- Confirm `sqlite3` is installed (macOS/Linux). +- Confirm `sqlite3` is installed or use manual import. - Use manual auth import if needed. ### Daemon fails to start diff --git a/src/commands/cursor-command-display.ts b/src/commands/cursor-command-display.ts index e63c109a..3e7d4c56 100644 --- a/src/commands/cursor-command-display.ts +++ b/src/commands/cursor-command-display.ts @@ -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}`); +} diff --git a/src/commands/cursor-command.ts b/src/commands/cursor-command.ts index 6699fba1..b0f8f12d 100644 --- a/src/commands/cursor-command.ts +++ b/src/commands/cursor-command.ts @@ -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 { 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 { } 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 --machine-id '); @@ -156,6 +192,13 @@ async function handleStatus(): Promise { return 0; } +async function handleProbe(): Promise { + const cursorConfig = getCursorConfig(); + const result = await probeCursorRuntime(cursorConfig); + renderCursorProbe(result); + return result.ok ? 0 : 1; +} + async function handleModels(): Promise { const cursorConfig = getCursorConfig(); const models = await getAvailableModels(cursorConfig.port); diff --git a/src/cursor/cursor-auth.ts b/src/cursor/cursor-auth.ts index 924077bd..313ec2a8 100644 --- a/src/cursor/cursor-auth.ts +++ b/src/cursor/cursor-auth.ts @@ -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.', }; } diff --git a/src/cursor/cursor-executor.ts b/src/cursor/cursor-executor.ts index 7f46ec42..a8ae63de 100644 --- a/src/cursor/cursor-executor.ts +++ b/src/cursor/cursor-executor.ts @@ -6,10 +6,19 @@ 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, +} from './cursor-stream-parser.js'; /** Executor parameters */ interface ExecutorParams { @@ -41,6 +50,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,13 +75,13 @@ 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 || @@ -74,19 +90,48 @@ function createErrorResponse(jsonError: { const isRateLimit = jsonError?.error?.code === 'resource_exhausted'; - return new Response( - JSON.stringify({ - error: { - message: errorMsg, - type: isRateLimit ? 'rate_limit_error' : 'api_error', - code: jsonError?.error?.details?.[0]?.debug?.error || 'unknown', - }, - }), - { - status: isRateLimit ? 429 : 400, - headers: { 'Content-Type': 'application/json' }, - } - ); + return { + message: errorMsg, + status: isRateLimit ? 429 : 400, + errorType: isRateLimit ? 'rate_limit_error' : 'api_error', + 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 +458,237 @@ 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({ 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, 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, finishReason: string | null) => + JSON.stringify({ + id: responseId, + object: 'chat.completion.chunk', + created, + model, + choices: [{ index: 0, delta, finish_reason: finishReason }], + }); + + const handleFrameError = (frame: Extract) => { + 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; + 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 +706,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 +732,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 isRateLimit = json?.error?.code === 'resource_exhausted'; + yield { + type: 'error', + error: { + message: msg, + status: isRateLimit ? 429 : 400, + errorType: isRateLimit ? 'rate_limit_error' : 'api_error', + code: isRateLimit ? 'rate_limited' : '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 +799,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; } @@ -711,7 +847,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 +976,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') { diff --git a/src/cursor/cursor-protobuf-decoder.ts b/src/cursor/cursor-protobuf-decoder.ts index a5b5fe8d..8a12423a 100644 --- a/src/cursor/cursor-protobuf-decoder.ts +++ b/src/cursor/cursor-protobuf-decoder.ts @@ -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) { diff --git a/src/cursor/cursor-protobuf-schema.ts b/src/cursor/cursor-protobuf-schema.ts index 296cfd49..d81ac5b1 100644 --- a/src/cursor/cursor-protobuf-schema.ts +++ b/src/cursor/cursor-protobuf-schema.ts @@ -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; +} diff --git a/src/cursor/cursor-protobuf.ts b/src/cursor/cursor-protobuf.ts index 7579cc42..e2bf76bc 100644 --- a/src/cursor/cursor-protobuf.ts +++ b/src/cursor/cursor-protobuf.ts @@ -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 diff --git a/src/cursor/cursor-runtime-probe.ts b/src/cursor/cursor-runtime-probe.ts new file mode 100644 index 00000000..3ac4ca3c --- /dev/null +++ b/src/cursor/cursor-runtime-probe.ts @@ -0,0 +1,258 @@ +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 { + 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) { + 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', + }; + } + + daemonRunning = true; + } + + 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.', + }; + } +} diff --git a/src/cursor/cursor-stream-parser.ts b/src/cursor/cursor-stream-parser.ts index 7fa06091..1c64d446 100644 --- a/src/cursor/cursor-stream-parser.ts +++ b/src/cursor/cursor-stream-parser.ts @@ -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,53 @@ export type FrameResult = }; }; +export class CursorConnectFrameError extends Error { + constructor( + message: string, + readonly status = 502, + readonly errorType = 'server_error' + ) { + super(message); + this.name = 'CursorConnectFrameError'; + } +} + +function formatConnectFrameFlags(flags: number): string { + return `0x${flags.toString(16).padStart(2, '0')}`; +} + +function toFrameErrorResult(error: unknown): Extract { + 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', + }; +} + /** * 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 +82,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 +125,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 isRateLimit = json?.error?.code === 'resource_exhausted'; + results.push({ + type: 'error', + message: msg, + status: isRateLimit ? 429 : 400, + errorType: isRateLimit ? 'rate_limit_error' : 'api_error', + }); + return results; + } + } catch { + // Ignore successful end-stream metadata trailers. + } + continue; + } // Check for JSON error try { @@ -116,7 +200,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; diff --git a/src/cursor/index.ts b/src/cursor/index.ts index 42e42860..053e5d6f 100644 --- a/src/cursor/index.ts +++ b/src/cursor/index.ts @@ -46,3 +46,4 @@ export { // Executor export { CursorExecutor } from './cursor-executor'; export { executeCursorProfile, generateCursorEnv } from './cursor-profile-executor'; +export { probeCursorRuntime } from './cursor-runtime-probe'; diff --git a/src/cursor/types.ts b/src/cursor/types.ts index df9c152f..199c03cf 100644 --- a/src/cursor/types.ts +++ b/src/cursor/types.ts @@ -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; } diff --git a/src/web-server/routes/cursor-routes.ts b/src/web-server/routes/cursor-routes.ts index b28c3705..73575352 100644 --- a/src/web-server/routes/cursor-routes.ts +++ b/src/web-server/routes/cursor-routes.ts @@ -11,6 +11,7 @@ import { stopDaemon, checkAuthStatus, autoDetectTokens, + probeCursorRuntime, saveCredentials, validateToken, } from '../../cursor'; @@ -124,7 +125,16 @@ router.post('/auth/auto-detect', async (_req: Request, res: Response): Promise => { } }); +/** + * POST /api/cursor/probe - Run a live authenticated runtime probe + */ +router.post('/probe', async (_req: Request, res: Response): Promise => { + 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} diff --git a/tests/unit/cursor/cursor-auth.test.ts b/tests/unit/cursor/cursor-auth.test.ts index b2aaa6bf..25003f12 100644 --- a/tests/unit/cursor/cursor-auth.test.ts +++ b/tests/unit/cursor/cursor-auth.test.ts @@ -3,6 +3,7 @@ */ import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { execFileSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; @@ -15,6 +16,7 @@ import { checkAuthStatus, deleteCredentials, autoDetectTokens, + getTokenStorageCandidates, } from '../../../src/cursor/cursor-auth'; // Test isolation @@ -403,26 +405,40 @@ describe('deleteCredentials', () => { }); describe('autoDetectTokens', () => { - it('should return not found for Windows platform', () => { - // Save original platform + it('should report checked paths when no Windows database exists', () => { const originalPlatform = process.platform; + const originalUserProfile = process.env.USERPROFILE; + const originalAppData = process.env.APPDATA; + const originalLocalAppData = process.env.LOCALAPPDATA; + const fakeHome = path.join(tempDir, 'win-home'); + process.env.USERPROFILE = fakeHome; + delete process.env.APPDATA; + delete process.env.LOCALAPPDATA; - // Mock Windows platform Object.defineProperty(process, 'platform', { value: 'win32', configurable: true, }); - const result = autoDetectTokens(); + try { + const result = autoDetectTokens(); - expect(result.found).toBe(false); - expect(result.error).toContain('not supported on Windows'); - - // Restore original platform - Object.defineProperty(process, 'platform', { - value: originalPlatform, - configurable: true, - }); + expect(result.found).toBe(false); + expect(result.reason).toBe('db_not_found'); + expect(result.checkedPaths?.length).toBeGreaterThan(0); + expect(result.error).toContain('Checked:'); + } finally { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true, + }); + if (originalUserProfile !== undefined) process.env.USERPROFILE = originalUserProfile; + else delete process.env.USERPROFILE; + if (originalAppData !== undefined) process.env.APPDATA = originalAppData; + else delete process.env.APPDATA; + if (originalLocalAppData !== undefined) process.env.LOCALAPPDATA = originalLocalAppData; + else delete process.env.LOCALAPPDATA; + } }); it('should return not found when database file does not exist', () => { @@ -438,9 +454,10 @@ describe('autoDetectTokens', () => { try { const result = autoDetectTokens(); - // Should fail because isolated test home has no Cursor database expect(result.found).toBe(false); - expect(result.error).toBeDefined(); + expect(result.reason).toBe('db_not_found'); + expect(result.checkedPaths?.length).toBeGreaterThan(0); + expect(result.error).toContain('Checked:'); } finally { if (originalHome !== undefined) { process.env.HOME = originalHome; @@ -457,4 +474,152 @@ describe('autoDetectTokens', () => { expect(result).toHaveProperty('found'); expect(typeof result.found).toBe('boolean'); }); + + it('should report sqlite_unavailable when database exists but sqlite3 is missing', () => { + const originalHome = process.env.HOME; + const originalPath = process.env.PATH; + const originalSqliteBin = process.env.CCS_CURSOR_SQLITE_BIN; + const fakeHome = path.join(tempDir, 'sqlite-missing-home'); + process.env.HOME = fakeHome; + process.env.CCS_CURSOR_SQLITE_BIN = 'definitely-missing-sqlite3'; + + try { + const dbPath = getTokenStorageCandidates()[0]; + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + fs.writeFileSync(dbPath, ''); + + const result = autoDetectTokens(); + expect(result.found).toBe(false); + expect(result.reason).toBe('sqlite_unavailable'); + expect(result.dbPath).toBe(dbPath); + } finally { + if (originalHome !== undefined) process.env.HOME = originalHome; + else delete process.env.HOME; + if (originalPath !== undefined) process.env.PATH = originalPath; + else delete process.env.PATH; + if (originalSqliteBin !== undefined) process.env.CCS_CURSOR_SQLITE_BIN = originalSqliteBin; + else delete process.env.CCS_CURSOR_SQLITE_BIN; + } + }); + + it('should use fallback keys and normalize JSON-encoded sqlite values', () => { + if (process.platform === 'win32') return; + + try { + execFileSync('sqlite3', ['--version'], { stdio: 'ignore' }); + } catch { + return; + } + + const originalHome = process.env.HOME; + const fakeHome = path.join(tempDir, 'sqlite-fallback-home'); + process.env.HOME = fakeHome; + + try { + const dbPath = getTokenStorageCandidates()[0]; + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + execFileSync('sqlite3', [ + dbPath, + 'CREATE TABLE IF NOT EXISTS itemTable (key TEXT PRIMARY KEY, value TEXT);', + ]); + execFileSync('sqlite3', [ + dbPath, + `INSERT OR REPLACE INTO itemTable (key, value) VALUES ('cursorAuth/token', '"${'a'.repeat(60)}"');`, + ]); + execFileSync('sqlite3', [ + dbPath, + `INSERT OR REPLACE INTO itemTable (key, value) VALUES ('storage.machineId', '"1234567890abcdef1234567890abcdef"');`, + ]); + + const result = autoDetectTokens(); + expect(result.found).toBe(true); + expect(result.accessToken).toBe('a'.repeat(60)); + expect(result.machineId).toBe('1234567890abcdef1234567890abcdef'); + } finally { + if (originalHome !== undefined) process.env.HOME = originalHome; + else delete process.env.HOME; + } + }); + + it('should continue scanning later database candidates after one invalid credential pair', () => { + if (process.platform !== 'darwin') { + return; + } + + try { + execFileSync('sqlite3', ['--version'], { stdio: 'ignore' }); + } catch { + return; + } + + const originalHome = process.env.HOME; + const fakeHome = path.join(tempDir, 'candidate-scan-home'); + process.env.HOME = fakeHome; + + try { + const [stablePath, insidersPath] = getTokenStorageCandidates(); + fs.mkdirSync(path.dirname(stablePath), { recursive: true }); + fs.mkdirSync(path.dirname(insidersPath), { recursive: true }); + + execFileSync('sqlite3', [ + stablePath, + 'CREATE TABLE IF NOT EXISTS itemTable (key TEXT PRIMARY KEY, value TEXT);', + ]); + execFileSync('sqlite3', [ + stablePath, + `INSERT OR REPLACE INTO itemTable (key, value) VALUES ('cursorAuth/accessToken', 'short');`, + ]); + execFileSync('sqlite3', [ + stablePath, + `INSERT OR REPLACE INTO itemTable (key, value) VALUES ('storage.serviceMachineId', 'bad');`, + ]); + + execFileSync('sqlite3', [ + insidersPath, + 'CREATE TABLE IF NOT EXISTS itemTable (key TEXT PRIMARY KEY, value TEXT);', + ]); + execFileSync('sqlite3', [ + insidersPath, + `INSERT OR REPLACE INTO itemTable (key, value) VALUES ('cursorAuth/accessToken', '${'a'.repeat(60)}');`, + ]); + execFileSync('sqlite3', [ + insidersPath, + `INSERT OR REPLACE INTO itemTable (key, value) VALUES ('storage.serviceMachineId', '1234567890abcdef1234567890abcdef');`, + ]); + + const result = autoDetectTokens(); + expect(result.found).toBe(true); + expect(result.dbPath).toBe(insidersPath); + } finally { + if (originalHome !== undefined) process.env.HOME = originalHome; + else delete process.env.HOME; + } + }); + + it('should report db_query_failed when the database exists but sqlite cannot query it', () => { + if (process.platform === 'win32') return; + + try { + execFileSync('sqlite3', ['--version'], { stdio: 'ignore' }); + } catch { + return; + } + + const originalHome = process.env.HOME; + const fakeHome = path.join(tempDir, 'query-failed-home'); + process.env.HOME = fakeHome; + + try { + const dbPath = getTokenStorageCandidates()[0]; + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + fs.writeFileSync(dbPath, 'not-a-sqlite-database'); + + const result = autoDetectTokens(); + expect(result.found).toBe(false); + expect(result.reason).toBe('db_query_failed'); + } finally { + if (originalHome !== undefined) process.env.HOME = originalHome; + else delete process.env.HOME; + } + }); }); diff --git a/tests/unit/cursor/cursor-daemon.test.ts b/tests/unit/cursor/cursor-daemon.test.ts index bcfc301a..6dd2afee 100644 --- a/tests/unit/cursor/cursor-daemon.test.ts +++ b/tests/unit/cursor/cursor-daemon.test.ts @@ -484,6 +484,9 @@ describe('renderCursorHelp', () => { expect(exitCode).toBe(0); expect(logs.some((line) => line.includes('Usage: ccs cursor '))).toBe(true); + expect(logs.some((line) => line.includes('probe Run a live authenticated runtime probe'))).toBe( + true + ); expect( logs.some((line) => line.includes('ccs cursor [claude args]')) ).toBe(true); diff --git a/tests/unit/cursor/cursor-protobuf.test.ts b/tests/unit/cursor/cursor-protobuf.test.ts index c2bdbd3b..337ae7ee 100644 --- a/tests/unit/cursor/cursor-protobuf.test.ts +++ b/tests/unit/cursor/cursor-protobuf.test.ts @@ -742,14 +742,27 @@ describe('Message Translation', () => { describe('Request Encoding', () => { describe('generateCursorBody', () => { - it('should encode basic text message', () => { + it('should encode a raw top-level request protobuf for basic text messages', () => { const result = generateCursorBody([{ role: 'user', content: 'Hello' }], 'gpt-4', [], null); + const topLevel = decodeMessage(result); + const requestPayload = topLevel.get(FIELD.Request.REQUEST)?.[0]?.value as Uint8Array; + const chatRequest = decodeMessage(requestPayload); + const encodedMessages = (chatRequest.get(FIELD.Chat.MESSAGES) || []).map((entry) => + decodeMessage(entry.value as Uint8Array) + ); + const decoder = new TextDecoder(); expect(result).toBeInstanceOf(Uint8Array); expect(result.length).toBeGreaterThan(0); + expect(Array.from(result.slice(0, 5))).not.toEqual([0, 0, 0, 1, 107]); + expect(topLevel.has(FIELD.Request.REQUEST)).toBe(true); + expect(encodedMessages).toHaveLength(1); + expect(decoder.decode(encodedMessages[0].get(FIELD.Message.CONTENT)?.[0]?.value as Uint8Array)).toBe( + 'Hello' + ); }); - it('should encode message with tools', () => { + it('should encode message with tools into the raw request payload', () => { const tools = [ { type: 'function' as const, @@ -773,9 +786,14 @@ describe('Request Encoding', () => { tools, null ); + const topLevel = decodeMessage(result); + const requestPayload = topLevel.get(FIELD.Request.REQUEST)?.[0]?.value as Uint8Array; + const chatRequest = decodeMessage(requestPayload); expect(result).toBeInstanceOf(Uint8Array); expect(result.length).toBeGreaterThan(0); + expect(topLevel.has(FIELD.Request.REQUEST)).toBe(true); + expect((chatRequest.get(FIELD.Chat.MCP_TOOLS) || []).length).toBe(1); }); it('should preserve flattened tool_result blocks through protobuf encoding', () => { @@ -806,10 +824,7 @@ describe('Request Encoding', () => { ); const body = generateCursorBody(translated.messages, 'gpt-4', [], null); - const frame = parseConnectRPCFrame(Buffer.from(body)); - expect(frame).not.toBeNull(); - - const topLevel = decodeMessage(frame!.payload); + const topLevel = decodeMessage(body); const requestPayload = topLevel.get(FIELD.Request.REQUEST)?.[0]?.value as Uint8Array; const chatRequest = decodeMessage(requestPayload); const encodedMessages = (chatRequest.get(FIELD.Chat.MESSAGES) || []).map((entry) => @@ -1177,7 +1192,7 @@ describe('CursorExecutor', () => { }); describe('decompressPayload error handling', () => { - it('should return empty buffer on decompression failure', () => { + it('returns an explicit executor error on decompression failure', async () => { // Create invalid gzip data const invalidGzip = Buffer.from([0x1f, 0x8b, 0x08, 0x00, 0xff, 0xff]); const frame = new Uint8Array(5 + invalidGzip.length); @@ -1192,13 +1207,15 @@ describe('CursorExecutor', () => { messages: [], }); - // Should handle gracefully and return valid response - expect(result.status).toBe(200); + expect(result.status).toBe(502); + const body = JSON.parse(await result.text()); + expect(body.error.type).toBe('server_error'); + expect(body.error.message).toContain('decompress'); }); }); describe('error handling', () => { - it('should return empty buffer on decompression failure', () => { + it('returns an explicit error for invalid compressed payloads', async () => { const executor = new CursorExecutor(); // Invalid compressed payload (not actually gzipped) @@ -1217,13 +1234,63 @@ describe('CursorExecutor', () => { const buffer = Buffer.from(frame); - // Should not crash - decompression failure returns empty buffer const result = executor.transformProtobufToJSON(buffer, 'test-model', { messages: [], stream: false, }); + expect(result.status).toBe(502); + const body = JSON.parse(await result.text()); + expect(body.error.type).toBe('server_error'); + expect(body.error.message).toContain('decompress'); + }); + + it('surfaces first-frame protocol errors in SSE mode without pretending success', async () => { + const executor = new CursorExecutor(); + const invalidGzipPayload = new Uint8Array([1, 2, 3, 4, 5]); + const frame = buildFrame(invalidGzipPayload, 0x01); + + const result = executor.transformProtobufToSSE(frame, 'test-model', { + messages: [], + stream: true, + }); + + expect(result.status).toBe(502); + const body = JSON.parse(await result.text()); + expect(body.error.type).toBe('server_error'); + expect(body.error.message).toContain('decompress'); + }); + + it('emits an SSE error event without [DONE] when a later frame fails', async () => { + const executor = new CursorExecutor(); + const combined = Buffer.concat([ + buildTextFrame('Before failure'), + buildFrame(new Uint8Array([1, 2, 3, 4, 5]), 0x01), + ]); + + const result = executor.transformProtobufToSSE(combined, 'test-model', { + messages: [], + stream: true, + }); + expect(result.status).toBe(200); + const body = await result.text(); + expect(body).toContain('Before failure'); + expect(body).toContain('event: error'); + expect(body).toContain('"type":"server_error"'); + expect(body).not.toContain('data: [DONE]'); + }); + + it('returns an explicit error for unknown ConnectRPC frame flags', async () => { + const executor = new CursorExecutor(); + const result = executor.transformProtobufToJSON(buildFrame(new Uint8Array([0x01]), 0x04), 'gpt-4', { + messages: [], + }); + + expect(result.status).toBe(502); + const body = JSON.parse(await result.text()); + expect(body.error.type).toBe('server_error'); + expect(body.error.message).toContain('0x04'); }); it('should log unknown message roles in debug mode', () => { @@ -1418,6 +1485,31 @@ describe('StreamingFrameParser', () => { } }); + it('should surface end-stream JSON errors instead of treating them as gzip', () => { + const parser = new StreamingFrameParser(); + const endStreamError = buildFrame( + new TextEncoder().encode( + JSON.stringify({ + error: { + code: 'invalid_argument', + message: 'parse binary: illegal tag: field no 0 wire type 0', + }, + }) + ), + 0x02 + ); + + const results = parser.push(endStreamError); + + expect(results.length).toBe(1); + expect(results[0].type).toBe('error'); + if (results[0].type === 'error') { + expect(results[0].status).toBe(400); + expect(results[0].errorType).toBe('api_error'); + expect(results[0].message).toContain('illegal tag'); + } + }); + it('should parse thinking frames', () => { const parser = new StreamingFrameParser(); const frame = buildThinkingFrame('Think step by step'); @@ -1458,11 +1550,39 @@ describe('StreamingFrameParser', () => { expect(results.length).toBe(1); expect(results[0].type).toBe('error'); if (results[0].type === 'error') { + expect(results[0].status).toBe(502); expect(results[0].errorType).toBe('server_error'); expect(results[0].message).toContain('Malformed protobuf response'); } }); + it('should reject invalid gzip-compressed frames explicitly', () => { + const parser = new StreamingFrameParser(); + const invalidCompressedFrame = buildFrame(new Uint8Array([1, 2, 3, 4, 5]), 0x01); + const results = parser.push(invalidCompressedFrame); + + expect(results.length).toBe(1); + expect(results[0].type).toBe('error'); + if (results[0].type === 'error') { + expect(results[0].status).toBe(502); + expect(results[0].errorType).toBe('server_error'); + expect(results[0].message).toContain('decompress'); + } + }); + + it('should reject unknown ConnectRPC frame flag bits', () => { + const parser = new StreamingFrameParser(); + const results = parser.push(buildFrame(new Uint8Array([0x01]), 0x04)); + + expect(results.length).toBe(1); + expect(results[0].type).toBe('error'); + if (results[0].type === 'error') { + expect(results[0].status).toBe(502); + expect(results[0].errorType).toBe('server_error'); + expect(results[0].message).toContain('0x04'); + } + }); + it('should report hasPartial() correctly', () => { const parser = new StreamingFrameParser(); expect(parser.hasPartial()).toBe(false); @@ -1496,10 +1616,18 @@ describe('decompressPayload', () => { expect(result).toEqual(errorPayload); }); - it('should return empty buffer on invalid gzip data', () => { + it('should throw on invalid gzip data', () => { const invalidGzip = Buffer.from([0x01, 0x02, 0x03, 0x04, 0x05]); - const result = decompressPayload(invalidGzip, 0x01); - expect(result.length).toBe(0); + expect(() => decompressPayload(invalidGzip, 0x01)).toThrow( + 'Failed to decompress Cursor ConnectRPC frame.' + ); + }); + + it('should throw on unknown ConnectRPC frame flags', () => { + const payload = Buffer.from('payload'); + expect(() => decompressPayload(payload, 0x04)).toThrow( + 'Unsupported ConnectRPC frame flags: 0x04' + ); }); it('should decompress valid gzip payload', () => { @@ -1511,12 +1639,12 @@ describe('decompressPayload', () => { expect(result.toString()).toBe('Hello compressed world'); }); - it('should handle GZIP_ALT and GZIP_BOTH flags', () => { + it('should not decompress plain end-stream trailers and should handle compressed end-stream trailers', () => { const zlib = require('zlib'); const original = Buffer.from('test data'); const compressed = zlib.gzipSync(original); - expect(decompressPayload(compressed, 0x02).toString()).toBe('test data'); + expect(decompressPayload(original, 0x02)).toEqual(original); expect(decompressPayload(compressed, 0x03).toString()).toBe('test data'); }); }); diff --git a/tests/unit/cursor/cursor-runtime-probe.test.ts b/tests/unit/cursor/cursor-runtime-probe.test.ts new file mode 100644 index 00000000..d8f83beb --- /dev/null +++ b/tests/unit/cursor/cursor-runtime-probe.test.ts @@ -0,0 +1,132 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as http from 'http'; +import * as os from 'os'; +import * as path from 'path'; +import type { CursorConfig } from '../../../src/config/unified-config-types'; + +let tempDir = ''; +let originalCcsHome: string | undefined; +let originalFetch: typeof globalThis.fetch; + +let setGlobalConfigDir: (dir: string | undefined) => void; +let saveCredentials: (credentials: { + accessToken: string; + machineId: string; + authMethod: 'manual' | 'auto-detect'; + importedAt: string; +}) => void; +let deleteCredentials: () => boolean; +let probeCursorRuntime: (config: CursorConfig) => Promise<{ + ok: boolean; + stage: 'config' | 'auth' | 'daemon' | 'runtime'; + status: number; + duration_ms: number; + error_type?: string | null; + message: string; +}>; + +beforeEach(async () => { + originalCcsHome = process.env.CCS_HOME; + originalFetch = globalThis.fetch; + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cursor-runtime-probe-test-')); + process.env.CCS_HOME = tempDir; + + const configManager = await import('../../../src/utils/config-manager'); + setGlobalConfigDir = configManager.setGlobalConfigDir; + setGlobalConfigDir(undefined); + + const cursorAuth = await import('../../../src/cursor/cursor-auth'); + saveCredentials = cursorAuth.saveCredentials; + deleteCredentials = cursorAuth.deleteCredentials; + + const runtimeProbe = await import( + `../../../src/cursor/cursor-runtime-probe?cursor-runtime-probe-test=${Date.now()}` + ); + probeCursorRuntime = runtimeProbe.probeCursorRuntime; + + deleteCredentials(); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + setGlobalConfigDir(undefined); + + if (tempDir && fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +describe('probeCursorRuntime', () => { + it('classifies daemon connection races as daemon-stage failures', async () => { + const port = 23000 + Math.floor(Math.random() * 2000); + + saveCredentials({ + accessToken: 'a'.repeat(60), + machineId: '1234567890abcdef1234567890abcdef', + authMethod: 'manual', + importedAt: new Date().toISOString(), + }); + + const server = http.createServer((req, res) => { + if (req.url === '/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ service: 'cursor-daemon' })); + setImmediate(() => { + server.close(); + }); + return; + } + + res.writeHead(404); + res.end(); + }); + + await new Promise((resolve) => server.listen(port, '127.0.0.1', () => resolve())); + + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + + if (url.startsWith('https://api2.cursor.sh')) { + return new Response( + JSON.stringify({ + models: [{ name: 'gpt-5.3-codex' }], + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + if (url.startsWith(`http://127.0.0.1:${port}/v1/chat/completions`)) { + const error = new Error('fetch failed') as Error & { cause?: { code: string } }; + error.cause = { code: 'ECONNREFUSED' }; + throw error; + } + + throw new Error(`Unexpected fetch: ${url}`); + }) as typeof globalThis.fetch; + + const result = await probeCursorRuntime({ + enabled: true, + port, + auto_start: false, + ghost_mode: true, + model: 'gpt-5.3-codex', + } as CursorConfig); + + expect(result.ok).toBe(false); + expect(result.stage).toBe('daemon'); + expect(result.status).toBe(503); + expect(result.error_type).toBe('daemon_unreachable'); + expect(result.message).toContain('unreachable'); + }); +}); diff --git a/tests/unit/web-server/cursor-routes.test.ts b/tests/unit/web-server/cursor-routes.test.ts index fda059b0..65945b83 100644 --- a/tests/unit/web-server/cursor-routes.test.ts +++ b/tests/unit/web-server/cursor-routes.test.ts @@ -280,9 +280,10 @@ describe('Cursor Routes Logic', () => { }); expect(res.status).toBe(404); - const json = (await res.json()) as { error?: string }; + const json = (await res.json()) as { error?: string; reason?: string }; expect(typeof json.error).toBe('string'); expect(json.error?.length).toBeGreaterThan(0); + expect(json.reason).toBe('db_not_found'); } finally { if (originalHome !== undefined) { process.env.HOME = originalHome; @@ -344,6 +345,82 @@ describe('Cursor Routes Logic', () => { } }); + it('POST /api/cursor/auth/auto-detect returns 503 when sqlite3 is unavailable', async () => { + if (process.platform === 'win32') { + return; + } + + const originalHome = process.env.HOME; + const originalPath = process.env.PATH; + const originalSqliteBin = process.env.CCS_CURSOR_SQLITE_BIN; + const fakeHome = path.join(tempDir, 'sqlite-missing-home'); + process.env.HOME = fakeHome; + process.env.CCS_CURSOR_SQLITE_BIN = 'definitely-missing-sqlite3'; + + try { + const dbPath = getTokenStoragePath(); + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + fs.writeFileSync(dbPath, ''); + + const res = await fetch(`${baseUrl}/api/cursor/auth/auto-detect`, { + method: 'POST', + }); + + expect(res.status).toBe(503); + const json = (await res.json()) as { reason?: string; db_path?: string }; + expect(json.reason).toBe('sqlite_unavailable'); + expect(json.db_path).toBeUndefined(); + } finally { + if (originalHome !== undefined) { + process.env.HOME = originalHome; + } else { + delete process.env.HOME; + } + + if (originalPath !== undefined) { + process.env.PATH = originalPath; + } else { + delete process.env.PATH; + } + if (originalSqliteBin !== undefined) { + process.env.CCS_CURSOR_SQLITE_BIN = originalSqliteBin; + } else { + delete process.env.CCS_CURSOR_SQLITE_BIN; + } + } + }); + + it('POST /api/cursor/auth/auto-detect returns 500 when the database exists but cannot be queried', async () => { + if (process.platform === 'win32') { + return; + } + + const originalHome = process.env.HOME; + const fakeHome = path.join(tempDir, 'query-failed-home'); + process.env.HOME = fakeHome; + + try { + const dbPath = getTokenStoragePath(); + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + fs.writeFileSync(dbPath, 'not-a-sqlite-database'); + + const res = await fetch(`${baseUrl}/api/cursor/auth/auto-detect`, { + method: 'POST', + }); + + expect(res.status).toBe(500); + const json = (await res.json()) as { reason?: string; db_path?: string }; + expect(json.reason).toBe('db_query_failed'); + expect(json.db_path).toBeUndefined(); + } finally { + if (originalHome !== undefined) { + process.env.HOME = originalHome; + } else { + delete process.env.HOME; + } + } + }); + it('POST /api/cursor/daemon/start returns 400 when integration is disabled', async () => { seedCursorConfig({ enabled: false }); seedCredentials(false); @@ -412,5 +489,28 @@ describe('Cursor Routes Logic', () => { expect(json.models.length).toBeGreaterThan(0); expect(json.current).toBe('gpt-5.3-codex'); }); + + it('POST /api/cursor/probe returns auth failure when credentials are missing', async () => { + const res = await fetch(`${baseUrl}/api/cursor/probe`, { method: 'POST' }); + expect(res.status).toBe(401); + + const json = (await res.json()) as { ok?: boolean; stage?: string; error_type?: string }; + expect(json.ok).toBe(false); + expect(json.stage).toBe('auth'); + expect(json.error_type).toBe('authentication_error'); + }); + + it('POST /api/cursor/probe returns daemon failure when daemon is down and auto_start is false', async () => { + seedCursorConfig({ enabled: true, auto_start: false }); + seedCredentials(false); + + const res = await fetch(`${baseUrl}/api/cursor/probe`, { method: 'POST' }); + expect(res.status).toBe(503); + + const json = (await res.json()) as { ok?: boolean; stage?: string; error_type?: string }; + expect(json.ok).toBe(false); + expect(json.stage).toBe('daemon'); + expect(json.error_type).toBe('daemon_not_running'); + }); }); }); diff --git a/ui/src/hooks/use-cursor.ts b/ui/src/hooks/use-cursor.ts index 8e34c6a7..747db82e 100644 --- a/ui/src/hooks/use-cursor.ts +++ b/ui/src/hooks/use-cursor.ts @@ -57,6 +57,35 @@ interface CursorAuthResult { message: string; } +export interface CursorProbeResult { + ok: boolean; + stage: 'config' | 'auth' | 'daemon' | 'runtime'; + status: number; + duration_ms: number; + model?: string; + error_type?: string | null; + message: string; +} + +function isCursorProbeResult(value: unknown): value is CursorProbeResult { + if (!value || typeof value !== 'object') return false; + + const candidate = value as Partial; + return ( + typeof candidate.message === 'string' && + typeof candidate.stage === 'string' && + typeof candidate.status === 'number' && + typeof candidate.duration_ms === 'number' && + typeof candidate.ok === 'boolean' + ); +} + +function getProbeErrorMessage(value: unknown): string | null { + if (!value || typeof value !== 'object' || !('error' in value)) return null; + const candidate = value as { error?: unknown }; + return typeof candidate.error === 'string' ? candidate.error : null; +} + async function fetchCursorStatus(): Promise { const res = await fetch(withApiBase('/cursor/status')); if (!res.ok) throw new Error('Failed to fetch cursor status'); @@ -144,6 +173,24 @@ async function stopCursorDaemon(): Promise<{ success: boolean; error?: string }> return res.json(); } +async function probeCursorRuntime(): Promise { + const res = await fetch(withApiBase('/cursor/probe'), { method: 'POST' }); + const payload = await res.json().catch(() => null); + + if (isCursorProbeResult(payload)) { + return payload; + } + + return { + ok: false, + stage: 'runtime', + status: res.status, + duration_ms: 0, + error_type: 'runtime_error', + message: getProbeErrorMessage(payload) ?? 'Failed to run live probe', + }; +} + export function useCursor() { const queryClient = useQueryClient(); @@ -205,6 +252,14 @@ export function useCursor() { onSuccess: invalidateCursorQueries, }); + const probeMutation = useMutation({ + mutationFn: probeCursorRuntime, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ['cursor-status'] }); + queryClient.invalidateQueries({ queryKey: ['cursor-models'] }); + }, + }); + return useMemo( () => ({ status: statusQuery.data, @@ -248,6 +303,12 @@ export function useCursor() { stopDaemon: stopDaemonMutation.mutate, stopDaemonAsync: stopDaemonMutation.mutateAsync, isStoppingDaemon: stopDaemonMutation.isPending, + + runProbe: probeMutation.mutate, + runProbeAsync: probeMutation.mutateAsync, + isRunningProbe: probeMutation.isPending, + probeResult: probeMutation.data, + resetProbe: probeMutation.reset, }), [ statusQuery.data, @@ -281,6 +342,11 @@ export function useCursor() { stopDaemonMutation.mutate, stopDaemonMutation.mutateAsync, stopDaemonMutation.isPending, + probeMutation.mutate, + probeMutation.mutateAsync, + probeMutation.isPending, + probeMutation.data, + probeMutation.reset, ] ); } diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index 0c2bee8b..faef6c61 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -1325,6 +1325,21 @@ const resources = { rawChanged: 'Raw settings changed externally. Refresh and retry.', failedSaveRaw: 'Failed to save raw settings', savedAll: 'Cursor configuration saved', + liveProbe: 'Live Probe', + runLiveProbe: 'Run Live Probe', + rerunLiveProbe: 'Re-run Live Probe', + probing: 'Running probe...', + probeNotRun: 'Probe not run yet', + probeLocalReadinessHint: + 'Local readiness checks can pass while live runtime access still fails. Use the live probe to verify the full Cursor path.', + probeStage: 'Stage', + probeHttpStatus: 'HTTP Status', + probeDuration: 'Duration', + probeModel: 'Model', + probeMessage: 'Message', + probeSucceeded: 'Probe succeeded', + probeFailed: 'Probe failed', + probeSaveFirst: 'Save or discard Cursor changes before running the live probe', }, droidPage: { loadingDiagnostics: 'Loading Droid diagnostics...', @@ -2658,6 +2673,21 @@ const resources = { rawChanged: '原始设置已被外部修改,请刷新后重试。', failedSaveRaw: '保存原始设置失败', savedAll: 'Cursor 配置已保存', + liveProbe: '实时探测', + runLiveProbe: '运行实时探测', + rerunLiveProbe: '重新运行实时探测', + probing: '正在运行探测...', + probeNotRun: '尚未运行探测', + probeLocalReadinessHint: + '本地就绪检查可能通过,但实时运行链路仍可能失败。请使用实时探测验证完整的 Cursor 路径。', + probeStage: '阶段', + probeHttpStatus: 'HTTP 状态', + probeDuration: '耗时', + probeModel: '模型', + probeMessage: '消息', + probeSucceeded: '探测成功', + probeFailed: '探测失败', + probeSaveFirst: '请先保存或放弃 Cursor 更改,再运行实时探测', }, droidPage: { loadingDiagnostics: '加载 Droid 诊断中...', @@ -4085,6 +4115,21 @@ const resources = { rawChanged: 'Cài đặt thô đã thay đổi bên ngoài. Làm mới và thử lại.', failedSaveRaw: 'Không lưu được cài đặt thô', savedAll: 'Đã lưu cấu hình Cursor', + liveProbe: 'Kiểm tra trực tiếp', + runLiveProbe: 'Chạy kiểm tra trực tiếp', + rerunLiveProbe: 'Chạy lại kiểm tra trực tiếp', + probing: 'Đang kiểm tra...', + probeNotRun: 'Chưa chạy kiểm tra', + probeLocalReadinessHint: + 'Các kiểm tra sẵn sàng cục bộ có thể đạt, nhưng luồng chạy thực tế vẫn có thể lỗi. Hãy dùng kiểm tra trực tiếp để xác minh toàn bộ đường đi của Cursor.', + probeStage: 'Giai đoạn', + probeHttpStatus: 'Trạng thái HTTP', + probeDuration: 'Thời lượng', + probeModel: 'Mô hình', + probeMessage: 'Thông điệp', + probeSucceeded: 'Kiểm tra thành công', + probeFailed: 'Kiểm tra thất bại', + probeSaveFirst: 'Hãy lưu hoặc bỏ các thay đổi Cursor trước khi chạy kiểm tra trực tiếp', }, droidPage: { loadingDiagnostics: 'Đang tải chẩn đoán Droid...', @@ -5520,6 +5565,21 @@ const resources = { rawChanged: '生の設定が外部で変更されました。更新して再試行してください。', failedSaveRaw: '生の設定の保存に失敗しました', savedAll: 'Cursor設定を保存しました', + liveProbe: 'ライブプローブ', + runLiveProbe: 'ライブプローブを実行', + rerunLiveProbe: 'ライブプローブを再実行', + probing: 'プローブを実行中...', + probeNotRun: 'まだプローブを実行していません', + probeLocalReadinessHint: + 'ローカルの準備チェックが通っても、実際のランタイム経路は失敗する場合があります。ライブプローブで Cursor の完全な経路を確認してください。', + probeStage: '段階', + probeHttpStatus: 'HTTP ステータス', + probeDuration: '所要時間', + probeModel: 'モデル', + probeMessage: 'メッセージ', + probeSucceeded: 'プローブ成功', + probeFailed: 'プローブ失敗', + probeSaveFirst: 'ライブプローブを実行する前に Cursor の変更を保存するか破棄してください', }, droidPage: { loadingDiagnostics: 'Droid診断を読み込み中...', diff --git a/ui/src/pages/cursor.tsx b/ui/src/pages/cursor.tsx index e788ecb0..d5bbec3f 100644 --- a/ui/src/pages/cursor.tsx +++ b/ui/src/pages/cursor.tsx @@ -62,6 +62,32 @@ interface RawSettingsParseResult { error?: string; } +function buildProbeSnapshotKey( + status?: { + enabled?: boolean; + authenticated?: boolean; + token_expired?: boolean; + daemon_running?: boolean; + port?: number; + ghost_mode?: boolean; + }, + config?: { + model?: string; + auto_start?: boolean; + } +): string { + return JSON.stringify({ + enabled: status?.enabled ?? null, + authenticated: status?.authenticated ?? null, + token_expired: status?.token_expired ?? null, + daemon_running: status?.daemon_running ?? null, + port: status?.port ?? null, + ghost_mode: status?.ghost_mode ?? null, + auto_start: config?.auto_start ?? null, + model: config?.model ?? null, + }); +} + function buildConfigDraft(config?: { port?: number; auto_start?: boolean; @@ -284,6 +310,10 @@ export function CursorPage() { isStartingDaemon, stopDaemonAsync, isStoppingDaemon, + runProbeAsync, + isRunningProbe, + probeResult, + resetProbe, } = useCursor(); const [configDraft, setConfigDraft] = useState(() => buildConfigDraft()); @@ -293,6 +323,9 @@ export function CursorPage() { const [manualAuthOpen, setManualAuthOpen] = useState(false); const [manualToken, setManualToken] = useState(''); const [manualMachineId, setManualMachineId] = useState(''); + const [probeSnapshotKey, setProbeSnapshotKey] = useState(() => + probeResult ? buildProbeSnapshotKey(status, config) : null + ); const pristineConfigDraft = buildConfigDraft(config); @@ -318,6 +351,14 @@ export function CursorPage() { const isRawJsonValid = rawParseResult.isValid; const hasChanges = configDirty || rawConfigDirty; const canSave = !rawConfigDirty || (rawSettingsReady && isRawJsonValid); + const currentProbeSnapshotKey = buildProbeSnapshotKey(status, config); + const visibleProbeResult = + probeResult && + !hasChanges && + probeSnapshotKey !== null && + probeSnapshotKey === currentProbeSnapshotKey + ? probeResult + : null; const orderedModels = useMemo(() => { const seen = new Set(); const sorted = [...models].sort((a, b) => a.name.localeCompare(b.name)); @@ -349,6 +390,11 @@ export function CursorPage() { setConfigDirty(true); }; + const clearProbeState = () => { + resetProbe(); + setProbeSnapshotKey(null); + }; + const canStart = Boolean(status?.enabled && status?.authenticated && !status?.token_expired); const integrationBadge = useMemo( () => @@ -395,6 +441,7 @@ export function CursorPage() { haiku_model: effectiveHaikuModel || undefined, }) ); + clearProbeState(); if (!suppressSuccessToast) { toast.success(t('cursorPage.savedConfig')); } @@ -500,6 +547,7 @@ export function CursorPage() { const handleToggleEnabled = async (enabled: boolean) => { try { await updateConfigAsync({ enabled }); + clearProbeState(); toast.success( enabled ? t('cursorPage.integrationEnabled') : t('cursorPage.integrationDisabled') ); @@ -511,6 +559,7 @@ export function CursorPage() { const handleAutoDetectAuth = async () => { try { await autoDetectAuthAsync(); + clearProbeState(); toast.success(t('cursorPage.credentialsImported')); } catch (error) { toast.error((error as Error).message || t('cursorPage.autoDetectFailed')); @@ -528,6 +577,7 @@ export function CursorPage() { accessToken: manualToken.trim(), machineId: manualMachineId.trim(), }); + clearProbeState(); toast.success(t('cursorPage.credentialsImported')); setManualAuthOpen(false); setManualToken(''); @@ -544,6 +594,7 @@ export function CursorPage() { toast.error(result.error || t('cursorPage.failedStartDaemon')); return; } + clearProbeState(); toast.success( result.pid ? t('cursorPage.daemonStartedWithPid', { pid: result.pid }) @@ -561,12 +612,34 @@ export function CursorPage() { toast.error(result.error || t('cursorPage.failedStopDaemon')); return; } + clearProbeState(); toast.success(t('cursorPage.daemonStopped')); } catch (error) { toast.error((error as Error).message || t('cursorPage.failedStopDaemon')); } }; + const handleRunProbe = async () => { + if (hasChanges) { + toast.error(t('cursorPage.probeSaveFirst')); + return; + } + + try { + const result = await runProbeAsync(); + const refreshedStatus = await refetchStatus(); + setProbeSnapshotKey(buildProbeSnapshotKey(refreshedStatus.data ?? status, config)); + if (result.ok) { + toast.success(t('cursorPage.probeSucceeded')); + return; + } + + toast.error(result.message || t('cursorPage.probeFailed')); + } catch (error) { + toast.error((error as Error).message || t('cursorPage.probeFailed')); + } + }; + const handleSaveRawSettings = async ({ suppressSuccessToast = false, }: { suppressSuccessToast?: boolean } = {}) => { @@ -586,6 +659,7 @@ export function CursorPage() { expectedMtime: rawSettings?.mtime, }); setRawConfigDirty(false); + clearProbeState(); if (!suppressSuccessToast) { toast.success(t('cursorPage.rawSaved')); } @@ -627,6 +701,7 @@ export function CursorPage() { const handleHeaderRefresh = () => { setRawConfigDirty(false); + clearProbeState(); refetchStatus(); refetchRawSettings(); }; @@ -710,6 +785,71 @@ export function CursorPage() { /> +
+
+
+ + + {t('cursorPage.liveProbe')} + +
+ + {visibleProbeResult + ? visibleProbeResult.ok + ? t('cursorPage.probeSucceeded') + : t('cursorPage.probeFailed') + : t('cursorPage.probeNotRun')} + +
+ + {visibleProbeResult ? ( +
+
+ {t('cursorPage.probeStage')} + {visibleProbeResult.stage} +
+
+ + {t('cursorPage.probeHttpStatus')} + + {visibleProbeResult.status} +
+
+ {t('cursorPage.probeDuration')} + {visibleProbeResult.duration_ms} ms +
+ {visibleProbeResult.model ? ( +
+ {t('cursorPage.probeModel')} + + {visibleProbeResult.model} + +
+ ) : null} +
+ {t('cursorPage.probeMessage')} +

+ {visibleProbeResult.message} +

+
+
+ ) : ( +

{t('cursorPage.probeNotRun')}

+ )} + +

+ {t('cursorPage.probeLocalReadinessHint')} +

+
+

{t('cursorPage.actions')} @@ -763,6 +903,25 @@ export function CursorPage() { {t('cursorPage.manualAuthImport')} + + {status?.daemon_running ? (