mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 14:16:43 +00:00
fix(cursor): guard raw-settings save race and enforce daemon preconditions
This commit is contained in:
@@ -20,6 +20,44 @@ import cursorSettingsRoutes from './cursor-settings-routes';
|
||||
|
||||
const router = Router();
|
||||
|
||||
interface DaemonStartPreconditionInput {
|
||||
enabled: boolean;
|
||||
authenticated: boolean;
|
||||
tokenExpired?: boolean;
|
||||
}
|
||||
|
||||
interface DaemonStartPreconditionError {
|
||||
status: number;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export function getDaemonStartPreconditionError(
|
||||
input: DaemonStartPreconditionInput
|
||||
): DaemonStartPreconditionError | null {
|
||||
if (!input.enabled) {
|
||||
return {
|
||||
status: 400,
|
||||
error: 'Cursor integration is disabled. Enable it before starting daemon.',
|
||||
};
|
||||
}
|
||||
|
||||
if (!input.authenticated) {
|
||||
return {
|
||||
status: 401,
|
||||
error: 'Cursor authentication required. Import credentials before starting daemon.',
|
||||
};
|
||||
}
|
||||
|
||||
if (input.tokenExpired) {
|
||||
return {
|
||||
status: 401,
|
||||
error: 'Cursor credentials expired. Re-authenticate before starting daemon.',
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Mount settings sub-routes
|
||||
router.use('/settings', cursorSettingsRoutes);
|
||||
|
||||
@@ -125,6 +163,21 @@ router.get('/models', async (_req: Request, res: Response): Promise<void> => {
|
||||
router.post('/daemon/start', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const cursorConfig = getCursorConfig();
|
||||
const authStatus = checkAuthStatus();
|
||||
const preconditionError = getDaemonStartPreconditionError({
|
||||
enabled: cursorConfig.enabled,
|
||||
authenticated: authStatus.authenticated,
|
||||
tokenExpired: authStatus.expired ?? false,
|
||||
});
|
||||
|
||||
if (preconditionError) {
|
||||
res.status(preconditionError.status).json({
|
||||
success: false,
|
||||
error: preconditionError.error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await startDaemon({
|
||||
port: cursorConfig.port,
|
||||
ghost_mode: cursorConfig.ghost_mode,
|
||||
|
||||
@@ -162,9 +162,17 @@ router.put('/raw', (req: Request, res: Response): void => {
|
||||
|
||||
const settingsPath = path.join(getCcsDir(), 'cursor.settings.json');
|
||||
|
||||
// Check for conflict if file exists and expectedMtime provided
|
||||
if (fs.existsSync(settingsPath) && expectedMtime) {
|
||||
// For existing files, expectedMtime is required to prevent blind overwrite races.
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
const stat = fs.statSync(settingsPath);
|
||||
if (typeof expectedMtime !== 'number' || !Number.isFinite(expectedMtime)) {
|
||||
res.status(409).json({
|
||||
error: 'File metadata not loaded. Refresh and retry.',
|
||||
mtime: stat.mtimeMs,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (Math.abs(stat.mtimeMs - expectedMtime) > 1000) {
|
||||
res.status(409).json({ error: 'File modified externally', mtime: stat.mtimeMs });
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Cursor Routes Tests
|
||||
* Tests for daemon start precondition validation logic.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { getDaemonStartPreconditionError } from '../../../src/web-server/routes/cursor-routes';
|
||||
|
||||
describe('Cursor Routes Logic', () => {
|
||||
describe('POST /daemon/start preconditions', () => {
|
||||
it('blocks start when integration is disabled', () => {
|
||||
const result = getDaemonStartPreconditionError({
|
||||
enabled: false,
|
||||
authenticated: true,
|
||||
tokenExpired: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 400,
|
||||
error: 'Cursor integration is disabled. Enable it before starting daemon.',
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks start when not authenticated', () => {
|
||||
const result = getDaemonStartPreconditionError({
|
||||
enabled: true,
|
||||
authenticated: false,
|
||||
tokenExpired: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 401,
|
||||
error: 'Cursor authentication required. Import credentials before starting daemon.',
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks start when token is expired', () => {
|
||||
const result = getDaemonStartPreconditionError({
|
||||
enabled: true,
|
||||
authenticated: true,
|
||||
tokenExpired: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 401,
|
||||
error: 'Cursor credentials expired. Re-authenticate before starting daemon.',
|
||||
});
|
||||
});
|
||||
|
||||
it('allows start when all preconditions are met', () => {
|
||||
const result = getDaemonStartPreconditionError({
|
||||
enabled: true,
|
||||
authenticated: true,
|
||||
tokenExpired: false,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -284,6 +284,32 @@ describe('Cursor Settings Routes Logic', () => {
|
||||
expect(hasConflict).toBe(true);
|
||||
});
|
||||
|
||||
it('requires expectedMtime when file already exists', () => {
|
||||
const settingsPath = path.join(getCcsDir(), 'cursor.settings.json');
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: { test: 'existing' } }));
|
||||
|
||||
const expectedMtime = undefined;
|
||||
const shouldRequireMtime =
|
||||
fs.existsSync(settingsPath) &&
|
||||
(typeof expectedMtime !== 'number' || !Number.isFinite(expectedMtime));
|
||||
|
||||
expect(shouldRequireMtime).toBe(true);
|
||||
});
|
||||
|
||||
it('does not require expectedMtime when file does not exist', () => {
|
||||
const settingsPath = path.join(getCcsDir(), 'cursor.settings.json');
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
fs.rmSync(settingsPath, { force: true });
|
||||
}
|
||||
|
||||
const expectedMtime = undefined;
|
||||
const shouldRequireMtime =
|
||||
fs.existsSync(settingsPath) &&
|
||||
(typeof expectedMtime !== 'number' || !Number.isFinite(expectedMtime));
|
||||
|
||||
expect(shouldRequireMtime).toBe(false);
|
||||
});
|
||||
|
||||
it('allows write when mtime matches', () => {
|
||||
const settingsPath = path.join(getCcsDir(), 'cursor.settings.json');
|
||||
const initialSettings = { env: { test: 'initial' } };
|
||||
|
||||
@@ -134,6 +134,8 @@ export function CursorPage() {
|
||||
const effectiveRawConfigText = rawConfigDirty
|
||||
? rawConfigText
|
||||
: JSON.stringify(rawSettings?.settings ?? {}, null, 2);
|
||||
const rawSettingsReady = Boolean(rawSettings);
|
||||
const disableRawSave = isSavingRawSettings || rawSettingsLoading || !rawSettingsReady;
|
||||
|
||||
const updateConfigDraft = (updater: (draft: CursorConfigDraft) => CursorConfigDraft) => {
|
||||
setConfigDraft((previousDraft) => {
|
||||
@@ -241,6 +243,11 @@ export function CursorPage() {
|
||||
};
|
||||
|
||||
const handleSaveRawSettings = async () => {
|
||||
if (!rawSettingsReady) {
|
||||
toast.error('Raw settings are still loading. Please wait and try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(effectiveRawConfigText || '{}');
|
||||
@@ -570,7 +577,7 @@ export function CursorPage() {
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button onClick={handleSaveRawSettings} disabled={isSavingRawSettings}>
|
||||
<Button onClick={handleSaveRawSettings} disabled={disableRawSave}>
|
||||
{isSavingRawSettings ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user