Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
4cd84a4
fix: add API proxy to Vite dev server for web mode CORS
DhanushSantosh Jan 17, 2026
7eae021
chore: update package-lock.json
DhanushSantosh Jan 17, 2026
4186b80
fix: use relative URLs in web mode to leverage Vite proxy
DhanushSantosh Jan 17, 2026
b8875f7
fix: improve CORS configuration to handle localhost and private IPs
DhanushSantosh Jan 17, 2026
e10cb83
debug: add CORS logging to diagnose origin rejection
DhanushSantosh Jan 17, 2026
b0b4976
fix: add localhost to CORS_ORIGIN for web mode development
DhanushSantosh Jan 17, 2026
fdad82b
fix: enable WebSocket proxying in Vite dev server
DhanushSantosh Jan 17, 2026
a7f7898
fix: persist session token to localStorage for web mode page reload s…
DhanushSantosh Jan 17, 2026
174c02c
fix: automatically remove projects with non-existent paths
DhanushSantosh Jan 17, 2026
2a8706e
fix: add session token to image URLs for web mode authentication
DhanushSantosh Jan 17, 2026
b66efae
fix: sync projects immediately instead of debouncing
DhanushSantosh Jan 17, 2026
9137f0e
fix: keep localStorage cache in sync with server settings
DhanushSantosh Jan 17, 2026
7b7ac72
fix: use shared data directory for Electron and web modes
DhanushSantosh Jan 17, 2026
484d4c6
fix: use shared data directory for Electron and web modes
DhanushSantosh Jan 18, 2026
f378122
fix: resolve data directory persistence between Electron and Web modes
DhanushSantosh Jan 18, 2026
2e57553
Merge remote-tracking branch 'upstream/v0.13.0rc' into patchcraft
DhanushSantosh Jan 18, 2026
505a2b1
docs: enhance docstrings to reach 80% coverage threshold
DhanushSantosh Jan 18, 2026
980006d
fix: use setItem helper and safer Playwright selector in tests
DhanushSantosh Jan 18, 2026
7795d81
merge: resolve conflicts with upstream/v0.13.0rc
DhanushSantosh Jan 18, 2026
f68aee6
fix: prevent response disposal race condition in E2E test
DhanushSantosh Jan 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 22 additions & 8 deletions apps/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ const PORT = parseInt(process.env.PORT || '3008', 10);
const HOST = process.env.HOST || '0.0.0.0';
const HOSTNAME = process.env.HOSTNAME || 'localhost';
const DATA_DIR = process.env.DATA_DIR || './data';
logger.info('[SERVER_STARTUP] process.env.DATA_DIR:', process.env.DATA_DIR);
logger.info('[SERVER_STARTUP] Resolved DATA_DIR:', DATA_DIR);
logger.info('[SERVER_STARTUP] process.cwd():', process.cwd());
Comment on lines +94 to +96
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

These startup logs are helpful for debugging but might be too verbose for production environments. Consider changing them to logger.debug or removing them if they are temporary.

Suggested change
logger.info('[SERVER_STARTUP] process.env.DATA_DIR:', process.env.DATA_DIR);
logger.info('[SERVER_STARTUP] Resolved DATA_DIR:', DATA_DIR);
logger.info('[SERVER_STARTUP] process.cwd():', process.cwd());
logger.debug('[SERVER_STARTUP] process.env.DATA_DIR:', process.env.DATA_DIR);
logger.debug('[SERVER_STARTUP] Resolved DATA_DIR:', DATA_DIR);
logger.debug('[SERVER_STARTUP] process.cwd():', process.cwd());

const ENABLE_REQUEST_LOGGING_DEFAULT = process.env.ENABLE_REQUEST_LOGGING !== 'false'; // Default to true

// Runtime-configurable request logging flag (can be changed via settings)
Expand Down Expand Up @@ -175,14 +178,25 @@ app.use(
return;
}

// For local development, allow localhost origins
if (
origin.startsWith('http://localhost:') ||
origin.startsWith('http://127.0.0.1:') ||
origin.startsWith('http://[::1]:')
) {
callback(null, origin);
return;
// For local development, allow all localhost/loopback origins (any port)
try {
const url = new URL(origin);
const hostname = url.hostname;

if (
hostname === 'localhost' ||
hostname === '127.0.0.1' ||
hostname === '::1' ||
hostname === '0.0.0.0' ||
hostname.startsWith('192.168.') ||
hostname.startsWith('10.') ||
hostname.startsWith('172.')
) {
callback(null, origin);
return;
}
} catch (err) {
// Ignore URL parsing errors
}
Comment on lines +181 to 200
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Restrict 172. CORS allowance to RFC1918 only.*
172.0.0.0/8 includes public ranges; this currently allows non-private origins. Limit to 172.16.0.0/12.

🔧 Proposed fix
       try {
         const url = new URL(origin);
         const hostname = url.hostname;
+        const isPrivate172 = /^172\.(1[6-9]|2\d|3[0-1])\./.test(hostname);

         if (
           hostname === 'localhost' ||
           hostname === '127.0.0.1' ||
           hostname === '::1' ||
           hostname === '0.0.0.0' ||
           hostname.startsWith('192.168.') ||
           hostname.startsWith('10.') ||
-          hostname.startsWith('172.')
+          isPrivate172
         ) {
           callback(null, origin);
           return;
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// For local development, allow all localhost/loopback origins (any port)
try {
const url = new URL(origin);
const hostname = url.hostname;
if (
hostname === 'localhost' ||
hostname === '127.0.0.1' ||
hostname === '::1' ||
hostname === '0.0.0.0' ||
hostname.startsWith('192.168.') ||
hostname.startsWith('10.') ||
hostname.startsWith('172.')
) {
callback(null, origin);
return;
}
} catch (err) {
// Ignore URL parsing errors
}
// For local development, allow all localhost/loopback origins (any port)
try {
const url = new URL(origin);
const hostname = url.hostname;
const isPrivate172 = /^172\.(1[6-9]|2\d|3[0-1])\./.test(hostname);
if (
hostname === 'localhost' ||
hostname === '127.0.0.1' ||
hostname === '::1' ||
hostname === '0.0.0.0' ||
hostname.startsWith('192.168.') ||
hostname.startsWith('10.') ||
isPrivate172
) {
callback(null, origin);
return;
}
} catch (err) {
// Ignore URL parsing errors
}
🤖 Prompt for AI Agents
In `@apps/server/src/index.ts` around lines 181 - 200, The CORS origin check in
the URL parsing block incorrectly allows all 172.* addresses; update the
condition near where hostname is derived (variable hostname inside the try block
handling origin and calling callback) to only permit RFC1918 172.16.0.0/12
addresses by parsing the second octet and ensuring it is between 16 and 31
inclusive before calling callback(null, origin); keep the existing checks for
'localhost', '127.0.0.1', '::1', '0.0.0.0', and '192.168.' and ensure URL parse
errors remain ignored.


// Reject other origins by default for security
Expand Down
26 changes: 16 additions & 10 deletions apps/server/src/routes/settings/routes/update-global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,18 +45,24 @@ export function createUpdateGlobalHandler(settingsService: SettingsService) {
}

// 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 projectsLen = Array.isArray((updates as any).projects)
? (updates as any).projects.length
: undefined;
const trashedLen = Array.isArray((updates as any).trashedProjects)
? (updates as any).trashedProjects.length
: undefined;
logger.info(
`[SERVER_SETTINGS_UPDATE] Request received: projects=${projectsLen ?? 'n/a'}, trashedProjects=${trashedLen ?? 'n/a'}, theme=${
(updates as any).theme ?? 'n/a'
}, localStorageMigrated=${(updates as any).localStorageMigrated ?? 'n/a'}`
);

logger.info('[SERVER_SETTINGS_UPDATE] Calling updateGlobalSettings...');
const settings = await settingsService.updateGlobalSettings(updates);
logger.info(
'[SERVER_SETTINGS_UPDATE] Update complete, projects count:',
settings.projects?.length ?? 0
);
Comment on lines +54 to +65
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

These informational logs are useful for debugging but could add noise to production logs. It would be better to use logger.debug for this level of detail, especially for the request received and the call to updateGlobalSettings.


// Apply server log level if it was updated
if ('serverLogLevel' in updates && updates.serverLogLevel) {
Expand Down
30 changes: 28 additions & 2 deletions apps/server/src/services/settings-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,13 +273,39 @@ export class SettingsService {
};

const currentProjectsLen = Array.isArray(current.projects) ? current.projects.length : 0;
// Check if this is a legitimate project removal (moved to trash) vs accidental wipe
const newTrashedProjectsLen = Array.isArray(sanitizedUpdates.trashedProjects)
? sanitizedUpdates.trashedProjects.length
: Array.isArray(current.trashedProjects)
? current.trashedProjects.length
: 0;

if (
Array.isArray(sanitizedUpdates.projects) &&
sanitizedUpdates.projects.length === 0 &&
currentProjectsLen > 0
) {
attemptedProjectWipe = true;
delete sanitizedUpdates.projects;
// Only treat as accidental wipe if trashedProjects is also empty
// (If projects are moved to trash, they appear in trashedProjects)
if (newTrashedProjectsLen === 0) {
logger.warn(
'[WIPE_PROTECTION] Attempted to set projects to empty array with no trash! Ignoring update.',
{
currentProjectsLen,
newProjectsLen: 0,
newTrashedProjectsLen,
currentProjects: current.projects?.map((p) => p.name),
}
);
attemptedProjectWipe = true;
delete sanitizedUpdates.projects;
} else {
logger.info('[LEGITIMATE_REMOVAL] Removing all projects to trash', {
currentProjectsLen,
newProjectsLen: 0,
movedToTrash: newTrashedProjectsLen,
});
}
}
Comment on lines +277 to 309
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The logic for newTrashedProjectsLen seems flawed. It falls back to the length of current.trashedProjects if sanitizedUpdates.trashedProjects is not provided. This could lead to incorrect behavior. For example, if an update sends projects: [] without a trashedProjects field, and there are existing trashed projects, this will be incorrectly identified as a legitimate removal instead of a potential wipe, leading to data loss.

A safer approach is to consider a removal legitimate only if the number of trashed projects increases in the same update.

    const currentTrashedProjectsLen = current.trashedProjects?.length ?? 0;

    if (
      Array.isArray(sanitizedUpdates.projects) &&
      sanitizedUpdates.projects.length === 0 &&
      currentProjectsLen > 0
    ) {
      // A legitimate removal of all projects should be accompanied by an increase in trashed projects.
      // If `trashedProjects` is not in the update or is not increasing, it's likely an accidental wipe.
      if (
        !Array.isArray(sanitizedUpdates.trashedProjects) ||
        sanitizedUpdates.trashedProjects.length <= currentTrashedProjectsLen
      ) {
        logger.warn(
          '[WIPE_PROTECTION] Attempted to set projects to empty array without moving items to trash! Ignoring update.',
          {
            currentProjectsLen,
            newProjectsLen: 0,
            currentTrashedProjectsLen,
            newTrashedProjectsLen: sanitizedUpdates.trashedProjects?.length ?? 'n/a',
            currentProjects: current.projects?.map((p) => p.name),
          }
        );
        attemptedProjectWipe = true;
        delete sanitizedUpdates.projects;
      } else {
        logger.info('[LEGITIMATE_REMOVAL] Removing all projects to trash', {
          currentProjectsLen,
          newProjectsLen: 0,
          movedToTrash: sanitizedUpdates.trashedProjects.length - currentTrashedProjectsLen,
        });
      }
    }

Comment on lines 275 to 309
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Wipe protection can still allow accidental wipes when trashedProjects is omitted.
If current.trashedProjects is non‑empty and the UI sends projects: [] without trashedProjects, the fallback makes it look “legitimate” and allows the wipe. Require an explicit trashedProjects update to treat as a removal.

🔧 Proposed fix
-    const newTrashedProjectsLen = Array.isArray(sanitizedUpdates.trashedProjects)
-      ? sanitizedUpdates.trashedProjects.length
-      : Array.isArray(current.trashedProjects)
-        ? current.trashedProjects.length
-        : 0;
+    const hasTrashedProjectsUpdate = Array.isArray(sanitizedUpdates.trashedProjects);
+    const newTrashedProjectsLen = hasTrashedProjectsUpdate
+      ? sanitizedUpdates.trashedProjects.length
+      : 0;

     if (
       Array.isArray(sanitizedUpdates.projects) &&
       sanitizedUpdates.projects.length === 0 &&
       currentProjectsLen > 0
     ) {
       // Only treat as accidental wipe if trashedProjects is also empty
       // (If projects are moved to trash, they appear in trashedProjects)
-      if (newTrashedProjectsLen === 0) {
+      if (!hasTrashedProjectsUpdate || newTrashedProjectsLen === 0) {
         logger.warn(
           '[WIPE_PROTECTION] Attempted to set projects to empty array with no trash! Ignoring update.',
           {
             currentProjectsLen,
             newProjectsLen: 0,
🤖 Prompt for AI Agents
In `@apps/server/src/services/settings-service.ts` around lines 275 - 309, The
wipe-protection currently treats an omitted sanitizedUpdates.trashedProjects as
legitimate removal by falling back to current.trashedProjects; change the logic
so only an explicit sanitizedUpdates.trashedProjects array counts as
moved-to-trash. In practice, compute newTrashedProjectsLen using only
Array.isArray(sanitizedUpdates.trashedProjects) ?
sanitizedUpdates.trashedProjects.length : 0 (do not fall back to
current.trashedProjects), and keep the rest of the check that if
sanitizedUpdates.projects is an empty array and current.projects had items and
newTrashedProjectsLen === 0 then log the wipe, set attemptedProjectWipe = true
and delete sanitizedUpdates.projects. This ensures that omission of
trashedProjects is treated as zero rather than inheriting current state.


ignoreEmptyArrayOverwrite('trashedProjects');
Expand Down
23 changes: 22 additions & 1 deletion apps/ui/src/components/views/dashboard-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,19 @@ export function DashboardView() {
const initResult = await initializeProject(path);

if (!initResult.success) {
// If the project directory doesn't exist, automatically remove it from the project list
if (initResult.error?.includes('does not exist')) {
const projectToRemove = projects.find((p) => p.path === path);
if (projectToRemove) {
logger.warn(`[Dashboard] Removing project with non-existent path: ${path}`);
moveProjectToTrash(projectToRemove.id);
toast.error('Project directory not found', {
description: `Removed ${name} from your projects list since the directory no longer exists.`,
});
return;
}
}

toast.error('Failed to initialize project', {
description: initResult.error || 'Unknown error occurred',
});
Expand Down Expand Up @@ -151,7 +164,15 @@ export function DashboardView() {
setIsOpening(false);
}
},
[trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject, navigate]
[
projects,
trashedProjects,
currentProject,
globalTheme,
upsertAndSetCurrentProject,
navigate,
moveProjectToTrash,
]
);

const handleOpenProject = useCallback(async () => {
Expand Down
76 changes: 69 additions & 7 deletions apps/ui/src/hooks/use-settings-migration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,9 +111,34 @@ export function resetMigrationState(): void {

/**
* Parse localStorage data into settings object
*
* Checks for settings in multiple locations:
* 1. automaker-settings-cache: Fresh server settings cached from last fetch
* 2. automaker-storage: Zustand-persisted app store state (legacy)
* 3. automaker-setup: Setup wizard state (legacy)
* 4. Standalone keys: worktree-panel-collapsed, file-browser-recent-folders, etc.
*
* @returns Merged settings object or null if no settings found
*/
export function parseLocalStorageSettings(): Partial<GlobalSettings> | null {
try {
// First, check for fresh server settings cache (updated whenever server settings are fetched)
// This prevents stale data when switching between modes
const settingsCache = getItem('automaker-settings-cache');
if (settingsCache) {
try {
const cached = JSON.parse(settingsCache) as GlobalSettings;
const cacheProjectCount = cached?.projects?.length ?? 0;
logger.info(`[CACHE_LOADED] projects=${cacheProjectCount}, theme=${cached?.theme}`);
return cached;
} catch (e) {
logger.warn('Failed to parse settings cache, falling back to old storage');
}
} else {
logger.info('[CACHE_EMPTY] No settings cache found in localStorage');
}

// Fall back to old Zustand persisted storage
const automakerStorage = getItem('automaker-storage');
if (!automakerStorage) {
return null;
Expand Down Expand Up @@ -186,7 +211,14 @@ export function parseLocalStorageSettings(): Partial<GlobalSettings> | null {

/**
* Check if localStorage has more complete data than server
* Returns true if localStorage has projects but server doesn't
*
* Compares the completeness of data to determine if a migration is needed.
* Returns true if localStorage has projects but server doesn't, indicating
* the localStorage data should be merged with server settings.
*
* @param localSettings Settings loaded from localStorage
* @param serverSettings Settings loaded from server
* @returns true if localStorage has more data that should be preserved
*/
export function localStorageHasMoreData(
localSettings: Partial<GlobalSettings> | null,
Expand All @@ -209,7 +241,15 @@ export function localStorageHasMoreData(

/**
* Merge localStorage settings with server settings
* Prefers server data, but uses localStorage for missing arrays/objects
*
* Intelligently combines settings from both sources:
* - Prefers server data as the base
* - Uses localStorage values when server has empty arrays/objects
* - Specific handling for: projects, trashedProjects, mcpServers, recentFolders, etc.
*
* @param serverSettings Settings from server API (base)
* @param localSettings Settings from localStorage (fallback)
* @returns Merged GlobalSettings object ready to hydrate the store
*/
export function mergeSettings(
serverSettings: GlobalSettings,
Expand Down Expand Up @@ -291,20 +331,33 @@ export function mergeSettings(
* This is the core migration logic extracted for use outside of React hooks.
* Call this from __root.tsx during app initialization.
*
* @param serverSettings - Settings fetched from the server API
* @returns Promise resolving to the final settings to use (merged if migration needed)
* Flow:
* 1. If server has localStorageMigrated flag, skip migration (already done)
* 2. Check if localStorage has more data than server
* 3. If yes, merge them and sync merged state back to server
* 4. Set localStorageMigrated flag to prevent re-migration
*
* @param serverSettings Settings fetched from the server API
* @returns Promise resolving to {settings, migrated} - final settings and whether migration occurred
*/
export async function performSettingsMigration(
serverSettings: GlobalSettings
): Promise<{ settings: GlobalSettings; migrated: boolean }> {
// Get localStorage data
const localSettings = parseLocalStorageSettings();
logger.info(`localStorage has ${localSettings?.projects?.length ?? 0} projects`);
logger.info(`Server has ${serverSettings.projects?.length ?? 0} projects`);
const localProjects = localSettings?.projects?.length ?? 0;
const serverProjects = serverSettings.projects?.length ?? 0;

logger.info('[MIGRATION_CHECK]', {
localStorageProjects: localProjects,
serverProjects: serverProjects,
localStorageMigrated: serverSettings.localStorageMigrated,
dataSourceMismatch: localProjects !== serverProjects,
});

// Check if migration has already been completed
if (serverSettings.localStorageMigrated) {
logger.info('localStorage migration already completed, using server settings only');
logger.info('[MIGRATION_SKIP] Using server settings only (migration already completed)');
return { settings: serverSettings, migrated: false };
}

Expand Down Expand Up @@ -412,6 +465,15 @@ export function useSettingsMigration(): MigrationState {
if (global.success && global.settings) {
serverSettings = global.settings as unknown as GlobalSettings;
logger.info(`Server has ${serverSettings.projects?.length ?? 0} projects`);

// Update localStorage with fresh server data to keep cache in sync
// This prevents stale localStorage data from being used when switching between modes
try {
setItem('automaker-settings-cache', JSON.stringify(serverSettings));
logger.debug('Updated localStorage with fresh server settings');
} catch (storageError) {
logger.warn('Failed to update localStorage cache:', storageError);
}
}
} catch (error) {
logger.error('Failed to fetch server settings:', error);
Expand Down
Loading