fix(cursor): propagate daemon auth token (#1616)

* fix(cursor): propagate daemon auth token

* fix(cursor): align daemon auth token propagation
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-06-30 12:37:41 -04:00
1 parent 3f1dce30bc
commit 4283c4665c
7 files changed
+164 -52

No files matched your search

+2
View File
@@ -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<number> {
const result = await startDaemon({
port: cursorConfig.port,
ghost_mode: cursorConfig.ghost_mode,
daemon_token: getCursorDaemonToken(),
});
if (result.success) {
+50 -15
View File
@@ -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<unknown> {
});
}
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<T>(
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<string, unknown>;
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(
+16 -7
View File
@@ -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<string | null> {
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<CursorDaemonStatus> {
const running = await isDaemonRunning(port);
export async function getDaemonStatus(
port: number,
daemonToken = getCursorDaemonToken()
): Promise<CursorDaemonStatus> {
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
+2 -1
View File
@@ -143,6 +143,7 @@ export async function probeCursorRuntime(config: CursorConfig): Promise<CursorPr
const startResult = await startDaemon({
port: config.port,
ghost_mode: config.ghost_mode,
daemon_token: daemonToken,
});
if (!startResult.success) {
@@ -190,7 +191,7 @@ export async function probeCursorRuntime(config: CursorConfig): Promise<CursorPr
try {
const response = await fetch(`http://127.0.0.1:${config.port}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', 'x-ccs-cursor-token': daemonToken },
body: JSON.stringify({
model,
max_tokens: 8,
+2
View File
@@ -17,6 +17,7 @@ import {
} from '../../cursor';
import cursorSettingsRoutes from './cursor-settings-routes';
import { getCursorDaemonToken } from '../../cursor/cursor-daemon-auth';
import { getCursorConfig } from '../../config/config-loader-facade';
import { isDashboardWebSocketOriginAllowed } from '../middleware/auth-middleware';
@@ -233,6 +234,7 @@ router.post('/daemon/start', async (_req: Request, res: Response): Promise<void>
const result = await startDaemon({
port: cursorConfig.port,
ghost_mode: cursorConfig.ghost_mode,
daemon_token: getCursorDaemonToken(),
});
const { daemonToken: _redactedDaemonToken, ...publicResult } = result;
void _redactedDaemonToken;
@@ -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[] };
+82 -29
View File
@@ -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<void>((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<void>((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<void>((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;
}