mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-08 08:20:01 +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();
|
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
|
// Mount settings sub-routes
|
||||||
router.use('/settings', cursorSettingsRoutes);
|
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> => {
|
router.post('/daemon/start', async (_req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const cursorConfig = getCursorConfig();
|
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({
|
const result = await startDaemon({
|
||||||
port: cursorConfig.port,
|
port: cursorConfig.port,
|
||||||
ghost_mode: cursorConfig.ghost_mode,
|
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');
|
const settingsPath = path.join(getCcsDir(), 'cursor.settings.json');
|
||||||
|
|
||||||
// Check for conflict if file exists and expectedMtime provided
|
// For existing files, expectedMtime is required to prevent blind overwrite races.
|
||||||
if (fs.existsSync(settingsPath) && expectedMtime) {
|
if (fs.existsSync(settingsPath)) {
|
||||||
const stat = fs.statSync(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) {
|
if (Math.abs(stat.mtimeMs - expectedMtime) > 1000) {
|
||||||
res.status(409).json({ error: 'File modified externally', mtime: stat.mtimeMs });
|
res.status(409).json({ error: 'File modified externally', mtime: stat.mtimeMs });
|
||||||
return;
|
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);
|
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', () => {
|
it('allows write when mtime matches', () => {
|
||||||
const settingsPath = path.join(getCcsDir(), 'cursor.settings.json');
|
const settingsPath = path.join(getCcsDir(), 'cursor.settings.json');
|
||||||
const initialSettings = { env: { test: 'initial' } };
|
const initialSettings = { env: { test: 'initial' } };
|
||||||
|
|||||||
@@ -134,6 +134,8 @@ export function CursorPage() {
|
|||||||
const effectiveRawConfigText = rawConfigDirty
|
const effectiveRawConfigText = rawConfigDirty
|
||||||
? rawConfigText
|
? rawConfigText
|
||||||
: JSON.stringify(rawSettings?.settings ?? {}, null, 2);
|
: JSON.stringify(rawSettings?.settings ?? {}, null, 2);
|
||||||
|
const rawSettingsReady = Boolean(rawSettings);
|
||||||
|
const disableRawSave = isSavingRawSettings || rawSettingsLoading || !rawSettingsReady;
|
||||||
|
|
||||||
const updateConfigDraft = (updater: (draft: CursorConfigDraft) => CursorConfigDraft) => {
|
const updateConfigDraft = (updater: (draft: CursorConfigDraft) => CursorConfigDraft) => {
|
||||||
setConfigDraft((previousDraft) => {
|
setConfigDraft((previousDraft) => {
|
||||||
@@ -241,6 +243,11 @@ export function CursorPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveRawSettings = async () => {
|
const handleSaveRawSettings = async () => {
|
||||||
|
if (!rawSettingsReady) {
|
||||||
|
toast.error('Raw settings are still loading. Please wait and try again.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let parsed: unknown;
|
let parsed: unknown;
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(effectiveRawConfigText || '{}');
|
parsed = JSON.parse(effectiveRawConfigText || '{}');
|
||||||
@@ -570,7 +577,7 @@ export function CursorPage() {
|
|||||||
/>
|
/>
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSaveRawSettings} disabled={isSavingRawSettings}>
|
<Button onClick={handleSaveRawSettings} disabled={disableRawSave}>
|
||||||
{isSavingRawSettings ? (
|
{isSavingRawSettings ? (
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
Reference in New Issue
Block a user