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

This commit is contained in:
Kai (Tam Nhu) Tran
2026-05-23 21:44:55 -04:00
committed by GitHub
parent 6c0f7ac39c
commit 03a30119d9
10 changed files with 82 additions and 12 deletions
+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[] {
if (!Array.isArray(raw)) {
throw new Error('messages must be an array');
@@ -202,6 +219,10 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser
try {
if (method === 'GET' && requestUrl === '/health') {
if (!hasValidDaemonToken(req)) {
writeJson(res, 401, { error: 'Unauthorized' });
return;
}
writeJson(res, 200, { ok: true, service: 'cursor-daemon' });
return;
}
@@ -234,6 +255,11 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser
return;
}
if (!hasValidDaemonToken(req)) {
writeJson(res, 401, { error: 'Unauthorized' });
return;
}
const rawBody = await readJsonBody(req);
const anthropicBody = isAnthropicRoute ? translateAnthropicRequest(rawBody) : undefined;
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.
* 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) => {
const req = http.request(
{
@@ -65,6 +65,7 @@ export async function isDaemonRunning(port: number): Promise<boolean> {
path: '/health',
method: 'GET',
timeout: 3000,
headers: daemonToken ? { 'x-ccs-cursor-token': daemonToken } : undefined,
},
(res) => {
let body = '';
@@ -127,7 +128,7 @@ export async function startDaemon(
config: CursorDaemonConfig
): Promise<{ success: boolean; pid?: number; error?: string }> {
// 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', {
provider: 'cursor',
port: config.port,
@@ -205,6 +206,10 @@ export async function startDaemon(
proc = spawn(process.execPath, args, {
stdio: 'ignore',
detached: true,
env: {
...process.env,
CCS_CURSOR_DAEMON_TOKEN: config.daemon_token || '',
},
});
// Unref so parent can exit
@@ -220,7 +225,7 @@ export async function startDaemon(
const pollHealth = async () => {
attempts++;
if (await isDaemonRunning(config.port)) {
if (await isDaemonRunning(config.port, config.daemon_token)) {
safeResolve({ success: true, pid: proc.pid });
} else if (attempts >= maxAttempts) {
// Kill orphaned process
+2 -1
View File
@@ -8,6 +8,7 @@ import * as http from 'http';
import type { CursorModel } from './types';
import type { CursorApiCredentials } from './cursor-protobuf-schema';
import { isDaemonRunning } from './cursor-daemon';
import { getCursorDaemonToken } from './cursor-daemon-auth';
import { buildCursorModelsHeaders } from './cursor-client-policy';
import {
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.
*/
export async function getAvailableModels(port: number): Promise<CursorModel[]> {
if (!(await isDaemonRunning(port))) {
if (!(await isDaemonRunning(port, getCursorDaemonToken()))) {
return DEFAULT_CURSOR_MODELS;
}
return fetchModelsFromDaemon(port);
+8 -3
View File
@@ -15,6 +15,7 @@ import { getImageAnalysisHookEnv, resolveImageAnalysisRuntimeStatus } from '../u
import { stripClaudeCodeEnv } from '../utils/shell-executor';
import { checkAuthStatus } from './cursor-auth';
import { isDaemonRunning, startDaemon } from './cursor-daemon';
import { getCursorDaemonToken } from './cursor-daemon-auth';
import { getGlobalEnvConfig } from '../config/config-loader-facade';
interface CursorImageAnalysisResolution {
@@ -31,6 +32,7 @@ interface CursorImageAnalysisDeps {
export function generateCursorEnv(
config: CursorConfig,
daemonToken: string,
claudeConfigDir?: string
): Record<string, string> {
const opusModel = config.opus_model || config.model;
@@ -39,7 +41,7 @@ export function generateCursorEnv(
return {
ANTHROPIC_BASE_URL: `http://127.0.0.1:${config.port}`,
ANTHROPIC_AUTH_TOKEN: 'cursor-managed',
ANTHROPIC_AUTH_TOKEN: daemonToken,
ANTHROPIC_MODEL: config.model,
ANTHROPIC_DEFAULT_OPUS_MODEL: opusModel,
ANTHROPIC_DEFAULT_SONNET_MODEL: sonnetModel,
@@ -130,13 +132,16 @@ export async function executeCursorProfile(
return 1;
}
let daemonRunning = await isDaemonRunning(config.port);
const daemonToken = getCursorDaemonToken();
let daemonRunning = await isDaemonRunning(config.port, daemonToken);
if (!daemonRunning) {
if (config.auto_start) {
console.log(info('Starting cursor daemon...'));
const result = await startDaemon({
port: config.port,
ghost_mode: config.ghost_mode,
daemon_token: daemonToken,
});
if (!result.success) {
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 globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {};
const webSearchEnv = getWebSearchHookEnv();
+4 -2
View File
@@ -1,6 +1,7 @@
import type { CursorConfig } from '../config/unified-config-types';
import { checkAuthStatus } from './cursor-auth';
import { isDaemonRunning, startDaemon } from './cursor-daemon';
import { getCursorDaemonToken } from './cursor-daemon-auth';
import { getModelsForDaemon, resolveCursorRequestModel } from './cursor-models';
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) {
const startResult = await startDaemon({
port: config.port,
@@ -144,7 +146,7 @@ export async function probeCursorRuntime(config: CursorConfig): Promise<CursorPr
});
if (!startResult.success) {
daemonRunning = await isDaemonRunning(config.port);
daemonRunning = await isDaemonRunning(config.port, daemonToken);
} else {
daemonRunning = true;
}
+1
View File
@@ -10,6 +10,7 @@
export interface CursorDaemonConfig {
port: number;
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 { generateCopilotEnv } from '../copilot/copilot-executor';
import { generateCursorEnv } from '../cursor';
import { getCursorDaemonToken } from '../cursor/cursor-daemon-auth';
import InstanceManager from '../management/instance-manager';
import SharedManager from '../management/shared-manager';
import { expandPath } from '../utils/helpers';
@@ -245,7 +246,11 @@ async function resolveExtensionEnv(
if (!result.cursorConfig) {
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) {
+1 -1
View File
@@ -197,7 +197,7 @@ describe('isDaemonRunning', () => {
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);
} finally {
await new Promise<void>((resolve) => {
@@ -46,11 +46,12 @@ describe('cursor-profile-executor', () => {
sonnet_model: 'cursor-sonnet',
haiku_model: 'cursor-haiku',
},
'test-token',
'/tmp/claude-config'
);
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_DEFAULT_OPUS_MODEL).toBe('cursor-opus');
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('cursor-sonnet');