From 4283c4665c7812a7eda343b31f6ff3359f8294b8 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Tue, 30 Jun 2026 12:37:41 -0400 Subject: [PATCH] fix(cursor): propagate daemon auth token (#1616) * fix(cursor): propagate daemon auth token * fix(cursor): align daemon auth token propagation --- src/commands/cursor-command.ts | 2 + src/cursor/cursor-daemon-entry.ts | 65 +++++++--- src/cursor/cursor-daemon.ts | 23 ++-- src/cursor/cursor-runtime-probe.ts | 3 +- src/web-server/routes/cursor-routes.ts | 2 + .../cursor-daemon-lifecycle.test.ts | 10 ++ tests/unit/cursor/cursor-daemon.test.ts | 111 +++++++++++++----- 7 files changed, 164 insertions(+), 52 deletions(-) diff --git a/src/commands/cursor-command.ts b/src/commands/cursor-command.ts index 7510f4a7..85e2488f 100644 --- a/src/commands/cursor-command.ts +++ b/src/commands/cursor-command.ts @@ -18,6 +18,7 @@ import { } from '../cursor'; import { DEFAULT_CURSOR_CONFIG } from '../config/unified-config-types'; +import { getCursorDaemonToken } from '../cursor/cursor-daemon-auth'; import { renderCursorHelp, renderCursorModels, @@ -253,6 +254,7 @@ async function handleStart(): Promise { const result = await startDaemon({ port: cursorConfig.port, ghost_mode: cursorConfig.ghost_mode, + daemon_token: getCursorDaemonToken(), }); if (result.success) { diff --git a/src/cursor/cursor-daemon-entry.ts b/src/cursor/cursor-daemon-entry.ts index a22a2a0a..db633f0f 100644 --- a/src/cursor/cursor-daemon-entry.ts +++ b/src/cursor/cursor-daemon-entry.ts @@ -22,6 +22,7 @@ import { translateAnthropicRequest } from './cursor-anthropic-translator'; import { checkAuthStatus } from './cursor-auth'; import { getModelsForDaemon, resolveCursorRequestModel } from './cursor-models'; import type { CursorTool } from './cursor-protobuf-schema'; +import { ValidationError } from '../errors/error-types'; interface DaemonRuntimeOptions { port: number; @@ -132,22 +133,46 @@ function readJsonBody(req: http.IncomingMessage): Promise { }); } +function headerMatchesToken(header: string | string[] | undefined, expectedToken: string): boolean { + if (typeof header === 'string') { + return header === expectedToken; + } + + if (Array.isArray(header)) { + return header.includes(expectedToken); + } + + return false; +} + +function authorizationMatchesToken( + header: string | string[] | undefined, + expectedToken: string +): boolean { + const values = Array.isArray(header) ? header : header ? [header] : []; + return values.some((value) => { + const trimmed = value.trim(); + if (trimmed === expectedToken) { + return true; + } + + const match = /^Bearer\s+(.+)$/i.exec(trimmed); + return match?.[1] === expectedToken; + }); +} + 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; + return ( + headerMatchesToken(req.headers['x-ccs-cursor-token'], expectedToken) || + headerMatchesToken(req.headers['anthropic-auth-token'], expectedToken) || + headerMatchesToken(req.headers['x-api-key'], expectedToken) || + authorizationMatchesToken(req.headers.authorization, expectedToken) + ); } function resolveInboundRequestId(req: http.IncomingMessage): string | undefined { @@ -180,17 +205,20 @@ function withCursorDaemonRequestContext( function normalizeMessages(raw: unknown): NormalizedOpenAIMessage[] { if (!Array.isArray(raw)) { - throw new Error('messages must be an array'); + throw new ValidationError('messages must be an array', 'messages'); } return raw.map((message, index) => { if (typeof message !== 'object' || message === null) { - throw new Error(`messages[${index}] must be an object`); + throw new ValidationError(`messages[${index}] must be an object`, `messages[${index}]`); } const m = message as Record; if (typeof m.role !== 'string' || !m.role) { - throw new Error(`messages[${index}].role must be a non-empty string`); + throw new ValidationError( + `messages[${index}].role must be a non-empty string`, + `messages[${index}].role` + ); } const content = m.content; @@ -200,7 +228,10 @@ function normalizeMessages(raw: unknown): NormalizedOpenAIMessage[] { typeof content !== 'string' && !Array.isArray(content) ) { - throw new Error(`messages[${index}].content must be string, array, or null`); + throw new ValidationError( + `messages[${index}].content must be string, array, or null`, + `messages[${index}].content` + ); } return { @@ -364,7 +395,11 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser } if (isAnthropicRoute) { - const expectedToken = (process.env.ANTHROPIC_AUTH_TOKEN || 'cursor-managed').trim(); + const expectedToken = ( + process.env.ANTHROPIC_AUTH_TOKEN || + process.env.CCS_CURSOR_DAEMON_TOKEN || + 'cursor-managed' + ).trim(); const requestToken = getAnthropicRequestToken(req.headers); if (!expectedToken || requestToken !== expectedToken) { await pipeWebResponseToNode( diff --git a/src/cursor/cursor-daemon.ts b/src/cursor/cursor-daemon.ts index 41b41f3c..0d46da81 100644 --- a/src/cursor/cursor-daemon.ts +++ b/src/cursor/cursor-daemon.ts @@ -12,6 +12,7 @@ import * as path from 'path'; import * as http from 'http'; import type { CursorDaemonConfig, CursorDaemonStatus } from './types'; import { getPidFromFile, writePidToFile, removePidFile } from './cursor-daemon-pid'; +import { getCursorDaemonToken } from './cursor-daemon-auth'; import { verifyDaemonOwnership } from './daemon-process-ownership'; import { createLogger, forwardRequestIdEnv } from '../services/logging'; export { getPidFromFile, writePidToFile, removePidFile } from './cursor-daemon-pid'; @@ -53,6 +54,15 @@ async function resolveDaemonEntrypoint(): Promise { return null; } +export function buildDaemonProcessEnv(daemonToken: string): NodeJS.ProcessEnv { + return { + ...process.env, + ...forwardRequestIdEnv(), + CCS_CURSOR_DAEMON_TOKEN: daemonToken, + ANTHROPIC_AUTH_TOKEN: daemonToken, + }; +} + /** * Check if cursor daemon is running on the specified port. * Uses 127.0.0.1 instead of localhost for more reliable local connections. @@ -108,8 +118,11 @@ export async function isDaemonRunning(port: number, daemonToken?: string): Promi /** * Get daemon status. */ -export async function getDaemonStatus(port: number): Promise { - const running = await isDaemonRunning(port); +export async function getDaemonStatus( + port: number, + daemonToken = getCursorDaemonToken() +): Promise { + const running = await isDaemonRunning(port, daemonToken); const pid = getPidFromFile(); return { @@ -224,11 +237,7 @@ export async function startDaemon( proc = spawn(process.execPath, args, { stdio: 'ignore', detached: true, - env: { - ...process.env, - ...forwardRequestIdEnv(), - CCS_CURSOR_DAEMON_TOKEN: effectiveConfig.daemon_token || '', - }, + env: buildDaemonProcessEnv(effectiveConfig.daemon_token || ''), }); // Unref so parent can exit diff --git a/src/cursor/cursor-runtime-probe.ts b/src/cursor/cursor-runtime-probe.ts index 65b8feb5..c21bddbe 100644 --- a/src/cursor/cursor-runtime-probe.ts +++ b/src/cursor/cursor-runtime-probe.ts @@ -143,6 +143,7 @@ export async function probeCursorRuntime(config: CursorConfig): Promise const result = await startDaemon({ port: cursorConfig.port, ghost_mode: cursorConfig.ghost_mode, + daemon_token: getCursorDaemonToken(), }); const { daemonToken: _redactedDaemonToken, ...publicResult } = result; void _redactedDaemonToken; diff --git a/tests/integration/cursor-daemon-lifecycle.test.ts b/tests/integration/cursor-daemon-lifecycle.test.ts index acb21499..b4b724e6 100644 --- a/tests/integration/cursor-daemon-lifecycle.test.ts +++ b/tests/integration/cursor-daemon-lifecycle.test.ts @@ -77,6 +77,16 @@ describe('cursor daemon lifecycle smoke', () => { expect(await isDaemonRunning(port, daemonToken)).toBe(true); + const standardAuthHealthResponse = await fetch(`http://127.0.0.1:${port}/health`, { + headers: { 'x-api-key': daemonToken }, + }); + expect(standardAuthHealthResponse.status).toBe(200); + + const bearerAuthHealthResponse = await fetch(`http://127.0.0.1:${port}/health`, { + headers: { Authorization: `Bearer ${daemonToken}` }, + }); + expect(bearerAuthHealthResponse.status).toBe(200); + const modelsResponse = await fetch(`http://127.0.0.1:${port}/v1/models`); expect(modelsResponse.status).toBe(200); const modelsJson = (await modelsResponse.json()) as { object?: string; data?: unknown[] }; diff --git a/tests/unit/cursor/cursor-daemon.test.ts b/tests/unit/cursor/cursor-daemon.test.ts index 3724bc39..d8fc2482 100644 --- a/tests/unit/cursor/cursor-daemon.test.ts +++ b/tests/unit/cursor/cursor-daemon.test.ts @@ -16,14 +16,13 @@ import { getDaemonStatus, stopDaemon, startDaemon, + buildDaemonProcessEnv, } from '../../../src/cursor/cursor-daemon'; import { getCcsDir } from '../../../src/utils/config-manager'; import { handleCursorCommand } from '../../../src/commands/cursor-command'; -import { - renderCursorHelp, - renderCursorStatus, -} from '../../../src/commands/cursor-command-display'; +import { renderCursorHelp, renderCursorStatus } from '../../../src/commands/cursor-command-display'; import { loadCredentials } from '../../../src/cursor/cursor-auth'; +import { getCursorDaemonToken } from '../../../src/cursor/cursor-daemon-auth'; import { DEFAULT_CURSOR_CONFIG } from '../../../src/config/unified-config-types'; // Test isolation @@ -148,6 +147,26 @@ describe('removePidFile', () => { }); }); +describe('buildDaemonProcessEnv', () => { + it('uses the daemon token for both daemon and Anthropic caller auth', () => { + const originalAnthropicToken = process.env.ANTHROPIC_AUTH_TOKEN; + process.env.ANTHROPIC_AUTH_TOKEN = 'inherited-stale-token'; + + try { + const env = buildDaemonProcessEnv('generated-daemon-token'); + + expect(env.CCS_CURSOR_DAEMON_TOKEN).toBe('generated-daemon-token'); + expect(env.ANTHROPIC_AUTH_TOKEN).toBe('generated-daemon-token'); + } finally { + if (originalAnthropicToken !== undefined) { + process.env.ANTHROPIC_AUTH_TOKEN = originalAnthropicToken; + } else { + delete process.env.ANTHROPIC_AUTH_TOKEN; + } + } + }); +}); + describe('startDaemon', () => { it('rejects invalid port (0)', async () => { const result = await startDaemon({ port: 0 }); @@ -197,7 +216,7 @@ describe('isDaemonRunning', () => { throw new Error('Unable to resolve test server port'); } - const result = await isDaemonRunning(address.port, "bad-token"); + const result = await isDaemonRunning(address.port, 'bad-token'); expect(result).toBe(false); } finally { await new Promise((resolve) => { @@ -222,6 +241,41 @@ describe('getDaemonStatus', () => { expect(status.port).toBe(19999); expect(status.pid).toBeUndefined(); }); + + it('uses the persisted daemon token when checking status', async () => { + const daemonToken = getCursorDaemonToken(); + const server = http.createServer((req, res) => { + if (req.url === '/health' && req.headers['x-ccs-cursor-token'] === daemonToken) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, service: 'cursor-daemon' })); + return; + } + + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + }); + + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => resolve()); + }); + + try { + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Unable to resolve test server port'); + } + + writePidToFile(12345); + const status = await getDaemonStatus(address.port); + expect(status.running).toBe(true); + expect(status.port).toBe(address.port); + expect(status.pid).toBe(12345); + } finally { + await new Promise((resolve) => { + server.close(() => resolve()); + }); + } + }); }); describe('stopDaemon', () => { @@ -275,17 +329,18 @@ describe('stopDaemon', () => { }); it('refuses to stop when daemon ownership cannot be verified', async () => { - const killSpy = spyOn(process, 'kill').mockImplementation( - ((pid: number, signal?: NodeJS.Signals | number) => { - if (pid === process.pid && signal === 0) { - const err = new Error('EPERM') as NodeJS.ErrnoException; - err.code = 'EPERM'; - throw err; - } + const killSpy = spyOn(process, 'kill').mockImplementation((( + pid: number, + signal?: NodeJS.Signals | number + ) => { + if (pid === process.pid && signal === 0) { + const err = new Error('EPERM') as NodeJS.ErrnoException; + err.code = 'EPERM'; + throw err; + } - return true; - }) as typeof process.kill - ); + return true; + }) as typeof process.kill); writePidToFile(process.pid); @@ -388,11 +443,13 @@ describe('renderCursorStatus', () => { true ); expect( - logs.some((line) => line.includes('Chat route: http://127.0.0.1:20129/v1/chat/completions')) - ).toBe(true); - expect( - logs.some((line) => line.includes('Anthropic base: http://127.0.0.1:20129')) + logs.some((line) => + line.includes('Chat route: http://127.0.0.1:20129/v1/chat/completions') + ) ).toBe(true); + expect(logs.some((line) => line.includes('Anthropic base: http://127.0.0.1:20129'))).toBe( + true + ); expect( logs.some((line) => line.includes(`Raw settings: ${getCcsDir()}/cursor.settings.json`)) ).toBe(true); @@ -460,9 +517,9 @@ describe('renderCursorStatus', () => { { running: true, port: 20129, pid: 1234 } ); - expect(logs.some((line) => line.includes('Raw settings: ~/.ccs/cursor.settings.json'))).toBe( - true - ); + expect( + logs.some((line) => line.includes('Raw settings: ~/.ccs/cursor.settings.json')) + ).toBe(true); } finally { if (originalCcsHomeValue !== undefined) { process.env.CCS_HOME = originalCcsHomeValue; @@ -496,15 +553,11 @@ describe('renderCursorHelp', () => { line.includes('Deprecated: `ccs cursor` now belongs to the CLIProxy Cursor provider.') ) ).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 --auth')) - ).toBe(true); - expect( - logs.some((line) => line.includes('ccs legacy cursor [claude args]')) + logs.some((line) => line.includes('probe Run a live authenticated runtime probe')) ).toBe(true); + expect(logs.some((line) => line.includes('ccs cursor --auth'))).toBe(true); + expect(logs.some((line) => line.includes('ccs legacy cursor [claude args]'))).toBe(true); } finally { console.log = originalLog; }