diff --git a/apps/server/src/lib/sdk-options.ts b/apps/server/src/lib/sdk-options.ts index 327ec059..d9b78398 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'; @@ -47,6 +48,128 @@ 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) + */ + +/** + * 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 +] 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; + +/** + * 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. + * + * 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); + + // 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; +} + +/** + * 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 explicitly disabled sandbox mode + if (enableSandboxMode === false) { + return { + enabled: false, + disabledReason: 'user_setting', + }; + } + + // Check for cloud storage incompatibility (applies when enabled or undefined) + if (isCloudStoragePath(cwd)) { + return { + enabled: false, + disabledReason: 'cloud_storage', + 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.`, + }; + } + + // Sandbox is compatible and enabled (true or undefined defaults to enabled) + return { + enabled: true, + }; +} + /** * Tool presets for different use cases */ @@ -381,7 +504,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 { @@ -397,6 +520,9 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options { // Build MCP-related options const mcpOptions = buildMcpOptions(config); + // Check sandbox compatibility (auto-disables for cloud storage paths) + const sandboxCheck = checkSandboxCompatibility(config.cwd, config.enableSandboxMode); + return { ...getBaseOptions(), model: getModelForUseCase('chat', effectiveModel), @@ -406,7 +532,7 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options { ...(mcpOptions.shouldRestrictTools && { allowedTools: [...TOOL_PRESETS.chat] }), // Apply MCP bypass options if configured ...mcpOptions.bypassOptions, - ...(config.enableSandboxMode && { + ...(sandboxCheck.enabled && { sandbox: { enabled: true, autoAllowBashIfSandboxed: true, @@ -425,7 +551,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 { @@ -438,6 +564,9 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options { // Build MCP-related options const mcpOptions = buildMcpOptions(config); + // Check sandbox compatibility (auto-disables for cloud storage paths) + const sandboxCheck = checkSandboxCompatibility(config.cwd, config.enableSandboxMode); + return { ...getBaseOptions(), model: getModelForUseCase('auto', config.model), @@ -447,7 +576,7 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options { ...(mcpOptions.shouldRestrictTools && { allowedTools: [...TOOL_PRESETS.fullAccess] }), // Apply MCP bypass options if configured ...mcpOptions.bypassOptions, - ...(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..3faea516 100644 --- a/apps/server/tests/unit/lib/sdk-options.test.ts +++ b/apps/server/tests/unit/lib/sdk-options.test.ts @@ -1,15 +1,161 @@ 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', () => { + 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 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); + 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); + }); + + // 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', () => { + 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=true when enableSandboxMode is undefined for local paths', async () => { + const { checkSandboxCompatibility } = await import('@/lib/sdk-options.js'); + const result = checkSandboxCompatibility('/Users/test/project', undefined); + expect(result.enabled).toBe(true); + expect(result.disabledReason).toBeUndefined(); + }); + + it('should return enabled=false for cloud storage paths when enableSandboxMode is undefined', async () => { + const { checkSandboxCompatibility } = await import('@/lib/sdk-options.js'); + const result = checkSandboxCompatibility( + '/Users/test/Library/CloudStorage/Dropbox-Personal/project', + undefined + ); + expect(result.enabled).toBe(false); + expect(result.disabledReason).toBe('cloud_storage'); + }); }); describe('TOOL_PRESETS', () => { @@ -224,13 +370,27 @@ describe('sdk-options.ts', () => { expect(options.sandbox).toBeUndefined(); }); - it('should not set sandbox when enableSandboxMode is not provided', async () => { + it('should enable sandbox by default when enableSandboxMode is not provided', async () => { const { createChatOptions } = await import('@/lib/sdk-options.js'); const options = createChatOptions({ cwd: '/test/path', }); + expect(options.sandbox).toEqual({ + enabled: true, + autoAllowBashIfSandboxed: true, + }); + }); + + 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(); }); }); @@ -285,13 +445,48 @@ describe('sdk-options.ts', () => { expect(options.sandbox).toBeUndefined(); }); - it('should not set sandbox when enableSandboxMode is not provided', async () => { + it('should enable sandbox by default when enableSandboxMode is not provided', async () => { const { createAutoModeOptions } = await import('@/lib/sdk-options.js'); const options = createAutoModeOptions({ cwd: '/test/path', }); + expect(options.sandbox).toEqual({ + enabled: true, + autoAllowBashIfSandboxed: true, + }); + }); + + 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 cloud storage paths even when enableSandboxMode is not provided', async () => { + const { createAutoModeOptions } = await import('@/lib/sdk-options.js'); + + const options = createAutoModeOptions({ + cwd: '/Users/test/Library/CloudStorage/Dropbox-Personal/project', + }); + + 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(); }); });