mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 10:16:49 +00:00
feat(cursor): harden live probe and runtime contracts
This commit is contained in:
@@ -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:<port>`
|
||||
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 <token> --machine-id <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
|
||||
|
||||
@@ -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);
|
||||
|
||||
+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.',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+348
-200
@@ -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<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;
|
||||
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') {
|
||||
|
||||
@@ -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,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<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) {
|
||||
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.',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<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',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
@@ -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';
|
||||
@@ -124,7 +125,16 @@ 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 =
|
||||
result.reason === 'sqlite_unavailable'
|
||||
? 503
|
||||
: result.reason === 'db_query_failed'
|
||||
? 500
|
||||
: 404;
|
||||
res.status(status).json({
|
||||
error: result.error ?? 'Token not found',
|
||||
reason: result.reason ?? null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -155,6 +165,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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -484,6 +484,9 @@ describe('renderCursorHelp', () => {
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(logs.some((line) => line.includes('Usage: ccs cursor <subcommand>'))).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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void>((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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<CursorProbeResult>;
|
||||
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<CursorStatus> {
|
||||
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<CursorProbeResult> {
|
||||
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,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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診断を読み込み中...',
|
||||
|
||||
@@ -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<CursorConfigDraft>(() => 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<string | null>(() =>
|
||||
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<string>();
|
||||
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() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border bg-background/80 p-3 space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Code2 className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{t('cursorPage.liveProbe')}
|
||||
</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant={probeResult ? 'outline' : 'secondary'}
|
||||
className={cn(
|
||||
probeResult?.ok && 'border-green-500/40 text-green-600 dark:text-green-300',
|
||||
probeResult &&
|
||||
!probeResult.ok &&
|
||||
'border-red-500/40 text-red-600 dark:text-red-300'
|
||||
)}
|
||||
>
|
||||
{visibleProbeResult
|
||||
? visibleProbeResult.ok
|
||||
? t('cursorPage.probeSucceeded')
|
||||
: t('cursorPage.probeFailed')
|
||||
: t('cursorPage.probeNotRun')}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{visibleProbeResult ? (
|
||||
<div className="space-y-1 text-xs">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">{t('cursorPage.probeStage')}</span>
|
||||
<span className="font-mono uppercase">{visibleProbeResult.stage}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">
|
||||
{t('cursorPage.probeHttpStatus')}
|
||||
</span>
|
||||
<span className="font-mono">{visibleProbeResult.status}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">{t('cursorPage.probeDuration')}</span>
|
||||
<span className="font-mono">{visibleProbeResult.duration_ms} ms</span>
|
||||
</div>
|
||||
{visibleProbeResult.model ? (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">{t('cursorPage.probeModel')}</span>
|
||||
<span className="font-mono text-[11px] text-right break-all">
|
||||
{visibleProbeResult.model}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-1 pt-1">
|
||||
<span className="text-muted-foreground">{t('cursorPage.probeMessage')}</span>
|
||||
<p className="text-[11px] leading-relaxed break-words">
|
||||
{visibleProbeResult.message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">{t('cursorPage.probeNotRun')}</p>
|
||||
)}
|
||||
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t('cursorPage.probeLocalReadinessHint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
{t('cursorPage.actions')}
|
||||
@@ -763,6 +903,25 @@ export function CursorPage() {
|
||||
{t('cursorPage.manualAuthImport')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={handleRunProbe}
|
||||
disabled={isRunningProbe}
|
||||
>
|
||||
{isRunningProbe ? (
|
||||
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<Code2 className="w-3.5 h-3.5 mr-1.5" />
|
||||
)}
|
||||
{isRunningProbe
|
||||
? t('cursorPage.probing')
|
||||
: visibleProbeResult
|
||||
? t('cursorPage.rerunLiveProbe')
|
||||
: t('cursorPage.runLiveProbe')}
|
||||
</Button>
|
||||
|
||||
{status?.daemon_running ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ReactNode } from 'react';
|
||||
import { AllProviders } from '../../setup/test-utils';
|
||||
import { useCursor } from '@/hooks/use-cursor';
|
||||
|
||||
function createJsonResponse(body: Record<string, unknown>, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => <AllProviders>{children}</AllProviders>;
|
||||
|
||||
describe('useCursor', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('preserves structured probe failures and refreshes live status queries', async () => {
|
||||
const probeFailure = {
|
||||
ok: false,
|
||||
stage: 'daemon',
|
||||
status: 503,
|
||||
duration_ms: 321,
|
||||
error_type: 'daemon_not_running',
|
||||
message: 'Cursor daemon is not running.',
|
||||
};
|
||||
|
||||
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
|
||||
if (url.endsWith('/api/cursor/status')) {
|
||||
return Promise.resolve(
|
||||
createJsonResponse({
|
||||
enabled: true,
|
||||
authenticated: true,
|
||||
auth_method: 'manual',
|
||||
token_age: 1,
|
||||
token_expired: false,
|
||||
daemon_running: false,
|
||||
port: 20129,
|
||||
auto_start: false,
|
||||
ghost_mode: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (url.endsWith('/api/cursor/settings')) {
|
||||
return Promise.resolve(
|
||||
createJsonResponse({
|
||||
enabled: true,
|
||||
port: 20129,
|
||||
auto_start: false,
|
||||
ghost_mode: true,
|
||||
model: 'gpt-5.3-codex',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (url.endsWith('/api/cursor/models')) {
|
||||
return Promise.resolve(
|
||||
createJsonResponse({
|
||||
models: [{ id: 'gpt-5.3-codex', name: 'GPT-5.3 Codex', provider: 'openai' }],
|
||||
current: 'gpt-5.3-codex',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (url.endsWith('/api/cursor/settings/raw')) {
|
||||
return Promise.resolve(
|
||||
createJsonResponse({
|
||||
settings: {},
|
||||
mtime: 100,
|
||||
path: '/tmp/cursor.settings.json',
|
||||
exists: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (url.endsWith('/api/cursor/probe') && init?.method === 'POST') {
|
||||
return Promise.resolve(createJsonResponse(probeFailure, 503));
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { result } = renderHook(() => useCursor(), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.status?.enabled).toBe(true));
|
||||
await waitFor(() => expect(result.current.models.length).toBe(1));
|
||||
|
||||
await act(async () => {
|
||||
const probeResult = await result.current.runProbeAsync();
|
||||
expect(probeResult).toMatchObject(probeFailure);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.probeResult).toMatchObject(probeFailure));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(([input]) => String(input).endsWith('/api/cursor/status'))
|
||||
.length
|
||||
).toBeGreaterThanOrEqual(2)
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(([input]) => String(input).endsWith('/api/cursor/models'))
|
||||
.length
|
||||
).toBeGreaterThanOrEqual(2)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, userEvent, waitFor } from '@tests/setup/test-utils';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
useCursor: vi.fn(),
|
||||
runProbeAsync: vi.fn(),
|
||||
resetProbe: vi.fn(),
|
||||
refetchStatus: vi.fn(),
|
||||
refetchRawSettings: vi.fn(),
|
||||
updateConfigAsync: vi.fn(),
|
||||
saveRawSettingsAsync: vi.fn(),
|
||||
autoDetectAuthAsync: vi.fn(),
|
||||
importManualAuthAsync: vi.fn(),
|
||||
startDaemonAsync: vi.fn(),
|
||||
stopDaemonAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-cursor', () => ({
|
||||
useCursor: mocks.useCursor,
|
||||
}));
|
||||
|
||||
const toastMocks = vi.hoisted(() => ({
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: toastMocks,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/copilot/config-form/raw-editor-section', () => ({
|
||||
RawEditorSection: () => <div>Raw Editor</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/searchable-select', () => ({
|
||||
SearchableSelect: ({ value }: { value?: string }) => (
|
||||
<div data-testid="searchable-select">{value ?? 'select'}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import { CursorPage } from '@/pages/cursor';
|
||||
|
||||
const probeFailure = {
|
||||
ok: false,
|
||||
stage: 'daemon',
|
||||
status: 503,
|
||||
duration_ms: 321,
|
||||
error_type: 'daemon_not_running',
|
||||
model: 'gpt-5.3-codex',
|
||||
message: 'Cursor daemon is not running.',
|
||||
};
|
||||
|
||||
function buildUseCursorResult(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
status: {
|
||||
enabled: true,
|
||||
authenticated: true,
|
||||
auth_method: 'manual',
|
||||
token_age: 1,
|
||||
token_expired: false,
|
||||
daemon_running: false,
|
||||
port: 20129,
|
||||
auto_start: false,
|
||||
ghost_mode: true,
|
||||
},
|
||||
statusLoading: false,
|
||||
refetchStatus: mocks.refetchStatus,
|
||||
config: {
|
||||
enabled: true,
|
||||
port: 20129,
|
||||
auto_start: false,
|
||||
ghost_mode: true,
|
||||
model: 'gpt-5.3-codex',
|
||||
opus_model: 'gpt-5.1-codex-max',
|
||||
sonnet_model: 'gpt-5.3-codex',
|
||||
haiku_model: 'gpt-5-mini',
|
||||
},
|
||||
updateConfigAsync: mocks.updateConfigAsync,
|
||||
isUpdatingConfig: false,
|
||||
models: [{ id: 'gpt-5.3-codex', name: 'GPT-5.3 Codex', provider: 'openai' }],
|
||||
modelsLoading: false,
|
||||
currentModel: 'gpt-5.3-codex',
|
||||
rawSettings: {
|
||||
settings: {},
|
||||
mtime: 100,
|
||||
path: '/tmp/cursor.settings.json',
|
||||
exists: true,
|
||||
},
|
||||
rawSettingsLoading: false,
|
||||
refetchRawSettings: mocks.refetchRawSettings,
|
||||
saveRawSettingsAsync: mocks.saveRawSettingsAsync,
|
||||
isSavingRawSettings: false,
|
||||
autoDetectAuthAsync: mocks.autoDetectAuthAsync,
|
||||
isAutoDetectingAuth: false,
|
||||
importManualAuthAsync: mocks.importManualAuthAsync,
|
||||
isImportingManualAuth: false,
|
||||
startDaemonAsync: mocks.startDaemonAsync,
|
||||
isStartingDaemon: false,
|
||||
stopDaemonAsync: mocks.stopDaemonAsync,
|
||||
isStoppingDaemon: false,
|
||||
runProbeAsync: mocks.runProbeAsync,
|
||||
isRunningProbe: false,
|
||||
probeResult: undefined,
|
||||
resetProbe: mocks.resetProbe,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('CursorPage', () => {
|
||||
beforeEach(() => {
|
||||
mocks.runProbeAsync.mockReset();
|
||||
mocks.resetProbe.mockReset();
|
||||
mocks.runProbeAsync.mockResolvedValue(probeFailure);
|
||||
mocks.useCursor.mockReturnValue(buildUseCursorResult());
|
||||
toastMocks.error.mockReset();
|
||||
toastMocks.success.mockReset();
|
||||
});
|
||||
|
||||
it('renders persistent live probe results in the sidebar', async () => {
|
||||
mocks.useCursor.mockReturnValue(
|
||||
buildUseCursorResult({
|
||||
probeResult: probeFailure,
|
||||
})
|
||||
);
|
||||
|
||||
render(<CursorPage />);
|
||||
|
||||
expect(screen.getByText('Live Probe')).toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByText('Probe failed')).toBeInTheDocument());
|
||||
expect(screen.getByText('503')).toBeInTheDocument();
|
||||
expect(screen.getByText('321 ms')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cursor daemon is not running.')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('gpt-5.3-codex').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('runs the live probe from the sidebar action', async () => {
|
||||
render(<CursorPage />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Run Live Probe' }));
|
||||
|
||||
await waitFor(() => expect(mocks.runProbeAsync).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('blocks live probe runs while local edits are unsaved', async () => {
|
||||
render(<CursorPage />);
|
||||
|
||||
await userEvent.click(screen.getByRole('tab', { name: 'Settings' }));
|
||||
await userEvent.clear(screen.getByLabelText('Port'));
|
||||
await userEvent.type(screen.getByLabelText('Port'), '20130');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Run Live Probe' }));
|
||||
|
||||
expect(mocks.runProbeAsync).not.toHaveBeenCalled();
|
||||
expect(toastMocks.error).toHaveBeenCalledWith(
|
||||
'Save or discard Cursor changes before running the live probe'
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user