diff --git a/src/cursor/cursor-daemon-auth.ts b/src/cursor/cursor-daemon-auth.ts new file mode 100644 index 00000000..23b6bd39 --- /dev/null +++ b/src/cursor/cursor-daemon-auth.ts @@ -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; +} diff --git a/src/cursor/cursor-daemon-entry.ts b/src/cursor/cursor-daemon-entry.ts index 868121da..53e05645 100644 --- a/src/cursor/cursor-daemon-entry.ts +++ b/src/cursor/cursor-daemon-entry.ts @@ -108,6 +108,23 @@ function readJsonBody(req: http.IncomingMessage): Promise { }); } +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) || {}); diff --git a/src/cursor/cursor-daemon.ts b/src/cursor/cursor-daemon.ts index 5c58b712..b88a2b64 100644 --- a/src/cursor/cursor-daemon.ts +++ b/src/cursor/cursor-daemon.ts @@ -56,7 +56,7 @@ async function resolveDaemonEntrypoint(): Promise { * 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 { +export async function isDaemonRunning(port: number, daemonToken?: string): Promise { return new Promise((resolve) => { const req = http.request( { @@ -65,6 +65,7 @@ export async function isDaemonRunning(port: number): Promise { 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 diff --git a/src/cursor/cursor-models.ts b/src/cursor/cursor-models.ts index 292ce5dc..384e50ae 100644 --- a/src/cursor/cursor-models.ts +++ b/src/cursor/cursor-models.ts @@ -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 { - if (!(await isDaemonRunning(port))) { + if (!(await isDaemonRunning(port, getCursorDaemonToken()))) { return DEFAULT_CURSOR_MODELS; } return fetchModelsFromDaemon(port); diff --git a/src/cursor/cursor-profile-executor.ts b/src/cursor/cursor-profile-executor.ts index e2b9b531..03d4d139 100644 --- a/src/cursor/cursor-profile-executor.ts +++ b/src/cursor/cursor-profile-executor.ts @@ -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 { 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(); diff --git a/src/cursor/cursor-runtime-probe.ts b/src/cursor/cursor-runtime-probe.ts index 302bbb75..65b8feb5 100644 --- a/src/cursor/cursor-runtime-probe.ts +++ b/src/cursor/cursor-runtime-probe.ts @@ -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 { if (!result.provider) { diff --git a/tests/unit/cursor/cursor-daemon.test.ts b/tests/unit/cursor/cursor-daemon.test.ts index edfac3bd..3724bc39 100644 --- a/tests/unit/cursor/cursor-daemon.test.ts +++ b/tests/unit/cursor/cursor-daemon.test.ts @@ -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((resolve) => { diff --git a/tests/unit/cursor/cursor-profile-executor.test.ts b/tests/unit/cursor/cursor-profile-executor.test.ts index 4d7d6260..1f144896 100644 --- a/tests/unit/cursor/cursor-profile-executor.test.ts +++ b/tests/unit/cursor/cursor-profile-executor.test.ts @@ -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');