mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 12:09:03 +00:00
fix(cursor): authenticate local daemon health/runtime traffic (#1373)
This commit is contained in:
1 parent
6c0f7ac39c
commit
03a30119d9
10 files changed
+82
-12
No files matched your search
@@ -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[] {
|
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) || {});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
export interface CursorDaemonConfig {
|
export interface CursorDaemonConfig {
|
||||||
port: number;
|
port: number;
|
||||||
ghost_mode?: boolean;
|
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 { 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) {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
Reference in new issue
Block a user