mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 02:11:28 +00:00
fix(cursor): authenticate local daemon health/runtime traffic (#1373)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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) || {});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
export interface CursorDaemonConfig {
|
||||
port: number;
|
||||
ghost_mode?: boolean;
|
||||
daemon_token?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user