mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-06 08:13:49 +00:00
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:
1 parent
3f1dce30bc
commit
4283c4665c
7 files changed
+164
-52
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[] };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user