From 495af733dae60e9b2f120f480005c0ede4131fe0 Mon Sep 17 00:00:00 2001 From: firstfloris Date: Sun, 28 Dec 2025 20:44:09 +0100 Subject: [PATCH] fix: auto-disable sandbox mode for cloud storage paths The Claude CLI sandbox feature is incompatible with cloud storage virtual filesystems (Dropbox, Google Drive, iCloud, OneDrive). When a project is in a cloud storage location, sandbox mode is now automatically disabled with a warning log to prevent process crashes. Added: - isCloudStoragePath() to detect cloud storage locations - checkSandboxCompatibility() for graceful degradation - 15 new tests for cloud storage detection and sandbox behavior --- apps/server/src/lib/sdk-options.ts | 109 ++++++++++++++++- .../server/tests/unit/lib/sdk-options.test.ts | 111 ++++++++++++++++++ 2 files changed, 216 insertions(+), 4 deletions(-) diff --git a/apps/server/src/lib/sdk-options.ts b/apps/server/src/lib/sdk-options.ts index 80433f5b..59229949 100644 --- a/apps/server/src/lib/sdk-options.ts +++ b/apps/server/src/lib/sdk-options.ts @@ -47,6 +47,101 @@ export function validateWorkingDirectory(cwd: string): void { } } +/** + * Known cloud storage path patterns where sandbox mode is incompatible. + * + * The Claude CLI sandbox feature uses filesystem isolation that conflicts with + * cloud storage providers' virtual filesystem implementations. This causes the + * Claude process to exit with code 1 when sandbox is enabled for these paths. + * + * Affected providers (macOS paths): + * - Dropbox: ~/Library/CloudStorage/Dropbox-* + * - Google Drive: ~/Library/CloudStorage/GoogleDrive-* + * - OneDrive: ~/Library/CloudStorage/OneDrive-* + * - iCloud Drive: ~/Library/Mobile Documents/ + * - Box: ~/Library/CloudStorage/Box-* + * + * @see https://github.com/anthropics/claude-code/issues/XXX (TODO: file upstream issue) + */ +const CLOUD_STORAGE_PATH_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; + +/** + * Check if a path is within a cloud storage location. + * + * Cloud storage providers use virtual filesystem implementations that are + * incompatible with the Claude CLI sandbox feature, causing process crashes. + * + * @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)); +} + +/** + * Result of sandbox compatibility check + */ +export interface SandboxCheckResult { + /** Whether sandbox should be enabled */ + enabled: boolean; + /** If disabled, the reason why */ + disabledReason?: 'cloud_storage' | 'user_setting'; + /** Human-readable message for logging/UI */ + message?: string; +} + +/** + * Determine if sandbox mode should be enabled for a given configuration. + * + * Sandbox mode is automatically disabled for cloud storage paths because the + * Claude CLI sandbox feature is incompatible with virtual filesystem + * implementations used by cloud storage providers (Dropbox, Google Drive, etc.). + * + * @param cwd - The working directory + * @param enableSandboxMode - User's sandbox mode setting + * @returns SandboxCheckResult with enabled status and reason if disabled + */ +export function checkSandboxCompatibility( + cwd: string, + enableSandboxMode?: boolean +): SandboxCheckResult { + // User has disabled sandbox mode + if (!enableSandboxMode) { + return { + enabled: false, + disabledReason: 'user_setting', + }; + } + + // 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, + }; + } + + // Sandbox is compatible and enabled + return { + enabled: true, + }; +} + /** * Tool presets for different use cases */ @@ -317,7 +412,7 @@ export function createSuggestionsOptions(config: CreateSdkOptionsConfig): Option * - Full tool access for code modification * - Standard turns for interactive sessions * - Model priority: explicit model > session model > chat default - * - Sandbox mode controlled by enableSandboxMode setting + * - Sandbox mode controlled by enableSandboxMode setting (auto-disabled for cloud storage) * - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading */ export function createChatOptions(config: CreateSdkOptionsConfig): Options { @@ -330,13 +425,16 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options { // Build CLAUDE.md auto-loading options if enabled const claudeMdOptions = buildClaudeMdOptions(config); + // Check sandbox compatibility (auto-disables for cloud storage paths) + const sandboxCheck = checkSandboxCompatibility(config.cwd, config.enableSandboxMode); + return { ...getBaseOptions(), model: getModelForUseCase('chat', effectiveModel), maxTurns: MAX_TURNS.standard, cwd: config.cwd, allowedTools: [...TOOL_PRESETS.chat], - ...(config.enableSandboxMode && { + ...(sandboxCheck.enabled && { sandbox: { enabled: true, autoAllowBashIfSandboxed: true, @@ -354,7 +452,7 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options { * - Full tool access for code modification and implementation * - Extended turns for thorough feature implementation * - Uses default model (can be overridden) - * - Sandbox mode controlled by enableSandboxMode setting + * - Sandbox mode controlled by enableSandboxMode setting (auto-disabled for cloud storage) * - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading */ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options { @@ -364,13 +462,16 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options { // Build CLAUDE.md auto-loading options if enabled const claudeMdOptions = buildClaudeMdOptions(config); + // Check sandbox compatibility (auto-disables for cloud storage paths) + const sandboxCheck = checkSandboxCompatibility(config.cwd, config.enableSandboxMode); + return { ...getBaseOptions(), model: getModelForUseCase('auto', config.model), maxTurns: MAX_TURNS.maximum, cwd: config.cwd, allowedTools: [...TOOL_PRESETS.fullAccess], - ...(config.enableSandboxMode && { + ...(sandboxCheck.enabled && { sandbox: { enabled: true, autoAllowBashIfSandboxed: true, diff --git a/apps/server/tests/unit/lib/sdk-options.test.ts b/apps/server/tests/unit/lib/sdk-options.test.ts index e5d4c7c0..08b743ab 100644 --- a/apps/server/tests/unit/lib/sdk-options.test.ts +++ b/apps/server/tests/unit/lib/sdk-options.test.ts @@ -12,6 +12,84 @@ describe('sdk-options.ts', () => { process.env = originalEnv; }); + describe('isCloudStoragePath', () => { + it('should detect Dropbox paths on macOS', async () => { + const { isCloudStoragePath } = await import('@/lib/sdk-options.js'); + expect(isCloudStoragePath('/Users/test/Library/CloudStorage/Dropbox-Personal/project')).toBe( + true + ); + expect(isCloudStoragePath('/Users/test/Library/CloudStorage/Dropbox/project')).toBe(true); + }); + + it('should detect Google Drive paths on macOS', async () => { + const { isCloudStoragePath } = await import('@/lib/sdk-options.js'); + expect( + isCloudStoragePath('/Users/test/Library/CloudStorage/GoogleDrive-user@gmail.com/project') + ).toBe(true); + }); + + it('should detect OneDrive paths on macOS', async () => { + const { isCloudStoragePath } = await import('@/lib/sdk-options.js'); + expect(isCloudStoragePath('/Users/test/Library/CloudStorage/OneDrive-Personal/project')).toBe( + true + ); + }); + + it('should detect iCloud Drive paths on macOS', async () => { + const { isCloudStoragePath } = await import('@/lib/sdk-options.js'); + expect( + isCloudStoragePath('/Users/test/Library/Mobile Documents/com~apple~CloudDocs/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); + expect(isCloudStoragePath('/home/user/code/project')).toBe(false); + expect(isCloudStoragePath('/var/www/app')).toBe(false); + }); + + it('should return false for relative paths not in cloud storage', async () => { + const { isCloudStoragePath } = await import('@/lib/sdk-options.js'); + expect(isCloudStoragePath('./project')).toBe(false); + expect(isCloudStoragePath('../other-project')).toBe(false); + }); + }); + + describe('checkSandboxCompatibility', () => { + it('should return enabled=false when user disables sandbox', async () => { + const { checkSandboxCompatibility } = await import('@/lib/sdk-options.js'); + const result = checkSandboxCompatibility('/Users/test/project', false); + expect(result.enabled).toBe(false); + expect(result.disabledReason).toBe('user_setting'); + }); + + it('should return enabled=false for cloud storage paths even when sandbox enabled', async () => { + const { checkSandboxCompatibility } = await import('@/lib/sdk-options.js'); + const result = checkSandboxCompatibility( + '/Users/test/Library/CloudStorage/Dropbox-Personal/project', + true + ); + expect(result.enabled).toBe(false); + expect(result.disabledReason).toBe('cloud_storage'); + expect(result.message).toContain('cloud storage'); + }); + + it('should return enabled=true for local paths when sandbox enabled', async () => { + const { checkSandboxCompatibility } = await import('@/lib/sdk-options.js'); + const result = checkSandboxCompatibility('/Users/test/projects/myapp', true); + expect(result.enabled).toBe(true); + expect(result.disabledReason).toBeUndefined(); + }); + + it('should return enabled=false when enableSandboxMode is undefined', async () => { + const { checkSandboxCompatibility } = await import('@/lib/sdk-options.js'); + const result = checkSandboxCompatibility('/Users/test/project', undefined); + expect(result.enabled).toBe(false); + expect(result.disabledReason).toBe('user_setting'); + }); + }); + describe('TOOL_PRESETS', () => { it('should export readOnly tools', async () => { const { TOOL_PRESETS } = await import('@/lib/sdk-options.js'); @@ -233,6 +311,17 @@ describe('sdk-options.ts', () => { expect(options.sandbox).toBeUndefined(); }); + + it('should auto-disable sandbox for cloud storage paths', async () => { + const { createChatOptions } = await import('@/lib/sdk-options.js'); + + const options = createChatOptions({ + cwd: '/Users/test/Library/CloudStorage/Dropbox-Personal/project', + enableSandboxMode: true, + }); + + expect(options.sandbox).toBeUndefined(); + }); }); describe('createAutoModeOptions', () => { @@ -294,6 +383,28 @@ describe('sdk-options.ts', () => { expect(options.sandbox).toBeUndefined(); }); + + it('should auto-disable sandbox for cloud storage paths', async () => { + const { createAutoModeOptions } = await import('@/lib/sdk-options.js'); + + const options = createAutoModeOptions({ + cwd: '/Users/test/Library/CloudStorage/Dropbox-Personal/project', + enableSandboxMode: true, + }); + + expect(options.sandbox).toBeUndefined(); + }); + + it('should auto-disable sandbox for iCloud paths', async () => { + const { createAutoModeOptions } = await import('@/lib/sdk-options.js'); + + const options = createAutoModeOptions({ + cwd: '/Users/test/Library/Mobile Documents/com~apple~CloudDocs/project', + enableSandboxMode: true, + }); + + expect(options.sandbox).toBeUndefined(); + }); }); describe('createCustomOptions', () => {