diff --git a/apps/server/src/lib/sdk-options.ts b/apps/server/src/lib/sdk-options.ts index 426cf73d..944b4092 100644 --- a/apps/server/src/lib/sdk-options.ts +++ b/apps/server/src/lib/sdk-options.ts @@ -16,7 +16,6 @@ */ import type { Options } from '@anthropic-ai/claude-agent-sdk'; -import os from 'os'; import path from 'path'; import { resolveModelString } from '@automaker/model-resolver'; import { createLogger } from '@automaker/utils'; @@ -57,139 +56,6 @@ 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-* - * - * Note: This is a known limitation when using cloud storage paths. - */ - -/** - * 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); - // Normalize to forward slashes for consistent pattern matching across platforms - let normalizedPath = resolvedPath.split(path.sep).join('/'); - // Remove Windows drive letter if present (e.g., "C:/Users" -> "/Users") - // This ensures Unix paths in tests work the same on Windows - normalizedPath = normalizedPath.replace(/^[A-Za-z]:/, ''); - - // Check macOS-specific patterns (these are specific enough to use includes) - if (MACOS_CLOUD_STORAGE_PATTERNS.some((pattern) => normalizedPath.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); - let normalizedCloudPath = cloudPath.split(path.sep).join('/'); - // Remove Windows drive letter if present - normalizedCloudPath = normalizedCloudPath.replace(/^[A-Za-z]:/, ''); - // 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 ( - normalizedPath === normalizedCloudPath || - normalizedPath.startsWith(normalizedCloudPath + '/') - ) { - 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 */ @@ -272,55 +138,31 @@ export function getModelForUseCase( /** * Base options that apply to all SDK calls + * AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation */ function getBaseOptions(): Partial { return { - permissionMode: 'acceptEdits', + permissionMode: 'bypassPermissions', + allowDangerouslySkipPermissions: true, }; } /** - * MCP permission options result + * MCP options result */ -interface McpPermissionOptions { - /** Whether tools should be restricted to a preset */ - shouldRestrictTools: boolean; - /** Options to spread when MCP bypass is enabled */ - bypassOptions: Partial; +interface McpOptions { /** Options to spread for MCP servers */ mcpServerOptions: Partial; } /** * Build MCP-related options based on configuration. - * Centralizes the logic for determining permission modes and tool restrictions - * when MCP servers are configured. * * @param config - The SDK options config - * @returns Object with MCP permission settings to spread into final options + * @returns Object with MCP server settings to spread into final options */ -function buildMcpOptions(config: CreateSdkOptionsConfig): McpPermissionOptions { - const hasMcpServers = config.mcpServers && Object.keys(config.mcpServers).length > 0; - // Default to true for autonomous workflow. Security is enforced when adding servers - // via the security warning dialog that explains the risks. - const mcpAutoApprove = config.mcpAutoApproveTools ?? true; - const mcpUnrestricted = config.mcpUnrestrictedTools ?? true; - - // Determine if we should bypass permissions based on settings - const shouldBypassPermissions = hasMcpServers && mcpAutoApprove; - // Determine if we should restrict tools (only when no MCP or unrestricted is disabled) - const shouldRestrictTools = !hasMcpServers || !mcpUnrestricted; - +function buildMcpOptions(config: CreateSdkOptionsConfig): McpOptions { return { - shouldRestrictTools, - // Only include bypass options when MCP is configured and auto-approve is enabled - bypassOptions: shouldBypassPermissions - ? { - permissionMode: 'bypassPermissions' as const, - // Required flag when using bypassPermissions mode - allowDangerouslySkipPermissions: true, - } - : {}, // Include MCP servers if configured mcpServerOptions: config.mcpServers ? { mcpServers: config.mcpServers } : {}, }; @@ -422,18 +264,9 @@ export interface CreateSdkOptionsConfig { /** Enable auto-loading of CLAUDE.md files via SDK's settingSources */ autoLoadClaudeMd?: boolean; - /** Enable sandbox mode for bash command isolation */ - enableSandboxMode?: boolean; - /** MCP servers to make available to the agent */ mcpServers?: Record; - /** Auto-approve MCP tool calls without permission prompts */ - mcpAutoApproveTools?: boolean; - - /** Allow unrestricted tools when MCP servers are enabled */ - mcpUnrestrictedTools?: boolean; - /** Extended thinking level for Claude models */ thinkingLevel?: ThinkingLevel; } @@ -554,7 +387,6 @@ 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 (auto-disabled for cloud storage) * - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading */ export function createChatOptions(config: CreateSdkOptionsConfig): Options { @@ -573,24 +405,12 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options { // Build thinking options const thinkingOptions = buildThinkingOptions(config.thinkingLevel); - // 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, - // Only restrict tools if no MCP servers configured or unrestricted is disabled - ...(mcpOptions.shouldRestrictTools && { allowedTools: [...TOOL_PRESETS.chat] }), - // Apply MCP bypass options if configured - ...mcpOptions.bypassOptions, - ...(sandboxCheck.enabled && { - sandbox: { - enabled: true, - autoAllowBashIfSandboxed: true, - }, - }), + allowedTools: [...TOOL_PRESETS.chat], ...claudeMdOptions, ...thinkingOptions, ...(config.abortController && { abortController: config.abortController }), @@ -605,7 +425,6 @@ 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 (auto-disabled for cloud storage) * - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading */ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options { @@ -621,24 +440,12 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options { // Build thinking options const thinkingOptions = buildThinkingOptions(config.thinkingLevel); - // 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, - // Only restrict tools if no MCP servers configured or unrestricted is disabled - ...(mcpOptions.shouldRestrictTools && { allowedTools: [...TOOL_PRESETS.fullAccess] }), - // Apply MCP bypass options if configured - ...mcpOptions.bypassOptions, - ...(sandboxCheck.enabled && { - sandbox: { - enabled: true, - autoAllowBashIfSandboxed: true, - }, - }), + allowedTools: [...TOOL_PRESETS.fullAccess], ...claudeMdOptions, ...thinkingOptions, ...(config.abortController && { abortController: config.abortController }), @@ -656,7 +463,6 @@ export function createCustomOptions( config: CreateSdkOptionsConfig & { maxTurns?: number; allowedTools?: readonly string[]; - sandbox?: { enabled: boolean; autoAllowBashIfSandboxed?: boolean }; } ): Options { // Validate working directory before creating options @@ -671,22 +477,17 @@ export function createCustomOptions( // Build thinking options const thinkingOptions = buildThinkingOptions(config.thinkingLevel); - // For custom options: use explicit allowedTools if provided, otherwise use preset based on MCP settings + // For custom options: use explicit allowedTools if provided, otherwise default to readOnly const effectiveAllowedTools = config.allowedTools ? [...config.allowedTools] - : mcpOptions.shouldRestrictTools - ? [...TOOL_PRESETS.readOnly] - : undefined; + : [...TOOL_PRESETS.readOnly]; return { ...getBaseOptions(), model: getModelForUseCase('default', config.model), maxTurns: config.maxTurns ?? MAX_TURNS.maximum, cwd: config.cwd, - ...(effectiveAllowedTools && { allowedTools: effectiveAllowedTools }), - ...(config.sandbox && { sandbox: config.sandbox }), - // Apply MCP bypass options if configured - ...mcpOptions.bypassOptions, + allowedTools: effectiveAllowedTools, ...claudeMdOptions, ...thinkingOptions, ...(config.abortController && { abortController: config.abortController }), diff --git a/apps/server/src/lib/settings-helpers.ts b/apps/server/src/lib/settings-helpers.ts index 9a322994..a56efbc6 100644 --- a/apps/server/src/lib/settings-helpers.ts +++ b/apps/server/src/lib/settings-helpers.ts @@ -55,34 +55,6 @@ export async function getAutoLoadClaudeMdSetting( } } -/** - * Get the enableSandboxMode setting from global settings. - * Returns false if settings service is not available. - * - * @param settingsService - Optional settings service instance - * @param logPrefix - Prefix for log messages (e.g., '[AgentService]') - * @returns Promise resolving to the enableSandboxMode setting value - */ -export async function getEnableSandboxModeSetting( - settingsService?: SettingsService | null, - logPrefix = '[SettingsHelper]' -): Promise { - if (!settingsService) { - logger.info(`${logPrefix} SettingsService not available, sandbox mode disabled`); - return false; - } - - try { - const globalSettings = await settingsService.getGlobalSettings(); - const result = globalSettings.enableSandboxMode ?? false; - logger.info(`${logPrefix} enableSandboxMode from global settings: ${result}`); - return result; - } catch (error) { - logger.error(`${logPrefix} Failed to load enableSandboxMode setting:`, error); - throw error; - } -} - /** * Filters out CLAUDE.md from context files when autoLoadClaudeMd is enabled * and rebuilds the formatted prompt without it. diff --git a/apps/server/src/providers/claude-provider.ts b/apps/server/src/providers/claude-provider.ts index 50e378be..92b0fdf7 100644 --- a/apps/server/src/providers/claude-provider.ts +++ b/apps/server/src/providers/claude-provider.ts @@ -70,14 +70,6 @@ export class ClaudeProvider extends BaseProvider { const maxThinkingTokens = getThinkingTokenBudget(thinkingLevel); // Build Claude SDK options - // AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation - const hasMcpServers = options.mcpServers && Object.keys(options.mcpServers).length > 0; - const defaultTools = ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch']; - - // AUTONOMOUS MODE: Always bypass permissions and allow unrestricted tools - // Only restrict tools when no MCP servers are configured - const shouldRestrictTools = !hasMcpServers; - const sdkOptions: Options = { model, systemPrompt, @@ -85,10 +77,9 @@ export class ClaudeProvider extends BaseProvider { cwd, // Pass only explicitly allowed environment variables to SDK env: buildEnv(), - // Only restrict tools if explicitly set OR (no MCP / unrestricted disabled) - ...(allowedTools && shouldRestrictTools && { allowedTools }), - ...(!allowedTools && shouldRestrictTools && { allowedTools: defaultTools }), - // AUTONOMOUS MODE: Always bypass permissions and allow dangerous operations + // Pass through allowedTools if provided by caller (decided by sdk-options.ts) + ...(allowedTools && { allowedTools }), + // AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation permissionMode: 'bypassPermissions', allowDangerouslySkipPermissions: true, abortController, @@ -98,8 +89,6 @@ export class ClaudeProvider extends BaseProvider { : {}), // Forward settingSources for CLAUDE.md file loading ...(options.settingSources && { settingSources: options.settingSources }), - // Forward sandbox configuration - ...(options.sandbox && { sandbox: options.sandbox }), // Forward MCP servers configuration ...(options.mcpServers && { mcpServers: options.mcpServers }), // Extended thinking configuration diff --git a/apps/server/src/routes/auto-mode/routes/follow-up-feature.ts b/apps/server/src/routes/auto-mode/routes/follow-up-feature.ts index 1ed14c39..bd9c480d 100644 --- a/apps/server/src/routes/auto-mode/routes/follow-up-feature.ts +++ b/apps/server/src/routes/auto-mode/routes/follow-up-feature.ts @@ -31,7 +31,9 @@ export function createFollowUpFeatureHandler(autoModeService: AutoModeService) { // Start follow-up in background // followUpFeature derives workDir from feature.branchName autoModeService - .followUpFeature(projectPath, featureId, prompt, imagePaths, useWorktrees ?? true) + // Default to false to match run-feature/resume-feature behavior. + // Worktrees should only be used when explicitly enabled by the user. + .followUpFeature(projectPath, featureId, prompt, imagePaths, useWorktrees ?? false) .catch((error) => { logger.error(`[AutoMode] Follow up feature ${featureId} error:`, error); }) diff --git a/apps/server/src/routes/context/routes/describe-file.ts b/apps/server/src/routes/context/routes/describe-file.ts index 8ecb60fd..60c115bb 100644 --- a/apps/server/src/routes/context/routes/describe-file.ts +++ b/apps/server/src/routes/context/routes/describe-file.ts @@ -232,7 +232,6 @@ File: ${fileName}${truncated ? ' (truncated)' : ''}`; maxTurns: 1, allowedTools: [], autoLoadClaudeMd, - sandbox: { enabled: true, autoAllowBashIfSandboxed: true }, thinkingLevel, // Pass thinking level for extended thinking }); diff --git a/apps/server/src/routes/context/routes/describe-image.ts b/apps/server/src/routes/context/routes/describe-image.ts index 4b4c281d..bd288cc0 100644 --- a/apps/server/src/routes/context/routes/describe-image.ts +++ b/apps/server/src/routes/context/routes/describe-image.ts @@ -394,14 +394,13 @@ export function createDescribeImageHandler( maxTurns: 1, allowedTools: [], autoLoadClaudeMd, - sandbox: { enabled: true, autoAllowBashIfSandboxed: true }, thinkingLevel, // Pass thinking level for extended thinking }); logger.info( `[${requestId}] SDK options model=${sdkOptions.model} maxTurns=${sdkOptions.maxTurns} allowedTools=${JSON.stringify( sdkOptions.allowedTools - )} sandbox=${JSON.stringify(sdkOptions.sandbox)}` + )}` ); const promptGenerator = (async function* () { diff --git a/apps/server/src/routes/worktree/routes/diffs.ts b/apps/server/src/routes/worktree/routes/diffs.ts index 801dd514..75f43d7f 100644 --- a/apps/server/src/routes/worktree/routes/diffs.ts +++ b/apps/server/src/routes/worktree/routes/diffs.ts @@ -11,9 +11,10 @@ import { getGitRepositoryDiffs } from '../../common.js'; export function createDiffsHandler() { return async (req: Request, res: Response): Promise => { try { - const { projectPath, featureId } = req.body as { + const { projectPath, featureId, useWorktrees } = req.body as { projectPath: string; featureId: string; + useWorktrees?: boolean; }; if (!projectPath || !featureId) { @@ -24,6 +25,19 @@ export function createDiffsHandler() { return; } + // If worktrees aren't enabled, don't probe .worktrees at all. + // This avoids noisy logs that make it look like features are "running in worktrees". + if (useWorktrees === false) { + const result = await getGitRepositoryDiffs(projectPath); + res.json({ + success: true, + diff: result.diff, + files: result.files, + hasChanges: result.hasChanges, + }); + return; + } + // Git worktrees are stored in project directory const worktreePath = path.join(projectPath, '.worktrees', featureId); @@ -41,7 +55,11 @@ export function createDiffsHandler() { }); } catch (innerError) { // Worktree doesn't exist - fallback to main project path - logError(innerError, 'Worktree access failed, falling back to main project'); + const code = (innerError as NodeJS.ErrnoException | undefined)?.code; + // ENOENT is expected when a feature has no worktree; don't log as an error. + if (code && code !== 'ENOENT') { + logError(innerError, 'Worktree access failed, falling back to main project'); + } try { const result = await getGitRepositoryDiffs(projectPath); diff --git a/apps/server/src/routes/worktree/routes/file-diff.ts b/apps/server/src/routes/worktree/routes/file-diff.ts index 82ed79bd..4d29eb26 100644 --- a/apps/server/src/routes/worktree/routes/file-diff.ts +++ b/apps/server/src/routes/worktree/routes/file-diff.ts @@ -15,10 +15,11 @@ const execAsync = promisify(exec); export function createFileDiffHandler() { return async (req: Request, res: Response): Promise => { try { - const { projectPath, featureId, filePath } = req.body as { + const { projectPath, featureId, filePath, useWorktrees } = req.body as { projectPath: string; featureId: string; filePath: string; + useWorktrees?: boolean; }; if (!projectPath || !featureId || !filePath) { @@ -29,6 +30,12 @@ export function createFileDiffHandler() { return; } + // If worktrees aren't enabled, don't probe .worktrees at all. + if (useWorktrees === false) { + res.json({ success: true, diff: '', filePath }); + return; + } + // Git worktrees are stored in project directory const worktreePath = path.join(projectPath, '.worktrees', featureId); @@ -57,7 +64,11 @@ export function createFileDiffHandler() { res.json({ success: true, diff, filePath }); } catch (innerError) { - logError(innerError, 'Worktree file diff failed'); + const code = (innerError as NodeJS.ErrnoException | undefined)?.code; + // ENOENT is expected when a feature has no worktree; don't log as an error. + if (code && code !== 'ENOENT') { + logError(innerError, 'Worktree file diff failed'); + } res.json({ success: true, diff: '', filePath }); } } catch (error) { diff --git a/apps/server/src/services/agent-service.ts b/apps/server/src/services/agent-service.ts index 7736fd6a..19df20c6 100644 --- a/apps/server/src/services/agent-service.ts +++ b/apps/server/src/services/agent-service.ts @@ -20,7 +20,6 @@ import { PathNotAllowedError } from '@automaker/platform'; import type { SettingsService } from './settings-service.js'; import { getAutoLoadClaudeMdSetting, - getEnableSandboxModeSetting, filterClaudeMdFromContext, getMCPServersFromSettings, getPromptCustomization, @@ -232,12 +231,6 @@ export class AgentService { '[AgentService]' ); - // Load enableSandboxMode setting (global setting only) - const enableSandboxMode = await getEnableSandboxModeSetting( - this.settingsService, - '[AgentService]' - ); - // Load MCP servers from settings (global setting only) const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AgentService]'); @@ -267,7 +260,6 @@ export class AgentService { systemPrompt: combinedSystemPrompt, abortController: session.abortController!, autoLoadClaudeMd, - enableSandboxMode, thinkingLevel: effectiveThinkingLevel, // Pass thinking level for Claude models mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, }); @@ -291,7 +283,6 @@ export class AgentService { abortController: session.abortController!, conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined, settingSources: sdkOptions.settingSources, - sandbox: sdkOptions.sandbox, // Pass sandbox configuration sdkSessionId: session.sdkSessionId, // Pass SDK session ID for resuming mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration }; diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 078512a3..df3ad7f7 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -47,7 +47,6 @@ import type { SettingsService } from './settings-service.js'; import { pipelineService, PipelineService } from './pipeline-service.js'; import { getAutoLoadClaudeMdSetting, - getEnableSandboxModeSetting, filterClaudeMdFromContext, getMCPServersFromSettings, getPromptCustomization, @@ -1314,7 +1313,6 @@ Format your response as a structured markdown document.`; allowedTools: sdkOptions.allowedTools as string[], abortController, settingSources: sdkOptions.settingSources, - sandbox: sdkOptions.sandbox, // Pass sandbox configuration thinkingLevel: analysisThinkingLevel, // Pass thinking level }; @@ -1784,9 +1782,13 @@ Format your response as a structured markdown document.`; // Apply dependency-aware ordering const { orderedFeatures } = resolveDependencies(pendingFeatures); + // Get skipVerificationInAutoMode setting + const settings = await this.settingsService?.getGlobalSettings(); + const skipVerification = settings?.skipVerificationInAutoMode ?? false; + // Filter to only features with satisfied dependencies const readyFeatures = orderedFeatures.filter((feature: Feature) => - areDependenciesSatisfied(feature, allFeatures) + areDependenciesSatisfied(feature, allFeatures, { skipVerification }) ); return readyFeatures; @@ -2062,9 +2064,6 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. ? options.autoLoadClaudeMd : await getAutoLoadClaudeMdSetting(finalProjectPath, this.settingsService, '[AutoMode]'); - // Load enableSandboxMode setting (global setting only) - const enableSandboxMode = await getEnableSandboxModeSetting(this.settingsService, '[AutoMode]'); - // Load MCP servers from settings (global setting only) const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AutoMode]'); @@ -2076,7 +2075,6 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. model: model, abortController, autoLoadClaudeMd, - enableSandboxMode, mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, thinkingLevel: options?.thinkingLevel, }); @@ -2119,7 +2117,6 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. abortController, systemPrompt: sdkOptions.systemPrompt, settingSources: sdkOptions.settingSources, - sandbox: sdkOptions.sandbox, // Pass sandbox configuration mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration thinkingLevel: options?.thinkingLevel, // Pass thinking level for extended thinking }; @@ -2202,9 +2199,23 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. }, WRITE_DEBOUNCE_MS); }; + // Heartbeat logging so "silent" model calls are visible. + // Some runs can take a while before the first streamed message arrives. + const streamStartTime = Date.now(); + let receivedAnyStreamMessage = false; + const STREAM_HEARTBEAT_MS = 15_000; + const streamHeartbeat = setInterval(() => { + if (receivedAnyStreamMessage) return; + const elapsedSeconds = Math.round((Date.now() - streamStartTime) / 1000); + logger.info( + `Waiting for first model response for feature ${featureId} (${elapsedSeconds}s elapsed)...` + ); + }, STREAM_HEARTBEAT_MS); + // Wrap stream processing in try/finally to ensure timeout cleanup on any error/abort try { streamLoop: for await (const msg of stream) { + receivedAnyStreamMessage = true; // Log raw stream event for debugging appendRawEvent(msg); @@ -2721,6 +2732,7 @@ Implement all the changes described in the plan above.`; } } } finally { + clearInterval(streamHeartbeat); // ALWAYS clear pending timeouts to prevent memory leaks // This runs on success, error, or abort if (writeTimeout) { diff --git a/apps/server/src/services/settings-service.ts b/apps/server/src/services/settings-service.ts index 94bdce24..4de7231c 100644 --- a/apps/server/src/services/settings-service.ts +++ b/apps/server/src/services/settings-service.ts @@ -153,14 +153,6 @@ export class SettingsService { const storedVersion = settings.version || 1; let needsSave = false; - // Migration v1 -> v2: Force enableSandboxMode to false for existing users - // Sandbox mode can cause issues on some systems, so we're disabling it by default - if (storedVersion < 2) { - logger.info('Migrating settings from v1 to v2: disabling sandbox mode'); - result.enableSandboxMode = false; - needsSave = true; - } - // Migration v2 -> v3: Convert string phase models to PhaseModelEntry objects // Note: migratePhaseModels() handles the actual conversion for both v1 and v2 formats if (storedVersion < 3) { @@ -537,6 +529,10 @@ export class SettingsService { appState.enableDependencyBlocking !== undefined ? (appState.enableDependencyBlocking as boolean) : true, + skipVerificationInAutoMode: + appState.skipVerificationInAutoMode !== undefined + ? (appState.skipVerificationInAutoMode as boolean) + : false, useWorktrees: (appState.useWorktrees as boolean) || false, showProfilesOnly: (appState.showProfilesOnly as boolean) || false, defaultPlanningMode: diff --git a/apps/server/tests/unit/lib/sdk-options.test.ts b/apps/server/tests/unit/lib/sdk-options.test.ts index b442ae1d..029cd8fa 100644 --- a/apps/server/tests/unit/lib/sdk-options.test.ts +++ b/apps/server/tests/unit/lib/sdk-options.test.ts @@ -1,161 +1,15 @@ 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', () => { @@ -325,19 +179,15 @@ describe('sdk-options.ts', () => { it('should create options with chat settings', async () => { const { createChatOptions, TOOL_PRESETS, MAX_TURNS } = await import('@/lib/sdk-options.js'); - const options = createChatOptions({ cwd: '/test/path', enableSandboxMode: true }); + const options = createChatOptions({ cwd: '/test/path' }); expect(options.cwd).toBe('/test/path'); expect(options.maxTurns).toBe(MAX_TURNS.standard); expect(options.allowedTools).toEqual([...TOOL_PRESETS.chat]); - expect(options.sandbox).toEqual({ - enabled: true, - autoAllowBashIfSandboxed: true, - }); }); it('should prefer explicit model over session model', async () => { - const { createChatOptions, getModelForUseCase } = await import('@/lib/sdk-options.js'); + const { createChatOptions } = await import('@/lib/sdk-options.js'); const options = createChatOptions({ cwd: '/test/path', @@ -358,41 +208,6 @@ describe('sdk-options.ts', () => { expect(options.model).toBe('claude-sonnet-4-20250514'); }); - - it('should not set sandbox when enableSandboxMode is false', async () => { - const { createChatOptions } = await import('@/lib/sdk-options.js'); - - const options = createChatOptions({ - cwd: '/test/path', - enableSandboxMode: false, - }); - - expect(options.sandbox).toBeUndefined(); - }); - - 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(); - }); }); describe('createAutoModeOptions', () => { @@ -400,15 +215,11 @@ describe('sdk-options.ts', () => { const { createAutoModeOptions, TOOL_PRESETS, MAX_TURNS } = await import('@/lib/sdk-options.js'); - const options = createAutoModeOptions({ cwd: '/test/path', enableSandboxMode: true }); + const options = createAutoModeOptions({ cwd: '/test/path' }); expect(options.cwd).toBe('/test/path'); expect(options.maxTurns).toBe(MAX_TURNS.maximum); expect(options.allowedTools).toEqual([...TOOL_PRESETS.fullAccess]); - expect(options.sandbox).toEqual({ - enabled: true, - autoAllowBashIfSandboxed: true, - }); }); it('should include systemPrompt when provided', async () => { @@ -433,62 +244,6 @@ describe('sdk-options.ts', () => { expect(options.abortController).toBe(abortController); }); - - it('should not set sandbox when enableSandboxMode is false', async () => { - const { createAutoModeOptions } = await import('@/lib/sdk-options.js'); - - const options = createAutoModeOptions({ - cwd: '/test/path', - enableSandboxMode: false, - }); - - expect(options.sandbox).toBeUndefined(); - }); - - 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(); - }); }); describe('createCustomOptions', () => { @@ -499,13 +254,11 @@ describe('sdk-options.ts', () => { cwd: '/test/path', maxTurns: 10, allowedTools: ['Read', 'Write'], - sandbox: { enabled: true }, }); expect(options.cwd).toBe('/test/path'); expect(options.maxTurns).toBe(10); expect(options.allowedTools).toEqual(['Read', 'Write']); - expect(options.sandbox).toEqual({ enabled: true }); }); it('should use defaults when optional params not provided', async () => { @@ -517,20 +270,6 @@ describe('sdk-options.ts', () => { expect(options.allowedTools).toEqual([...TOOL_PRESETS.readOnly]); }); - it('should include sandbox when provided', async () => { - const { createCustomOptions } = await import('@/lib/sdk-options.js'); - - const options = createCustomOptions({ - cwd: '/test/path', - sandbox: { enabled: true, autoAllowBashIfSandboxed: false }, - }); - - expect(options.sandbox).toEqual({ - enabled: true, - autoAllowBashIfSandboxed: false, - }); - }); - it('should include systemPrompt when provided', async () => { const { createCustomOptions } = await import('@/lib/sdk-options.js'); diff --git a/apps/server/tests/unit/providers/claude-provider.test.ts b/apps/server/tests/unit/providers/claude-provider.test.ts index 38e1bf4c..a02d3b5a 100644 --- a/apps/server/tests/unit/providers/claude-provider.test.ts +++ b/apps/server/tests/unit/providers/claude-provider.test.ts @@ -79,7 +79,7 @@ describe('claude-provider.ts', () => { }); }); - it('should use default allowed tools when not specified', async () => { + it('should not include allowedTools when not specified (caller decides via sdk-options)', async () => { vi.mocked(sdk.query).mockReturnValue( (async function* () { yield { type: 'text', text: 'test' }; @@ -95,37 +95,8 @@ describe('claude-provider.ts', () => { expect(sdk.query).toHaveBeenCalledWith({ prompt: 'Test', - options: expect.objectContaining({ - allowedTools: ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'], - }), - }); - }); - - it('should pass sandbox configuration when provided', async () => { - vi.mocked(sdk.query).mockReturnValue( - (async function* () { - yield { type: 'text', text: 'test' }; - })() - ); - - const generator = provider.executeQuery({ - prompt: 'Test', - cwd: '/test', - sandbox: { - enabled: true, - autoAllowBashIfSandboxed: true, - }, - }); - - await collectAsyncGenerator(generator); - - expect(sdk.query).toHaveBeenCalledWith({ - prompt: 'Test', - options: expect.objectContaining({ - sandbox: { - enabled: true, - autoAllowBashIfSandboxed: true, - }, + options: expect.not.objectContaining({ + allowedTools: expect.anything(), }), }); }); diff --git a/apps/ui/src/components/dialogs/index.ts b/apps/ui/src/components/dialogs/index.ts index dd2597f5..4cadb26d 100644 --- a/apps/ui/src/components/dialogs/index.ts +++ b/apps/ui/src/components/dialogs/index.ts @@ -3,6 +3,4 @@ export { DeleteAllArchivedSessionsDialog } from './delete-all-archived-sessions- export { DeleteSessionDialog } from './delete-session-dialog'; export { FileBrowserDialog } from './file-browser-dialog'; export { NewProjectModal } from './new-project-modal'; -export { SandboxRejectionScreen } from './sandbox-rejection-screen'; -export { SandboxRiskDialog } from './sandbox-risk-dialog'; export { WorkspacePickerModal } from './workspace-picker-modal'; diff --git a/apps/ui/src/components/dialogs/sandbox-rejection-screen.tsx b/apps/ui/src/components/dialogs/sandbox-rejection-screen.tsx deleted file mode 100644 index 2e830f15..00000000 --- a/apps/ui/src/components/dialogs/sandbox-rejection-screen.tsx +++ /dev/null @@ -1,93 +0,0 @@ -/** - * Sandbox Rejection Screen - * - * Shown in web mode when user denies the sandbox risk confirmation. - * Prompts them to either restart the app in a container or reload to try again. - */ - -import { useState } from 'react'; -import { createLogger } from '@automaker/utils/logger'; -import { ShieldX, RefreshCw, Container, Copy, Check } from 'lucide-react'; - -const logger = createLogger('SandboxRejectionScreen'); -import { Button } from '@/components/ui/button'; - -const DOCKER_COMMAND = 'npm run dev:docker'; - -export function SandboxRejectionScreen() { - const [copied, setCopied] = useState(false); - - const handleReload = () => { - // Clear the rejection state and reload - sessionStorage.removeItem('automaker-sandbox-denied'); - window.location.reload(); - }; - - const handleCopy = async () => { - try { - await navigator.clipboard.writeText(DOCKER_COMMAND); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch (err) { - logger.error('Failed to copy:', err); - } - }; - - return ( -
-
-
-
- -
-
- -
-

Access Denied

-

- You declined to accept the risks of running Automaker outside a sandbox environment. -

-
- -
-
- -
-

Run in Docker (Recommended)

-

- Run Automaker in a containerized sandbox environment: -

-
- {DOCKER_COMMAND} - -
-
-
-
- -
- -
-
-
- ); -} diff --git a/apps/ui/src/components/dialogs/sandbox-risk-dialog.tsx b/apps/ui/src/components/dialogs/sandbox-risk-dialog.tsx deleted file mode 100644 index 7b6eab90..00000000 --- a/apps/ui/src/components/dialogs/sandbox-risk-dialog.tsx +++ /dev/null @@ -1,140 +0,0 @@ -/** - * Sandbox Risk Confirmation Dialog - * - * Shows when the app is running outside a containerized environment. - * Users must acknowledge the risks before proceeding. - */ - -import { useState } from 'react'; -import { createLogger } from '@automaker/utils/logger'; -import { ShieldAlert, Copy, Check } from 'lucide-react'; - -const logger = createLogger('SandboxRiskDialog'); -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { Button } from '@/components/ui/button'; -import { Checkbox } from '@/components/ui/checkbox'; -import { Label } from '@/components/ui/label'; - -interface SandboxRiskDialogProps { - open: boolean; - onConfirm: (skipInFuture: boolean) => void; - onDeny: () => void; -} - -const DOCKER_COMMAND = 'npm run dev:docker'; - -export function SandboxRiskDialog({ open, onConfirm, onDeny }: SandboxRiskDialogProps) { - const [copied, setCopied] = useState(false); - const [skipInFuture, setSkipInFuture] = useState(false); - - const handleConfirm = () => { - onConfirm(skipInFuture); - // Reset checkbox state after confirmation - setSkipInFuture(false); - }; - - const handleCopy = async () => { - try { - await navigator.clipboard.writeText(DOCKER_COMMAND); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch (err) { - logger.error('Failed to copy:', err); - } - }; - - return ( - {}}> - e.preventDefault()} - onEscapeKeyDown={(e) => e.preventDefault()} - showCloseButton={false} - > - - - - Sandbox Environment Not Detected - - -
-

- Warning: This application is running outside of a containerized - sandbox environment. AI agents will have direct access to your filesystem and can - execute commands on your system. -

- -
-

Potential Risks:

-
    -
  • Agents can read, modify, or delete files on your system
  • -
  • Agents can execute arbitrary commands and install software
  • -
  • Agents can access environment variables and credentials
  • -
  • Unintended side effects from agent actions may affect your system
  • -
-
- -
-

- For safer operation, consider running Automaker in Docker: -

-
- {DOCKER_COMMAND} - -
-
-
-
-
- - -
- setSkipInFuture(checked === true)} - data-testid="sandbox-skip-checkbox" - /> - -
-
- - -
-
-
-
- ); -} diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index 2c82261b..1580baad 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -89,6 +89,7 @@ export function BoardView() { setWorktrees, useWorktrees, enableDependencyBlocking, + skipVerificationInAutoMode, isPrimaryWorktreeBranch, getPrimaryWorktreeBranch, setPipelineConfig, @@ -732,10 +733,17 @@ export function BoardView() { }, []); useEffect(() => { + logger.info( + '[AutoMode] Effect triggered - isRunning:', + autoMode.isRunning, + 'hasProject:', + !!currentProject + ); if (!autoMode.isRunning || !currentProject) { return; } + logger.info('[AutoMode] Starting auto mode polling loop for project:', currentProject.path); let isChecking = false; let isActive = true; // Track if this effect is still active @@ -755,6 +763,14 @@ export function BoardView() { try { // Double-check auto mode is still running before proceeding if (!isActive || !autoModeRunningRef.current || !currentProject) { + logger.debug( + '[AutoMode] Skipping check - isActive:', + isActive, + 'autoModeRunning:', + autoModeRunningRef.current, + 'hasProject:', + !!currentProject + ); return; } @@ -762,6 +778,12 @@ export function BoardView() { // Use ref to get the latest running tasks without causing effect re-runs const currentRunning = runningAutoTasksRef.current.length + pendingFeaturesRef.current.size; const availableSlots = maxConcurrency - currentRunning; + logger.debug( + '[AutoMode] Checking features - running:', + currentRunning, + 'available slots:', + availableSlots + ); // No available slots, skip check if (availableSlots <= 0) { @@ -769,10 +791,12 @@ export function BoardView() { } // Filter backlog features by the currently selected worktree branch - // This logic mirrors use-board-column-features.ts for consistency + // This logic mirrors use-board-column-features.ts for consistency. + // HOWEVER: auto mode should still run even if the user is viewing a non-primary worktree, + // so we fall back to "all backlog features" when none are visible in the current view. // Use ref to get the latest features without causing effect re-runs const currentFeatures = hookFeaturesRef.current; - const backlogFeatures = currentFeatures.filter((f) => { + const backlogFeaturesInView = currentFeatures.filter((f) => { if (f.status !== 'backlog') return false; const featureBranch = f.branchName; @@ -796,7 +820,25 @@ export function BoardView() { return featureBranch === currentWorktreeBranch; }); + const backlogFeatures = + backlogFeaturesInView.length > 0 + ? backlogFeaturesInView + : currentFeatures.filter((f) => f.status === 'backlog'); + + logger.debug( + '[AutoMode] Features - total:', + currentFeatures.length, + 'backlog in view:', + backlogFeaturesInView.length, + 'backlog total:', + backlogFeatures.length + ); + if (backlogFeatures.length === 0) { + logger.debug( + '[AutoMode] No backlog features found, statuses:', + currentFeatures.map((f) => f.status).join(', ') + ); return; } @@ -806,12 +848,25 @@ export function BoardView() { ); // Filter out features with blocking dependencies if dependency blocking is enabled - const eligibleFeatures = enableDependencyBlocking - ? sortedBacklog.filter((f) => { - const blockingDeps = getBlockingDependencies(f, currentFeatures); - return blockingDeps.length === 0; - }) - : sortedBacklog; + // NOTE: skipVerificationInAutoMode means "ignore unmet dependency verification" so we + // should NOT exclude blocked features in that mode. + const eligibleFeatures = + enableDependencyBlocking && !skipVerificationInAutoMode + ? sortedBacklog.filter((f) => { + const blockingDeps = getBlockingDependencies(f, currentFeatures); + if (blockingDeps.length > 0) { + logger.debug('[AutoMode] Feature', f.id, 'blocked by deps:', blockingDeps); + } + return blockingDeps.length === 0; + }) + : sortedBacklog; + + logger.debug( + '[AutoMode] Eligible features after dep check:', + eligibleFeatures.length, + 'dependency blocking enabled:', + enableDependencyBlocking + ); // Start features up to available slots const featuresToStart = eligibleFeatures.slice(0, availableSlots); @@ -820,6 +875,13 @@ export function BoardView() { return; } + logger.info( + '[AutoMode] Starting', + featuresToStart.length, + 'features:', + featuresToStart.map((f) => f.id).join(', ') + ); + for (const feature of featuresToStart) { // Check again before starting each feature if (!isActive || !autoModeRunningRef.current || !currentProject) { @@ -827,8 +889,9 @@ export function BoardView() { } // Simplified: No worktree creation on client - server derives workDir from feature.branchName - // If feature has no branchName and primary worktree is selected, assign primary branch - if (currentWorktreePath === null && !feature.branchName) { + // If feature has no branchName, assign it to the primary branch so it can run consistently + // even when the user is viewing a non-primary worktree. + if (!feature.branchName) { const primaryBranch = (currentProject.path ? getPrimaryWorktreeBranch(currentProject.path) : null) || 'main'; @@ -878,6 +941,7 @@ export function BoardView() { getPrimaryWorktreeBranch, isPrimaryWorktreeBranch, enableDependencyBlocking, + skipVerificationInAutoMode, persistFeatureUpdate, ]); diff --git a/apps/ui/src/components/views/board-view/board-header.tsx b/apps/ui/src/components/views/board-view/board-header.tsx index 884cf495..b5de63bf 100644 --- a/apps/ui/src/components/views/board-view/board-header.tsx +++ b/apps/ui/src/components/views/board-view/board-header.tsx @@ -1,13 +1,15 @@ +import { useState } from 'react'; import { HotkeyButton } from '@/components/ui/hotkey-button'; import { Button } from '@/components/ui/button'; import { Slider } from '@/components/ui/slider'; import { Switch } from '@/components/ui/switch'; import { Label } from '@/components/ui/label'; -import { Plus, Bot, Wand2 } from 'lucide-react'; +import { Plus, Bot, Wand2, Settings2 } from 'lucide-react'; import { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts'; import { ClaudeUsagePopover } from '@/components/claude-usage-popover'; import { useAppStore } from '@/store/app-store'; import { useSetupStore } from '@/store/setup-store'; +import { AutoModeSettingsDialog } from './dialogs/auto-mode-settings-dialog'; interface BoardHeaderProps { projectName: string; @@ -38,8 +40,11 @@ export function BoardHeader({ addFeatureShortcut, isMounted, }: BoardHeaderProps) { + const [showAutoModeSettings, setShowAutoModeSettings] = useState(false); const apiKeys = useAppStore((state) => state.apiKeys); const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus); + const skipVerificationInAutoMode = useAppStore((state) => state.skipVerificationInAutoMode); + const setSkipVerificationInAutoMode = useAppStore((state) => state.setSkipVerificationInAutoMode); // Hide usage tracking when using API key (only show for Claude Code CLI users) // Check both user-entered API key and environment variable ANTHROPIC_API_KEY @@ -97,9 +102,25 @@ export function BoardHeader({ onCheckedChange={onAutoModeToggle} data-testid="auto-mode-toggle" /> + )} + {/* Auto Mode Settings Dialog */} + + - - )} - {/* Project Delete */} {project && (
@@ -97,7 +60,7 @@ export function DangerZoneSection({ )} {/* Empty state when nothing to show */} - {!skipSandboxWarning && !project && ( + {!project && (

No danger zone actions available.

diff --git a/apps/ui/src/components/views/settings-view/feature-defaults/feature-defaults-section.tsx b/apps/ui/src/components/views/settings-view/feature-defaults/feature-defaults-section.tsx index 24ebe15b..d55522bf 100644 --- a/apps/ui/src/components/views/settings-view/feature-defaults/feature-defaults-section.tsx +++ b/apps/ui/src/components/views/settings-view/feature-defaults/feature-defaults-section.tsx @@ -12,6 +12,7 @@ import { ScrollText, ShieldCheck, User, + FastForward, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { @@ -29,6 +30,7 @@ interface FeatureDefaultsSectionProps { showProfilesOnly: boolean; defaultSkipTests: boolean; enableDependencyBlocking: boolean; + skipVerificationInAutoMode: boolean; useWorktrees: boolean; defaultPlanningMode: PlanningMode; defaultRequirePlanApproval: boolean; @@ -37,6 +39,7 @@ interface FeatureDefaultsSectionProps { onShowProfilesOnlyChange: (value: boolean) => void; onDefaultSkipTestsChange: (value: boolean) => void; onEnableDependencyBlockingChange: (value: boolean) => void; + onSkipVerificationInAutoModeChange: (value: boolean) => void; onUseWorktreesChange: (value: boolean) => void; onDefaultPlanningModeChange: (value: PlanningMode) => void; onDefaultRequirePlanApprovalChange: (value: boolean) => void; @@ -47,6 +50,7 @@ export function FeatureDefaultsSection({ showProfilesOnly, defaultSkipTests, enableDependencyBlocking, + skipVerificationInAutoMode, useWorktrees, defaultPlanningMode, defaultRequirePlanApproval, @@ -55,6 +59,7 @@ export function FeatureDefaultsSection({ onShowProfilesOnlyChange, onDefaultSkipTestsChange, onEnableDependencyBlockingChange, + onSkipVerificationInAutoModeChange, onUseWorktreesChange, onDefaultPlanningModeChange, onDefaultRequirePlanApprovalChange, @@ -309,6 +314,34 @@ export function FeatureDefaultsSection({ {/* Separator */}
+ {/* Skip Verification in Auto Mode Setting */} +
+ onSkipVerificationInAutoModeChange(checked === true)} + className="mt-1" + data-testid="skip-verification-auto-mode-checkbox" + /> +
+ +

+ When enabled, auto mode will grab features even if their dependencies are not + verified, as long as they are not currently running. This allows faster pipeline + execution without waiting for manual verification. +

+
+
+ + {/* Separator */} +
+ {/* Worktree Isolation Setting */}
{ + try { + if (typeof window === 'undefined') return {}; + const raw = window.sessionStorage?.getItem(AUTO_MODE_SESSION_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== 'object') return {}; + return parsed as Record; + } catch { + return {}; + } +} + +function writeAutoModeSession(next: Record): void { + try { + if (typeof window === 'undefined') return; + window.sessionStorage?.setItem(AUTO_MODE_SESSION_KEY, JSON.stringify(next)); + } catch { + // ignore storage errors (private mode, disabled storage, etc.) + } +} + +function setAutoModeSessionForProjectPath(projectPath: string, running: boolean): void { + const current = readAutoModeSession(); + const next = { ...current, [projectPath]: running }; + writeAutoModeSession(next); +} + // Type guard for plan_approval_required event function isPlanApprovalEvent( event: AutoModeEvent @@ -64,6 +94,23 @@ export function useAutoMode() { // Check if we can start a new task based on concurrency limit const canStartNewTask = runningAutoTasks.length < maxConcurrency; + // Restore auto-mode toggle after a renderer refresh (e.g. dev HMR reload). + // This is intentionally session-scoped to avoid auto-running features after a full app restart. + useEffect(() => { + if (!currentProject) return; + + const session = readAutoModeSession(); + const desired = session[currentProject.path]; + if (typeof desired !== 'boolean') return; + + if (desired !== isAutoModeRunning) { + logger.info( + `[AutoMode] Restoring session state for ${currentProject.path}: ${desired ? 'ON' : 'OFF'}` + ); + setAutoModeRunning(currentProject.id, desired); + } + }, [currentProject, isAutoModeRunning, setAutoModeRunning]); + // Handle auto mode events - listen globally for all projects useEffect(() => { const api = getElectronAPI(); @@ -337,6 +384,7 @@ export function useAutoMode() { return; } + setAutoModeSessionForProjectPath(currentProject.path, true); setAutoModeRunning(currentProject.id, true); logger.debug(`[AutoMode] Started with maxConcurrency: ${maxConcurrency}`); }, [currentProject, setAutoModeRunning, maxConcurrency]); @@ -348,6 +396,7 @@ export function useAutoMode() { return; } + setAutoModeSessionForProjectPath(currentProject.path, false); setAutoModeRunning(currentProject.id, false); // NOTE: We intentionally do NOT clear running tasks here. // Stopping auto mode only turns off the toggle to prevent new features diff --git a/apps/ui/src/hooks/use-settings-migration.ts b/apps/ui/src/hooks/use-settings-migration.ts index 3674036b..0ab0d9fe 100644 --- a/apps/ui/src/hooks/use-settings-migration.ts +++ b/apps/ui/src/hooks/use-settings-migration.ts @@ -23,6 +23,7 @@ import { getHttpApiClient, waitForApiKeyInit } from '@/lib/http-api-client'; import { isElectron } from '@/lib/electron'; import { getItem, removeItem } from '@/lib/storage'; import { useAppStore } from '@/store/app-store'; +import type { GlobalSettings } from '@automaker/types'; const logger = createLogger('SettingsMigration'); @@ -123,7 +124,63 @@ export function useSettingsMigration(): MigrationState { // If settings files already exist, no migration needed if (!status.needsMigration) { - logger.info('Settings files exist, no migration needed'); + logger.info('Settings files exist - hydrating UI store from server'); + + // IMPORTANT: the server settings file is now the source of truth. + // If localStorage/Zustand get out of sync (e.g. cleared localStorage), + // the UI can show stale values even though the server will execute with + // the file-based settings. Hydrate the store from the server on startup. + try { + const global = await api.settings.getGlobal(); + if (global.success && global.settings) { + const serverSettings = global.settings as unknown as GlobalSettings; + const current = useAppStore.getState(); + + useAppStore.setState({ + theme: serverSettings.theme as unknown as import('@/store/app-store').ThemeMode, + sidebarOpen: serverSettings.sidebarOpen, + chatHistoryOpen: serverSettings.chatHistoryOpen, + kanbanCardDetailLevel: serverSettings.kanbanCardDetailLevel, + maxConcurrency: serverSettings.maxConcurrency, + defaultSkipTests: serverSettings.defaultSkipTests, + enableDependencyBlocking: serverSettings.enableDependencyBlocking, + skipVerificationInAutoMode: serverSettings.skipVerificationInAutoMode, + useWorktrees: serverSettings.useWorktrees, + showProfilesOnly: serverSettings.showProfilesOnly, + defaultPlanningMode: serverSettings.defaultPlanningMode, + defaultRequirePlanApproval: serverSettings.defaultRequirePlanApproval, + defaultAIProfileId: serverSettings.defaultAIProfileId, + muteDoneSound: serverSettings.muteDoneSound, + enhancementModel: serverSettings.enhancementModel, + validationModel: serverSettings.validationModel, + phaseModels: serverSettings.phaseModels, + enabledCursorModels: serverSettings.enabledCursorModels, + cursorDefaultModel: serverSettings.cursorDefaultModel, + autoLoadClaudeMd: serverSettings.autoLoadClaudeMd ?? false, + keyboardShortcuts: { + ...current.keyboardShortcuts, + ...(serverSettings.keyboardShortcuts as unknown as Partial< + typeof current.keyboardShortcuts + >), + }, + aiProfiles: serverSettings.aiProfiles, + mcpServers: serverSettings.mcpServers, + promptCustomization: serverSettings.promptCustomization ?? {}, + projects: serverSettings.projects, + trashedProjects: serverSettings.trashedProjects, + projectHistory: serverSettings.projectHistory, + projectHistoryIndex: serverSettings.projectHistoryIndex, + lastSelectedSessionByProject: serverSettings.lastSelectedSessionByProject, + }); + + logger.info('Hydrated UI settings from server settings file'); + } else { + logger.warn('Failed to load global settings from server:', global); + } + } catch (error) { + logger.error('Failed to hydrate UI settings from server:', error); + } + setState({ checked: true, migrated: false, error: null }); return; } @@ -201,14 +258,28 @@ export function useSettingsMigration(): MigrationState { export async function syncSettingsToServer(): Promise { try { const api = getHttpApiClient(); - const automakerStorage = getItem('automaker-storage'); - - if (!automakerStorage) { - return false; + // IMPORTANT: + // Prefer the live Zustand state over localStorage to avoid race conditions + // (Zustand persistence writes can lag behind `set(...)`, which would cause us + // to sync stale values to the server). + // + // localStorage remains as a fallback for cases where the store isn't ready. + let state: Record | null = null; + try { + state = useAppStore.getState() as unknown as Record; + } catch { + // Ignore and fall back to localStorage } - const parsed = JSON.parse(automakerStorage); - const state = parsed.state || parsed; + if (!state) { + const automakerStorage = getItem('automaker-storage'); + if (!automakerStorage) { + return false; + } + + const parsed = JSON.parse(automakerStorage) as Record; + state = (parsed.state as Record | undefined) || parsed; + } // Extract settings to sync const updates = { @@ -219,6 +290,7 @@ export async function syncSettingsToServer(): Promise { maxConcurrency: state.maxConcurrency, defaultSkipTests: state.defaultSkipTests, enableDependencyBlocking: state.enableDependencyBlocking, + skipVerificationInAutoMode: state.skipVerificationInAutoMode, useWorktrees: state.useWorktrees, showProfilesOnly: state.showProfilesOnly, defaultPlanningMode: state.defaultPlanningMode, @@ -229,8 +301,6 @@ export async function syncSettingsToServer(): Promise { validationModel: state.validationModel, phaseModels: state.phaseModels, autoLoadClaudeMd: state.autoLoadClaudeMd, - enableSandboxMode: state.enableSandboxMode, - skipSandboxWarning: state.skipSandboxWarning, keyboardShortcuts: state.keyboardShortcuts, aiProfiles: state.aiProfiles, mcpServers: state.mcpServers, diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 9bf58d8e..d8cb073a 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -379,32 +379,6 @@ export const verifySession = async (): Promise => { } }; -/** - * Check if the server is running in a containerized (sandbox) environment. - * This endpoint is unauthenticated so it can be checked before login. - */ -export const checkSandboxEnvironment = async (): Promise<{ - isContainerized: boolean; - error?: string; -}> => { - try { - const response = await fetch(`${getServerUrl()}/api/health/environment`, { - method: 'GET', - }); - - if (!response.ok) { - logger.warn('Failed to check sandbox environment'); - return { isContainerized: false, error: 'Failed to check environment' }; - } - - const data = await response.json(); - return { isContainerized: data.isContainerized ?? false }; - } catch (error) { - logger.error('Sandbox environment check failed:', error); - return { isContainerized: false, error: 'Network error' }; - } -}; - type EventType = | 'agent:stream' | 'auto-mode:event' diff --git a/apps/ui/src/routes/__root.tsx b/apps/ui/src/routes/__root.tsx index ce21a07d..f050c39f 100644 --- a/apps/ui/src/routes/__root.tsx +++ b/apps/ui/src/routes/__root.tsx @@ -16,28 +16,19 @@ import { initApiKey, isElectronMode, verifySession, - checkSandboxEnvironment, getServerUrlSync, checkExternalServerMode, isExternalServerMode, } from '@/lib/http-api-client'; import { Toaster } from 'sonner'; import { ThemeOption, themeOptions } from '@/config/theme-options'; -import { SandboxRiskDialog } from '@/components/dialogs/sandbox-risk-dialog'; -import { SandboxRejectionScreen } from '@/components/dialogs/sandbox-rejection-screen'; import { LoadingState } from '@/components/ui/loading-state'; const logger = createLogger('RootLayout'); function RootLayoutContent() { const location = useLocation(); - const { - setIpcConnected, - currentProject, - getEffectiveTheme, - skipSandboxWarning, - setSkipSandboxWarning, - } = useAppStore(); + const { setIpcConnected, currentProject, getEffectiveTheme } = useAppStore(); const { setupComplete } = useSetupStore(); const navigate = useNavigate(); const [isMounted, setIsMounted] = useState(false); @@ -52,12 +43,6 @@ function RootLayoutContent() { const isSetupRoute = location.pathname === '/setup'; const isLoginRoute = location.pathname === '/login'; - // Sandbox environment check state - type SandboxStatus = 'pending' | 'containerized' | 'needs-confirmation' | 'denied' | 'confirmed'; - // Always start from pending on a fresh page load so the user sees the prompt - // each time the app is launched/refreshed (unless running in a container). - const [sandboxStatus, setSandboxStatus] = useState('pending'); - // Hidden streamer panel - opens with "\" key const handleStreamerPanelShortcut = useCallback((event: KeyboardEvent) => { const activeElement = document.activeElement; @@ -104,73 +89,6 @@ function RootLayoutContent() { setIsMounted(true); }, []); - // Check sandbox environment on mount - useEffect(() => { - // Skip if already decided - if (sandboxStatus !== 'pending') { - return; - } - - const checkSandbox = async () => { - try { - const result = await checkSandboxEnvironment(); - - if (result.isContainerized) { - // Running in a container, no warning needed - setSandboxStatus('containerized'); - } else if (skipSandboxWarning) { - // User opted to skip the warning, auto-confirm - setSandboxStatus('confirmed'); - } else { - // Not containerized, show warning dialog - setSandboxStatus('needs-confirmation'); - } - } catch (error) { - logger.error('Failed to check environment:', error); - // On error, assume not containerized and show warning - if (skipSandboxWarning) { - setSandboxStatus('confirmed'); - } else { - setSandboxStatus('needs-confirmation'); - } - } - }; - - checkSandbox(); - }, [sandboxStatus, skipSandboxWarning]); - - // Handle sandbox risk confirmation - const handleSandboxConfirm = useCallback( - (skipInFuture: boolean) => { - if (skipInFuture) { - setSkipSandboxWarning(true); - } - setSandboxStatus('confirmed'); - }, - [setSkipSandboxWarning] - ); - - // Handle sandbox risk denial - const handleSandboxDeny = useCallback(async () => { - if (isElectron()) { - // In Electron mode, quit the application - // Use window.electronAPI directly since getElectronAPI() returns the HTTP client - try { - const electronAPI = window.electronAPI; - if (electronAPI?.quit) { - await electronAPI.quit(); - } else { - logger.error('quit() not available on electronAPI'); - } - } catch (error) { - logger.error('Failed to quit app:', error); - } - } else { - // In web mode, show rejection screen - setSandboxStatus('denied'); - } - }, []); - // Ref to prevent concurrent auth checks from running const authCheckRunning = useRef(false); @@ -330,31 +248,11 @@ function RootLayoutContent() { } }, [deferredTheme]); - // Show rejection screen if user denied sandbox risk (web mode only) - if (sandboxStatus === 'denied' && !isElectron()) { - return ; - } - - // Show loading while checking sandbox environment - if (sandboxStatus === 'pending') { - return ( -
- -
- ); - } - // Show login page (full screen, no sidebar) if (isLoginRoute) { return (
- {/* Show sandbox dialog on top of login page if needed */} -
); } @@ -386,12 +284,6 @@ function RootLayoutContent() { return (
- {/* Show sandbox dialog on top of setup page if needed */} -
); } @@ -420,13 +312,6 @@ function RootLayoutContent() { }`} /> - - {/* Show sandbox dialog if needed */} - ); } diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index d799b1a7..9fe64004 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -1,9 +1,11 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import type { Project, TrashedProject } from '@/lib/electron'; +import { createLogger } from '@automaker/utils/logger'; import type { Feature as BaseFeature, FeatureImagePath, + FeatureTextFilePath, ModelAlias, PlanningMode, AIProfile, @@ -19,8 +21,10 @@ import type { } from '@automaker/types'; import { getAllCursorModelIds, DEFAULT_PHASE_MODELS } from '@automaker/types'; +const logger = createLogger('AppStore'); + // Re-export types for convenience -export type { ThemeMode, ModelAlias }; +export type { ModelAlias }; export type ViewMode = | 'welcome' @@ -460,6 +464,7 @@ export interface AppState { // Feature Default Settings defaultSkipTests: boolean; // Default value for skip tests when creating new features enableDependencyBlocking: boolean; // When true, show blocked badges and warnings for features with incomplete dependencies (default: true) + skipVerificationInAutoMode: boolean; // When true, auto-mode grabs features even if dependencies are not verified (only checks they're not running) // Worktree Settings useWorktrees: boolean; // Whether to use git worktree isolation for features (default: false) @@ -506,8 +511,6 @@ export interface AppState { // Claude Agent SDK Settings autoLoadClaudeMd: boolean; // Auto-load CLAUDE.md files using SDK's settingSources option - enableSandboxMode: boolean; // Enable sandbox mode for bash commands (may cause issues on some systems) - skipSandboxWarning: boolean; // Skip the sandbox environment warning dialog on startup // MCP Servers mcpServers: MCPServerConfig[]; // List of configured MCP servers for agent use @@ -749,6 +752,7 @@ export interface AppActions { // Feature Default Settings actions setDefaultSkipTests: (skip: boolean) => void; setEnableDependencyBlocking: (enabled: boolean) => void; + setSkipVerificationInAutoMode: (enabled: boolean) => Promise; // Worktree Settings actions setUseWorktrees: (enabled: boolean) => void; @@ -804,8 +808,6 @@ export interface AppActions { // Claude Agent SDK Settings actions setAutoLoadClaudeMd: (enabled: boolean) => Promise; - setEnableSandboxMode: (enabled: boolean) => Promise; - setSkipSandboxWarning: (skip: boolean) => Promise; // Prompt Customization actions setPromptCustomization: (customization: PromptCustomization) => Promise; @@ -1006,6 +1008,7 @@ const initialState: AppState = { boardViewMode: 'kanban', // Default to kanban view defaultSkipTests: true, // Default to manual verification (tests disabled) enableDependencyBlocking: true, // Default to enabled (show dependency blocking UI) + skipVerificationInAutoMode: false, // Default to disabled (require dependencies to be verified) useWorktrees: false, // Default to disabled (worktree feature is experimental) currentWorktreeByProject: {}, worktreesByProject: {}, @@ -1019,8 +1022,6 @@ const initialState: AppState = { enabledCursorModels: getAllCursorModelIds(), // All Cursor models enabled by default cursorDefaultModel: 'auto', // Default to auto selection autoLoadClaudeMd: false, // Default to disabled (user must opt-in) - enableSandboxMode: false, // Default to disabled (can be enabled for additional security) - skipSandboxWarning: false, // Default to disabled (show sandbox warning dialog) mcpServers: [], // No MCP servers configured by default promptCustomization: {}, // Empty by default - all prompts use built-in defaults aiProfiles: DEFAULT_AI_PROFILES, @@ -1574,6 +1575,12 @@ export const useAppStore = create()( // Feature Default Settings actions setDefaultSkipTests: (skip) => set({ defaultSkipTests: skip }), setEnableDependencyBlocking: (enabled) => set({ enableDependencyBlocking: enabled }), + setSkipVerificationInAutoMode: async (enabled) => { + set({ skipVerificationInAutoMode: enabled }); + // Sync to server settings file + const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); + await syncSettingsToServer(); + }, // Worktree Settings actions setUseWorktrees: (enabled) => set({ useWorktrees: enabled }), @@ -1703,22 +1710,15 @@ export const useAppStore = create()( // Claude Agent SDK Settings actions setAutoLoadClaudeMd: async (enabled) => { + const previous = get().autoLoadClaudeMd; set({ autoLoadClaudeMd: enabled }); // Sync to server settings file const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); - await syncSettingsToServer(); - }, - setEnableSandboxMode: async (enabled) => { - set({ enableSandboxMode: enabled }); - // Sync to server settings file - const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); - await syncSettingsToServer(); - }, - setSkipSandboxWarning: async (skip) => { - set({ skipSandboxWarning: skip }); - // Sync to server settings file - const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); - await syncSettingsToServer(); + const ok = await syncSettingsToServer(); + if (!ok) { + logger.error('Failed to sync autoLoadClaudeMd setting to server - reverting'); + set({ autoLoadClaudeMd: previous }); + } }, // Prompt Customization actions setPromptCustomization: async (customization) => { @@ -2688,8 +2688,9 @@ export const useAppStore = create()( const current = get().terminalState; if (current.tabs.length === 0) { // Nothing to save, clear any existing layout - const { [projectPath]: _, ...rest } = get().terminalLayoutByProject; - set({ terminalLayoutByProject: rest }); + const next = { ...get().terminalLayoutByProject }; + delete next[projectPath]; + set({ terminalLayoutByProject: next }); return; } @@ -2745,8 +2746,9 @@ export const useAppStore = create()( }, clearPersistedTerminalLayout: (projectPath) => { - const { [projectPath]: _, ...rest } = get().terminalLayoutByProject; - set({ terminalLayoutByProject: rest }); + const next = { ...get().terminalLayoutByProject }; + delete next[projectPath]; + set({ terminalLayoutByProject: next }); }, // Spec Creation actions @@ -2995,6 +2997,7 @@ export const useAppStore = create()( // Auto-mode should always default to OFF on app refresh defaultSkipTests: state.defaultSkipTests, enableDependencyBlocking: state.enableDependencyBlocking, + skipVerificationInAutoMode: state.skipVerificationInAutoMode, useWorktrees: state.useWorktrees, currentWorktreeByProject: state.currentWorktreeByProject, worktreesByProject: state.worktreesByProject, @@ -3007,8 +3010,6 @@ export const useAppStore = create()( enabledCursorModels: state.enabledCursorModels, cursorDefaultModel: state.cursorDefaultModel, autoLoadClaudeMd: state.autoLoadClaudeMd, - enableSandboxMode: state.enableSandboxMode, - skipSandboxWarning: state.skipSandboxWarning, // MCP settings mcpServers: state.mcpServers, // Prompt customization diff --git a/libs/dependency-resolver/src/index.ts b/libs/dependency-resolver/src/index.ts index 9ecaa487..63fd22e4 100644 --- a/libs/dependency-resolver/src/index.ts +++ b/libs/dependency-resolver/src/index.ts @@ -12,5 +12,6 @@ export { getAncestors, formatAncestorContextForPrompt, type DependencyResolutionResult, + type DependencySatisfactionOptions, type AncestorContext, } from './resolver.js'; diff --git a/libs/dependency-resolver/src/resolver.ts b/libs/dependency-resolver/src/resolver.ts index f54524c0..145617f4 100644 --- a/libs/dependency-resolver/src/resolver.ts +++ b/libs/dependency-resolver/src/resolver.ts @@ -174,21 +174,40 @@ function detectCycles(features: Feature[], featureMap: Map): st return cycles; } +export interface DependencySatisfactionOptions { + /** If true, only require dependencies to not be 'running' (ignore verification requirement) */ + skipVerification?: boolean; +} + /** * Checks if a feature's dependencies are satisfied (all complete or verified) * * @param feature - Feature to check * @param allFeatures - All features in the project + * @param options - Optional configuration for dependency checking * @returns true if all dependencies are satisfied, false otherwise */ -export function areDependenciesSatisfied(feature: Feature, allFeatures: Feature[]): boolean { +export function areDependenciesSatisfied( + feature: Feature, + allFeatures: Feature[], + options?: DependencySatisfactionOptions +): boolean { if (!feature.dependencies || feature.dependencies.length === 0) { return true; // No dependencies = always ready } + const skipVerification = options?.skipVerification ?? false; + return feature.dependencies.every((depId: string) => { const dep = allFeatures.find((f) => f.id === depId); - return dep && (dep.status === 'completed' || dep.status === 'verified'); + if (!dep) return false; + + if (skipVerification) { + // When skipping verification, only block if dependency is currently running + return dep.status !== 'running'; + } + // Default: require 'completed' or 'verified' + return dep.status === 'completed' || dep.status === 'verified'; }); } diff --git a/libs/types/src/provider.ts b/libs/types/src/provider.ts index 5b3549a6..ce4a4ab8 100644 --- a/libs/types/src/provider.ts +++ b/libs/types/src/provider.ts @@ -77,7 +77,6 @@ export interface ExecuteOptions { conversationHistory?: ConversationMessage[]; // Previous messages for context sdkSessionId?: string; // Claude SDK session ID for resuming conversations settingSources?: Array<'user' | 'project' | 'local'>; // Sources for CLAUDE.md loading - sandbox?: { enabled: boolean; autoAllowBashIfSandboxed?: boolean }; // Sandbox configuration /** * If true, the provider should run in read-only mode (no file modifications). * For Cursor CLI, this omits the --force flag, making it suggest-only. diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 0220da3a..cad2cd6f 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -406,6 +406,8 @@ export interface GlobalSettings { defaultSkipTests: boolean; /** Default: enable dependency blocking */ enableDependencyBlocking: boolean; + /** Skip verification requirement in auto-mode (treat 'completed' same as 'verified') */ + skipVerificationInAutoMode: boolean; /** Default: use git worktrees for feature branches */ useWorktrees: boolean; /** Default: only show AI profiles (hide other settings) */ @@ -474,10 +476,6 @@ export interface GlobalSettings { // Claude Agent SDK Settings /** Auto-load CLAUDE.md files using SDK's settingSources option */ autoLoadClaudeMd?: boolean; - /** Enable sandbox mode for bash commands (default: false, enable for additional security) */ - enableSandboxMode?: boolean; - /** Skip showing the sandbox risk warning dialog */ - skipSandboxWarning?: boolean; // MCP Server Configuration /** List of configured MCP servers for agent use */ @@ -650,6 +648,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = { maxConcurrency: 3, defaultSkipTests: true, enableDependencyBlocking: true, + skipVerificationInAutoMode: false, useWorktrees: false, showProfilesOnly: false, defaultPlanningMode: 'skip', @@ -672,8 +671,6 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = { worktreePanelCollapsed: false, lastSelectedSessionByProject: {}, autoLoadClaudeMd: false, - enableSandboxMode: false, - skipSandboxWarning: false, mcpServers: [], };