diff --git a/apps/server/src/lib/sdk-options.ts b/apps/server/src/lib/sdk-options.ts index 6b907c6d..fdb8f8be 100644 --- a/apps/server/src/lib/sdk-options.ts +++ b/apps/server/src/lib/sdk-options.ts @@ -16,6 +16,7 @@ */ import type { Options } from '@anthropic-ai/claude-agent-sdk'; +import os from 'os'; import path from 'path'; import { resolveModelString } from '@automaker/model-resolver'; import { DEFAULT_MODELS, CLAUDE_MODEL_MAP, type McpServerConfig } from '@automaker/types'; @@ -63,12 +64,24 @@ export function validateWorkingDirectory(cwd: string): void { * * @see https://github.com/anthropics/claude-code/issues/XXX (TODO: file upstream issue) */ -const CLOUD_STORAGE_PATH_PATTERNS = [ + +/** + * macOS-specific cloud storage patterns that appear under ~/Library/ + * These are specific enough to use with includes() safely. + */ +const MACOS_CLOUD_STORAGE_PATTERNS = [ '/Library/CloudStorage/', // Dropbox, Google Drive, OneDrive, Box on macOS '/Library/Mobile Documents/', // iCloud Drive on macOS - '/Google Drive/', // Google Drive on some systems - '/Dropbox/', // Dropbox on Linux/alternative installs - '/OneDrive/', // OneDrive on Linux/alternative installs +] as const; + +/** + * Generic cloud storage folder names that need to be anchored to the home directory + * to avoid false positives (e.g., /home/user/my-project-about-dropbox/). + */ +const HOME_ANCHORED_CLOUD_FOLDERS = [ + 'Google Drive', // Google Drive on some systems + 'Dropbox', // Dropbox on Linux/alternative installs + 'OneDrive', // OneDrive on Linux/alternative installs ] as const; /** @@ -77,12 +90,34 @@ const CLOUD_STORAGE_PATH_PATTERNS = [ * Cloud storage providers use virtual filesystem implementations that are * incompatible with the Claude CLI sandbox feature, causing process crashes. * + * Uses two detection strategies: + * 1. macOS-specific patterns (under ~/Library/) - checked via includes() + * 2. Generic folder names - anchored to home directory to avoid false positives + * * @param cwd - The working directory path to check * @returns true if the path is in a cloud storage location */ export function isCloudStoragePath(cwd: string): boolean { const resolvedPath = path.resolve(cwd); - return CLOUD_STORAGE_PATH_PATTERNS.some((pattern) => resolvedPath.includes(pattern)); + + // Check macOS-specific patterns (these are specific enough to use includes) + if (MACOS_CLOUD_STORAGE_PATTERNS.some((pattern) => resolvedPath.includes(pattern))) { + return true; + } + + // Check home-anchored patterns to avoid false positives + // e.g., /home/user/my-project-about-dropbox/ should NOT match + const home = os.homedir(); + for (const folder of HOME_ANCHORED_CLOUD_FOLDERS) { + const cloudPath = path.join(home, folder); + // Check if resolved path starts with the cloud storage path followed by a separator + // This ensures we match ~/Dropbox/project but not ~/Dropbox-archive or ~/my-dropbox-tool + if (resolvedPath === cloudPath || resolvedPath.startsWith(cloudPath + path.sep)) { + return true; + } + } + + return false; } /** @@ -122,17 +157,10 @@ export function checkSandboxCompatibility( // Check for cloud storage incompatibility if (isCloudStoragePath(cwd)) { - const message = - `Sandbox mode auto-disabled: Project is in a cloud storage location (${cwd}). ` + - `The Claude CLI sandbox feature is incompatible with cloud storage filesystems. ` + - `To use sandbox mode, move your project to a local directory.`; - - console.warn(`[SdkOptions] ${message}`); - return { enabled: false, disabledReason: 'cloud_storage', - message, + message: `Sandbox mode auto-disabled: Project is in a cloud storage location (${cwd}). The Claude CLI sandbox feature is incompatible with cloud storage filesystems. To use sandbox mode, move your project to a local directory.`, }; } diff --git a/apps/server/tests/unit/lib/sdk-options.test.ts b/apps/server/tests/unit/lib/sdk-options.test.ts index 08b743ab..f1fcf710 100644 --- a/apps/server/tests/unit/lib/sdk-options.test.ts +++ b/apps/server/tests/unit/lib/sdk-options.test.ts @@ -1,15 +1,20 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import os from 'os'; describe('sdk-options.ts', () => { let originalEnv: NodeJS.ProcessEnv; + let homedirSpy: ReturnType; beforeEach(() => { originalEnv = { ...process.env }; vi.resetModules(); + // Spy on os.homedir and set default return value + homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue('/Users/test'); }); afterEach(() => { process.env = originalEnv; + homedirSpy.mockRestore(); }); describe('isCloudStoragePath', () => { @@ -42,6 +47,25 @@ describe('sdk-options.ts', () => { ).toBe(true); }); + it('should detect home-anchored Dropbox paths', async () => { + const { isCloudStoragePath } = await import('@/lib/sdk-options.js'); + expect(isCloudStoragePath('/Users/test/Dropbox')).toBe(true); + expect(isCloudStoragePath('/Users/test/Dropbox/project')).toBe(true); + expect(isCloudStoragePath('/Users/test/Dropbox/nested/deep/project')).toBe(true); + }); + + it('should detect home-anchored Google Drive paths', async () => { + const { isCloudStoragePath } = await import('@/lib/sdk-options.js'); + expect(isCloudStoragePath('/Users/test/Google Drive')).toBe(true); + expect(isCloudStoragePath('/Users/test/Google Drive/project')).toBe(true); + }); + + it('should detect home-anchored OneDrive paths', async () => { + const { isCloudStoragePath } = await import('@/lib/sdk-options.js'); + expect(isCloudStoragePath('/Users/test/OneDrive')).toBe(true); + expect(isCloudStoragePath('/Users/test/OneDrive/project')).toBe(true); + }); + it('should return false for local paths', async () => { const { isCloudStoragePath } = await import('@/lib/sdk-options.js'); expect(isCloudStoragePath('/Users/test/projects/myapp')).toBe(false); @@ -54,6 +78,40 @@ describe('sdk-options.ts', () => { expect(isCloudStoragePath('./project')).toBe(false); expect(isCloudStoragePath('../other-project')).toBe(false); }); + + // Tests for false positive prevention - paths that contain cloud storage names but aren't cloud storage + it('should NOT flag paths that merely contain "dropbox" in the name', async () => { + const { isCloudStoragePath } = await import('@/lib/sdk-options.js'); + // Projects with dropbox-like names + expect(isCloudStoragePath('/home/user/my-project-about-dropbox')).toBe(false); + expect(isCloudStoragePath('/Users/test/projects/dropbox-clone')).toBe(false); + expect(isCloudStoragePath('/Users/test/projects/Dropbox-backup-tool')).toBe(false); + // Dropbox folder that's NOT in the home directory + expect(isCloudStoragePath('/var/shared/Dropbox/project')).toBe(false); + }); + + it('should NOT flag paths that merely contain "Google Drive" in the name', async () => { + const { isCloudStoragePath } = await import('@/lib/sdk-options.js'); + expect(isCloudStoragePath('/Users/test/projects/google-drive-api-client')).toBe(false); + expect(isCloudStoragePath('/home/user/Google Drive API Tests')).toBe(false); + }); + + it('should NOT flag paths that merely contain "OneDrive" in the name', async () => { + const { isCloudStoragePath } = await import('@/lib/sdk-options.js'); + expect(isCloudStoragePath('/Users/test/projects/onedrive-sync-tool')).toBe(false); + expect(isCloudStoragePath('/home/user/OneDrive-migration-scripts')).toBe(false); + }); + + it('should handle different home directories correctly', async () => { + // Change the mocked home directory + homedirSpy.mockReturnValue('/home/linuxuser'); + const { isCloudStoragePath } = await import('@/lib/sdk-options.js'); + + // Should detect Dropbox under the Linux home directory + expect(isCloudStoragePath('/home/linuxuser/Dropbox/project')).toBe(true); + // Should NOT detect Dropbox under the old home directory (since home changed) + expect(isCloudStoragePath('/Users/test/Dropbox/project')).toBe(false); + }); }); describe('checkSandboxCompatibility', () => {