diff --git a/src/web-server/routes/cursor-routes.ts b/src/web-server/routes/cursor-routes.ts index 8e75aecf..0ba13344 100644 --- a/src/web-server/routes/cursor-routes.ts +++ b/src/web-server/routes/cursor-routes.ts @@ -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 => { router.post('/daemon/start', async (_req: Request, res: Response): Promise => { 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, diff --git a/src/web-server/routes/cursor-settings-routes.ts b/src/web-server/routes/cursor-settings-routes.ts index 2c918477..ce1d229a 100644 --- a/src/web-server/routes/cursor-settings-routes.ts +++ b/src/web-server/routes/cursor-settings-routes.ts @@ -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; diff --git a/tests/unit/web-server/cursor-routes.test.ts b/tests/unit/web-server/cursor-routes.test.ts new file mode 100644 index 00000000..a54bd2c6 --- /dev/null +++ b/tests/unit/web-server/cursor-routes.test.ts @@ -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(); + }); + }); +}); diff --git a/tests/unit/web-server/cursor-settings-routes.test.ts b/tests/unit/web-server/cursor-settings-routes.test.ts index 13a05dfa..c2e5dfac 100644 --- a/tests/unit/web-server/cursor-settings-routes.test.ts +++ b/tests/unit/web-server/cursor-settings-routes.test.ts @@ -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' } }; diff --git a/ui/src/pages/cursor.tsx b/ui/src/pages/cursor.tsx index f7dc3e0b..40d35a9f 100644 --- a/ui/src/pages/cursor.tsx +++ b/ui/src/pages/cursor.tsx @@ -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 -