mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 14:17:11 +00:00
feat(cursor): harden live probe and runtime contracts
This commit is contained in:
1 parent
2eaf760d20
commit
e1049e38d4
23 files changed
+2186
-306
No files matched your search
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user