mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 06:42:03 +00:00
feat: enhance global settings update with data loss prevention
- Added safeguards to prevent overwriting non-empty arrays with empty arrays during global settings updates, specifically for the 'projects' field. - Implemented logging for updates to assist in diagnosing accidental wipes of critical settings. - Updated tests to verify that projects are preserved during logout transitions and that theme changes are ignored if a project wipe is attempted. - Enhanced the settings synchronization logic to ensure safe handling during authentication state changes.
This commit is contained in:
@@ -11,7 +11,7 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import type { GlobalSettings } from '../../../types/settings.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { getErrorMessage, logError, logger } from '../common.js';
|
||||
|
||||
/**
|
||||
* Create handler factory for PUT /api/settings/global
|
||||
@@ -32,6 +32,18 @@ export function createUpdateGlobalHandler(settingsService: SettingsService) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Minimal debug logging to help diagnose accidental wipes.
|
||||
if ('projects' in updates || 'theme' in updates || 'localStorageMigrated' in updates) {
|
||||
const projectsLen = Array.isArray((updates as any).projects)
|
||||
? (updates as any).projects.length
|
||||
: undefined;
|
||||
logger.info(
|
||||
`Update global settings request: projects=${projectsLen ?? 'n/a'}, theme=${
|
||||
(updates as any).theme ?? 'n/a'
|
||||
}, localStorageMigrated=${(updates as any).localStorageMigrated ?? 'n/a'}`
|
||||
);
|
||||
}
|
||||
|
||||
const settings = await settingsService.updateGlobalSettings(updates);
|
||||
|
||||
res.json({
|
||||
|
||||
@@ -266,25 +266,80 @@ export class SettingsService {
|
||||
const settingsPath = getGlobalSettingsPath(this.dataDir);
|
||||
|
||||
const current = await this.getGlobalSettings();
|
||||
|
||||
// Guard against destructive "empty array/object" overwrites.
|
||||
// During auth transitions, the UI can briefly have default/empty state and accidentally
|
||||
// sync it, wiping persisted settings (especially `projects`).
|
||||
const sanitizedUpdates: Partial<GlobalSettings> = { ...updates };
|
||||
let attemptedProjectWipe = false;
|
||||
|
||||
const ignoreEmptyArrayOverwrite = <K extends keyof GlobalSettings>(key: K): void => {
|
||||
const nextVal = sanitizedUpdates[key] as unknown;
|
||||
const curVal = current[key] as unknown;
|
||||
if (
|
||||
Array.isArray(nextVal) &&
|
||||
nextVal.length === 0 &&
|
||||
Array.isArray(curVal) &&
|
||||
curVal.length > 0
|
||||
) {
|
||||
delete sanitizedUpdates[key];
|
||||
}
|
||||
};
|
||||
|
||||
const currentProjectsLen = Array.isArray(current.projects) ? current.projects.length : 0;
|
||||
if (
|
||||
Array.isArray(sanitizedUpdates.projects) &&
|
||||
sanitizedUpdates.projects.length === 0 &&
|
||||
currentProjectsLen > 0
|
||||
) {
|
||||
attemptedProjectWipe = true;
|
||||
delete sanitizedUpdates.projects;
|
||||
}
|
||||
|
||||
ignoreEmptyArrayOverwrite('trashedProjects');
|
||||
ignoreEmptyArrayOverwrite('projectHistory');
|
||||
ignoreEmptyArrayOverwrite('recentFolders');
|
||||
ignoreEmptyArrayOverwrite('aiProfiles');
|
||||
ignoreEmptyArrayOverwrite('mcpServers');
|
||||
ignoreEmptyArrayOverwrite('enabledCursorModels');
|
||||
ignoreEmptyArrayOverwrite('enabledCodexModels');
|
||||
|
||||
// Empty object overwrite guard
|
||||
if (
|
||||
sanitizedUpdates.lastSelectedSessionByProject &&
|
||||
typeof sanitizedUpdates.lastSelectedSessionByProject === 'object' &&
|
||||
!Array.isArray(sanitizedUpdates.lastSelectedSessionByProject) &&
|
||||
Object.keys(sanitizedUpdates.lastSelectedSessionByProject).length === 0 &&
|
||||
current.lastSelectedSessionByProject &&
|
||||
Object.keys(current.lastSelectedSessionByProject).length > 0
|
||||
) {
|
||||
delete sanitizedUpdates.lastSelectedSessionByProject;
|
||||
}
|
||||
|
||||
// If a request attempted to wipe projects, also ignore theme changes in that same request.
|
||||
if (attemptedProjectWipe) {
|
||||
delete sanitizedUpdates.theme;
|
||||
}
|
||||
|
||||
const updated: GlobalSettings = {
|
||||
...current,
|
||||
...updates,
|
||||
...sanitizedUpdates,
|
||||
version: SETTINGS_VERSION,
|
||||
};
|
||||
|
||||
// Deep merge keyboard shortcuts if provided
|
||||
if (updates.keyboardShortcuts) {
|
||||
if (sanitizedUpdates.keyboardShortcuts) {
|
||||
updated.keyboardShortcuts = {
|
||||
...current.keyboardShortcuts,
|
||||
...updates.keyboardShortcuts,
|
||||
...sanitizedUpdates.keyboardShortcuts,
|
||||
};
|
||||
}
|
||||
|
||||
// Deep merge phaseModels if provided
|
||||
if (updates.phaseModels) {
|
||||
if (sanitizedUpdates.phaseModels) {
|
||||
updated.phaseModels = {
|
||||
...current.phaseModels,
|
||||
...updates.phaseModels,
|
||||
...sanitizedUpdates.phaseModels,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -144,6 +144,33 @@ describe('settings-service.ts', () => {
|
||||
expect(updated.keyboardShortcuts.agent).toBe(DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts.agent);
|
||||
});
|
||||
|
||||
it('should not overwrite non-empty projects with an empty array (data loss guard)', async () => {
|
||||
const initial: GlobalSettings = {
|
||||
...DEFAULT_GLOBAL_SETTINGS,
|
||||
theme: 'solarized' as GlobalSettings['theme'],
|
||||
projects: [
|
||||
{
|
||||
id: 'proj1',
|
||||
name: 'Project 1',
|
||||
path: '/tmp/project-1',
|
||||
lastOpened: new Date().toISOString(),
|
||||
},
|
||||
] as any,
|
||||
};
|
||||
const settingsPath = path.join(testDataDir, 'settings.json');
|
||||
await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2));
|
||||
|
||||
const updated = await settingsService.updateGlobalSettings({
|
||||
projects: [],
|
||||
theme: 'light',
|
||||
} as any);
|
||||
|
||||
expect(updated.projects.length).toBe(1);
|
||||
expect((updated.projects as any)[0]?.id).toBe('proj1');
|
||||
// Theme should be preserved in the same request if it attempted to wipe projects
|
||||
expect(updated.theme).toBe('solarized');
|
||||
});
|
||||
|
||||
it('should create data directory if it does not exist', async () => {
|
||||
const newDataDir = path.join(os.tmpdir(), `new-data-dir-${Date.now()}`);
|
||||
const newService = new SettingsService(newDataDir);
|
||||
|
||||
Reference in New Issue
Block a user