fix(cursor): authenticate local daemon health/runtime traffic (#1373)

This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-05-23 21:44:55 -04:00
1 parent 6c0f7ac39c
commit 03a30119d9
10 files changed
+82 -12

No files matched your search

+24
View File
@@ -0,0 +1,24 @@
import * as crypto from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import { getCcsDir } from '../utils/config-manager';
const DAEMON_TOKEN_FILE = 'cursor-daemon-token';
export function getCursorDaemonToken(): string {
const tokenPath = path.join(getCcsDir(), DAEMON_TOKEN_FILE);
try {
const token = fs.readFileSync(tokenPath, 'utf8').trim();
if (token.length > 0) {
return token;
}
} catch {
// Token file missing or unreadable; regenerate below.
}
const token = crypto.randomBytes(32).toString('hex');
fs.mkdirSync(path.dirname(tokenPath), { recursive: true });
fs.writeFileSync(tokenPath, token, { mode: 0o600 });
return token;
}
+26
View File
@@ -108,6 +108,23 @@ function readJsonBody(req: http.IncomingMessage): Promise<unknown> {
}); });
} }
function hasValidDaemonToken(req: http.IncomingMessage): boolean {
const expectedToken = process.env.CCS_CURSOR_DAEMON_TOKEN;
if (!expectedToken) {
return false;
}
const provided = req.headers['x-ccs-cursor-token'];
if (typeof provided === 'string') {
return provided === expectedToken;
}
if (Array.isArray(provided)) {
return provided.includes(expectedToken);
}
return false;
}
function normalizeMessages(raw: unknown): NormalizedOpenAIMessage[] { function normalizeMessages(raw: unknown): NormalizedOpenAIMessage[] {
if (!Array.isArray(raw)) { if (!Array.isArray(raw)) {
throw new Error('messages must be an array'); throw new Error('messages must be an array');
@@ -202,6 +219,10 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser
try { try {
if (method === 'GET' && requestUrl === '/health') { if (method === 'GET' && requestUrl === '/health') {
if (!hasValidDaemonToken(req)) {
writeJson(res, 401, { error: 'Unauthorized' });
return;
}
writeJson(res, 200, { ok: true, service: 'cursor-daemon' }); writeJson(res, 200, { ok: true, service: 'cursor-daemon' });
return; return;
} }
@@ -234,6 +255,11 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser
return; return;
} }
if (!hasValidDaemonToken(req)) {
writeJson(res, 401, { error: 'Unauthorized' });
return;
}
const rawBody = await readJsonBody(req); const rawBody = await readJsonBody(req);
const anthropicBody = isAnthropicRoute ? translateAnthropicRequest(rawBody) : undefined; const anthropicBody = isAnthropicRoute ? translateAnthropicRequest(rawBody) : undefined;
const parsedBody = anthropicBody ?? ((rawBody as OpenAIChatRequest) || {}); const parsedBody = anthropicBody ?? ((rawBody as OpenAIChatRequest) || {});
+8 -3
View File
@@ -56,7 +56,7 @@ async function resolveDaemonEntrypoint(): Promise<string | null> {
* Check if cursor daemon is running on the specified port. * Check if cursor daemon is running on the specified port.
* Uses 127.0.0.1 instead of localhost for more reliable local connections. * Uses 127.0.0.1 instead of localhost for more reliable local connections.
*/ */
export async function isDaemonRunning(port: number): Promise<boolean> { export async function isDaemonRunning(port: number, daemonToken?: string): Promise<boolean> {
return new Promise((resolve) => { return new Promise((resolve) => {
const req = http.request( const req = http.request(
{ {
@@ -65,6 +65,7 @@ export async function isDaemonRunning(port: number): Promise<boolean> {
path: '/health', path: '/health',
method: 'GET', method: 'GET',
timeout: 3000, timeout: 3000,
headers: daemonToken ? { 'x-ccs-cursor-token': daemonToken } : undefined,
}, },
(res) => { (res) => {
let body = ''; let body = '';
@@ -127,7 +128,7 @@ export async function startDaemon(
config: CursorDaemonConfig config: CursorDaemonConfig
): Promise<{ success: boolean; pid?: number; error?: string }> { ): Promise<{ success: boolean; pid?: number; error?: string }> {
// Check if already running // Check if already running
if (await isDaemonRunning(config.port)) { if (await isDaemonRunning(config.port, config.daemon_token)) {
logger.stage('dispatch', 'cursor.daemon.already_running', 'Cursor daemon already running', { logger.stage('dispatch', 'cursor.daemon.already_running', 'Cursor daemon already running', {
provider: 'cursor', provider: 'cursor',
port: config.port, port: config.port,
@@ -205,6 +206,10 @@ export async function startDaemon(
proc = spawn(process.execPath, args, { proc = spawn(process.execPath, args, {
stdio: 'ignore', stdio: 'ignore',
detached: true, detached: true,
env: {
...process.env,
CCS_CURSOR_DAEMON_TOKEN: config.daemon_token || '',
},
}); });
// Unref so parent can exit // Unref so parent can exit
@@ -220,7 +225,7 @@ export async function startDaemon(
const pollHealth = async () => { const pollHealth = async () => {
attempts++; attempts++;
if (await isDaemonRunning(config.port)) { if (await isDaemonRunning(config.port, config.daemon_token)) {
safeResolve({ success: true, pid: proc.pid }); safeResolve({ success: true, pid: proc.pid });
} else if (attempts >= maxAttempts) { } else if (attempts >= maxAttempts) {
// Kill orphaned process // Kill orphaned process
+2 -1
View File
@@ -8,6 +8,7 @@ import * as http from 'http';
import type { CursorModel } from './types'; import type { CursorModel } from './types';
import type { CursorApiCredentials } from './cursor-protobuf-schema'; import type { CursorApiCredentials } from './cursor-protobuf-schema';
import { isDaemonRunning } from './cursor-daemon'; import { isDaemonRunning } from './cursor-daemon';
import { getCursorDaemonToken } from './cursor-daemon-auth';
import { buildCursorModelsHeaders } from './cursor-client-policy'; import { buildCursorModelsHeaders } from './cursor-client-policy';
import { import {
DEFAULT_CURSOR_MODEL, DEFAULT_CURSOR_MODEL,
@@ -285,7 +286,7 @@ export async function fetchModelsFromDaemon(port: number): Promise<CursorModel[]
* Checks daemon health first to avoid 5s timeout when daemon is not running. * Checks daemon health first to avoid 5s timeout when daemon is not running.
*/ */
export async function getAvailableModels(port: number): Promise<CursorModel[]> { export async function getAvailableModels(port: number): Promise<CursorModel[]> {
if (!(await isDaemonRunning(port))) { if (!(await isDaemonRunning(port, getCursorDaemonToken()))) {
return DEFAULT_CURSOR_MODELS; return DEFAULT_CURSOR_MODELS;
} }
return fetchModelsFromDaemon(port); return fetchModelsFromDaemon(port);
+8 -3
View File
@@ -15,6 +15,7 @@ import { getImageAnalysisHookEnv, resolveImageAnalysisRuntimeStatus } from '../u
import { stripClaudeCodeEnv } from '../utils/shell-executor'; import { stripClaudeCodeEnv } from '../utils/shell-executor';
import { checkAuthStatus } from './cursor-auth'; import { checkAuthStatus } from './cursor-auth';
import { isDaemonRunning, startDaemon } from './cursor-daemon'; import { isDaemonRunning, startDaemon } from './cursor-daemon';
import { getCursorDaemonToken } from './cursor-daemon-auth';
import { getGlobalEnvConfig } from '../config/config-loader-facade'; import { getGlobalEnvConfig } from '../config/config-loader-facade';
interface CursorImageAnalysisResolution { interface CursorImageAnalysisResolution {
@@ -31,6 +32,7 @@ interface CursorImageAnalysisDeps {
export function generateCursorEnv( export function generateCursorEnv(
config: CursorConfig, config: CursorConfig,
daemonToken: string,
claudeConfigDir?: string claudeConfigDir?: string
): Record<string, string> { ): Record<string, string> {
const opusModel = config.opus_model || config.model; const opusModel = config.opus_model || config.model;
@@ -39,7 +41,7 @@ export function generateCursorEnv(
return { return {
ANTHROPIC_BASE_URL: `http://127.0.0.1:${config.port}`, ANTHROPIC_BASE_URL: `http://127.0.0.1:${config.port}`,
ANTHROPIC_AUTH_TOKEN: 'cursor-managed', ANTHROPIC_AUTH_TOKEN: daemonToken,
ANTHROPIC_MODEL: config.model, ANTHROPIC_MODEL: config.model,
ANTHROPIC_DEFAULT_OPUS_MODEL: opusModel, ANTHROPIC_DEFAULT_OPUS_MODEL: opusModel,
ANTHROPIC_DEFAULT_SONNET_MODEL: sonnetModel, ANTHROPIC_DEFAULT_SONNET_MODEL: sonnetModel,
@@ -130,13 +132,16 @@ export async function executeCursorProfile(
return 1; return 1;
} }
let daemonRunning = await isDaemonRunning(config.port); const daemonToken = getCursorDaemonToken();
let daemonRunning = await isDaemonRunning(config.port, daemonToken);
if (!daemonRunning) { if (!daemonRunning) {
if (config.auto_start) { if (config.auto_start) {
console.log(info('Starting cursor daemon...')); console.log(info('Starting cursor daemon...'));
const result = await startDaemon({ const result = await startDaemon({
port: config.port, port: config.port,
ghost_mode: config.ghost_mode, ghost_mode: config.ghost_mode,
daemon_token: daemonToken,
}); });
if (!result.success) { if (!result.success) {
console.error(fail(`Failed to start cursor daemon: ${result.error}`)); console.error(fail(`Failed to start cursor daemon: ${result.error}`));
@@ -154,7 +159,7 @@ export async function executeCursorProfile(
} }
} }
const cursorEnv = generateCursorEnv(config, claudeConfigDir); const cursorEnv = generateCursorEnv(config, daemonToken, claudeConfigDir);
const globalEnvConfig = getGlobalEnvConfig(); const globalEnvConfig = getGlobalEnvConfig();
const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {}; const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {};
const webSearchEnv = getWebSearchHookEnv(); const webSearchEnv = getWebSearchHookEnv();
+4 -2
View File
@@ -1,6 +1,7 @@
import type { CursorConfig } from '../config/unified-config-types'; import type { CursorConfig } from '../config/unified-config-types';
import { checkAuthStatus } from './cursor-auth'; import { checkAuthStatus } from './cursor-auth';
import { isDaemonRunning, startDaemon } from './cursor-daemon'; import { isDaemonRunning, startDaemon } from './cursor-daemon';
import { getCursorDaemonToken } from './cursor-daemon-auth';
import { getModelsForDaemon, resolveCursorRequestModel } from './cursor-models'; import { getModelsForDaemon, resolveCursorRequestModel } from './cursor-models';
export interface CursorProbeResult { export interface CursorProbeResult {
@@ -136,7 +137,8 @@ export async function probeCursorRuntime(config: CursorConfig): Promise<CursorPr
}; };
} }
let daemonRunning = await isDaemonRunning(config.port); const daemonToken = getCursorDaemonToken();
let daemonRunning = await isDaemonRunning(config.port, daemonToken);
if (!daemonRunning && config.auto_start) { if (!daemonRunning && config.auto_start) {
const startResult = await startDaemon({ const startResult = await startDaemon({
port: config.port, port: config.port,
@@ -144,7 +146,7 @@ export async function probeCursorRuntime(config: CursorConfig): Promise<CursorPr
}); });
if (!startResult.success) { if (!startResult.success) {
daemonRunning = await isDaemonRunning(config.port); daemonRunning = await isDaemonRunning(config.port, daemonToken);
} else { } else {
daemonRunning = true; daemonRunning = true;
} }
+1
View File
@@ -10,6 +10,7 @@
export interface CursorDaemonConfig { export interface CursorDaemonConfig {
port: number; port: number;
ghost_mode?: boolean; ghost_mode?: boolean;
daemon_token?: string;
} }
/** /**
+6 -1
View File
@@ -13,6 +13,7 @@ import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager';
import { getProxyTarget } from '../cliproxy/proxy/proxy-target-resolver'; import { getProxyTarget } from '../cliproxy/proxy/proxy-target-resolver';
import { generateCopilotEnv } from '../copilot/copilot-executor'; import { generateCopilotEnv } from '../copilot/copilot-executor';
import { generateCursorEnv } from '../cursor'; import { generateCursorEnv } from '../cursor';
import { getCursorDaemonToken } from '../cursor/cursor-daemon-auth';
import InstanceManager from '../management/instance-manager'; import InstanceManager from '../management/instance-manager';
import SharedManager from '../management/shared-manager'; import SharedManager from '../management/shared-manager';
import { expandPath } from '../utils/helpers'; import { expandPath } from '../utils/helpers';
@@ -245,7 +246,11 @@ async function resolveExtensionEnv(
if (!result.cursorConfig) { if (!result.cursorConfig) {
throw new Error(`Profile "${requestedProfile}" is missing cursor configuration.`); throw new Error(`Profile "${requestedProfile}" is missing cursor configuration.`);
} }
return generateCursorEnv(result.cursorConfig, continuity.claudeConfigDir); return generateCursorEnv(
result.cursorConfig,
getCursorDaemonToken(),
continuity.claudeConfigDir
);
})() })()
: (() => { : (() => {
if (!result.provider) { if (!result.provider) {
+1 -1
View File
@@ -197,7 +197,7 @@ describe('isDaemonRunning', () => {
throw new Error('Unable to resolve test server port'); throw new Error('Unable to resolve test server port');
} }
const result = await isDaemonRunning(address.port); const result = await isDaemonRunning(address.port, "bad-token");
expect(result).toBe(false); expect(result).toBe(false);
} finally { } finally {
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
@@ -46,11 +46,12 @@ describe('cursor-profile-executor', () => {
sonnet_model: 'cursor-sonnet', sonnet_model: 'cursor-sonnet',
haiku_model: 'cursor-haiku', haiku_model: 'cursor-haiku',
}, },
'test-token',
'/tmp/claude-config' '/tmp/claude-config'
); );
expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:20129'); expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:20129');
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('cursor-managed'); expect(env.ANTHROPIC_AUTH_TOKEN).toBe('test-token');
expect(env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); expect(env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex');
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('cursor-opus'); expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('cursor-opus');
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('cursor-sonnet'); expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('cursor-sonnet');