From 920dcd105f32e9907f2004074ed99346abba7bc1 Mon Sep 17 00:00:00 2001 From: Stephan Rieche Date: Sat, 27 Dec 2025 12:24:28 +0100 Subject: [PATCH 01/73] feat: add configurable sandbox mode setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a global setting to enable/disable sandbox mode for Claude Agent SDK. This allows users to control sandbox behavior based on their authentication setup and system compatibility. Changes: - Add enableSandboxMode to GlobalSettings (default: true) - Add sandbox mode checkbox in Claude settings UI - Wire up setting through app store and settings service - Update createChatOptions and createAutoModeOptions to use setting - Add getEnableSandboxModeSetting helper function - Remove hardcoded sandbox configuration from ClaudeProvider - Add detailed logging throughout agent execution flow The sandbox mode requires API key or OAuth token authentication. Users experiencing issues with CLI-only auth can disable it in settings. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- apps/server/src/index.ts | 25 +++- apps/server/src/lib/sdk-options.ts | 27 +++-- apps/server/src/lib/settings-helpers.ts | 28 +++++ apps/server/src/providers/claude-provider.ts | 111 ++++++++++++++++-- apps/server/src/routes/agent/routes/send.ts | 15 +++ apps/server/src/services/agent-service.ts | 69 ++++++++++- apps/server/src/services/auto-mode-service.ts | 10 +- .../ui/src/components/views/settings-view.tsx | 4 + .../claude/claude-md-settings.tsx | 32 ++++- apps/ui/src/store/app-store.ts | 10 ++ libs/types/src/settings.ts | 3 + 11 files changed, 308 insertions(+), 26 deletions(-) diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 6b63ffa8..188e2883 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -190,12 +190,31 @@ server.on('upgrade', (request, socket, head) => { // Events WebSocket connection handler wss.on('connection', (ws: WebSocket) => { - console.log('[WebSocket] Client connected'); + console.log('[WebSocket] Client connected, ready state:', ws.readyState); // Subscribe to all events and forward to this client const unsubscribe = events.subscribe((type, payload) => { + console.log('[WebSocket] Event received:', { + type, + hasPayload: !!payload, + payloadKeys: payload ? Object.keys(payload) : [], + wsReadyState: ws.readyState, + wsOpen: ws.readyState === WebSocket.OPEN, + }); + if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type, payload })); + const message = JSON.stringify({ type, payload }); + console.log('[WebSocket] Sending event to client:', { + type, + messageLength: message.length, + sessionId: (payload as any)?.sessionId, + }); + ws.send(message); + } else { + console.log( + '[WebSocket] WARNING: Cannot send event, WebSocket not open. ReadyState:', + ws.readyState + ); } }); @@ -205,7 +224,7 @@ wss.on('connection', (ws: WebSocket) => { }); ws.on('error', (error) => { - console.error('[WebSocket] Error:', error); + console.error('[WebSocket] ERROR:', error); unsubscribe(); }); }); diff --git a/apps/server/src/lib/sdk-options.ts b/apps/server/src/lib/sdk-options.ts index e7fc3578..80433f5b 100644 --- a/apps/server/src/lib/sdk-options.ts +++ b/apps/server/src/lib/sdk-options.ts @@ -216,6 +216,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; } /** @@ -314,7 +317,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 enabled for bash safety + * - Sandbox mode controlled by enableSandboxMode setting * - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading */ export function createChatOptions(config: CreateSdkOptionsConfig): Options { @@ -333,10 +336,12 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options { maxTurns: MAX_TURNS.standard, cwd: config.cwd, allowedTools: [...TOOL_PRESETS.chat], - sandbox: { - enabled: true, - autoAllowBashIfSandboxed: true, - }, + ...(config.enableSandboxMode && { + sandbox: { + enabled: true, + autoAllowBashIfSandboxed: true, + }, + }), ...claudeMdOptions, ...(config.abortController && { abortController: config.abortController }), }; @@ -349,7 +354,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 enabled for bash safety + * - Sandbox mode controlled by enableSandboxMode setting * - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading */ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options { @@ -365,10 +370,12 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options { maxTurns: MAX_TURNS.maximum, cwd: config.cwd, allowedTools: [...TOOL_PRESETS.fullAccess], - sandbox: { - enabled: true, - autoAllowBashIfSandboxed: true, - }, + ...(config.enableSandboxMode && { + sandbox: { + enabled: true, + autoAllowBashIfSandboxed: true, + }, + }), ...claudeMdOptions, ...(config.abortController && { abortController: config.abortController }), }; diff --git a/apps/server/src/lib/settings-helpers.ts b/apps/server/src/lib/settings-helpers.ts index 9c4456ff..8c6e3073 100644 --- a/apps/server/src/lib/settings-helpers.ts +++ b/apps/server/src/lib/settings-helpers.ts @@ -45,6 +45,34 @@ 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) { + console.log(`${logPrefix} SettingsService not available, sandbox mode disabled`); + return false; + } + + try { + const globalSettings = await settingsService.getGlobalSettings(); + const result = globalSettings.enableSandboxMode ?? false; + console.log(`${logPrefix} enableSandboxMode from global settings: ${result}`); + return result; + } catch (error) { + console.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 9237cdf6..563dd70c 100644 --- a/apps/server/src/providers/claude-provider.ts +++ b/apps/server/src/providers/claude-provider.ts @@ -23,6 +23,8 @@ export class ClaudeProvider extends BaseProvider { * Execute a query using Claude Agent SDK */ async *executeQuery(options: ExecuteOptions): AsyncGenerator { + console.log('[ClaudeProvider] executeQuery() called'); + const { prompt, model, @@ -35,6 +37,20 @@ export class ClaudeProvider extends BaseProvider { sdkSessionId, } = options; + console.log('[ClaudeProvider] Options:', { + model, + cwd, + maxTurns, + promptType: typeof prompt, + promptLength: typeof prompt === 'string' ? prompt.length : 'array', + hasSystemPrompt: !!systemPrompt, + systemPromptLength: systemPrompt?.length, + hasConversationHistory: !!conversationHistory?.length, + conversationHistoryLength: conversationHistory?.length || 0, + sdkSessionId, + allowedToolsCount: allowedTools?.length, + }); + // Build Claude SDK options const defaultTools = ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch']; const toolsToUse = allowedTools || defaultTools; @@ -45,11 +61,7 @@ export class ClaudeProvider extends BaseProvider { maxTurns, cwd, allowedTools: toolsToUse, - permissionMode: 'acceptEdits', - sandbox: { - enabled: true, - autoAllowBashIfSandboxed: true, - }, + permissionMode: 'default', abortController, // Resume existing SDK session if we have a session ID ...(sdkSessionId && conversationHistory && conversationHistory.length > 0 @@ -59,6 +71,15 @@ export class ClaudeProvider extends BaseProvider { ...(options.settingSources && { settingSources: options.settingSources }), }; + console.log('[ClaudeProvider] SDK options prepared:', { + model: sdkOptions.model, + maxTurns: sdkOptions.maxTurns, + permissionMode: sdkOptions.permissionMode, + sandboxEnabled: sdkOptions.sandbox?.enabled || false, + hasResume: !!(sdkOptions as any).resume, + toolsCount: sdkOptions.allowedTools?.length, + }); + // Build prompt payload let promptPayload: string | AsyncIterable; @@ -83,14 +104,84 @@ export class ClaudeProvider extends BaseProvider { // Execute via Claude Agent SDK try { - const stream = query({ prompt: promptPayload, options: sdkOptions }); + console.log('[ClaudeProvider] ANTHROPIC_API_KEY exists:', !!process.env.ANTHROPIC_API_KEY); + console.log( + '[ClaudeProvider] ANTHROPIC_API_KEY length:', + process.env.ANTHROPIC_API_KEY?.length || 0 + ); + console.log('[ClaudeProvider] HOME directory:', process.env.HOME); + console.log('[ClaudeProvider] User:', process.env.USER); + console.log('[ClaudeProvider] Current working directory:', process.cwd()); - // Stream messages directly - they're already in the correct format - for await (const msg of stream) { - yield msg as ProviderMessage; + // CRITICAL DEBUG: Log exact SDK options being passed + console.log('[ClaudeProvider] EXACT sdkOptions being passed to query():'); + console.log( + JSON.stringify( + { + model: sdkOptions.model, + maxTurns: sdkOptions.maxTurns, + cwd: sdkOptions.cwd, + allowedTools: sdkOptions.allowedTools, + permissionMode: sdkOptions.permissionMode, + hasSandbox: !!sdkOptions.sandbox, + hasAbortController: !!sdkOptions.abortController, + hasResume: !!(sdkOptions as any).resume, + hasSettingSources: !!sdkOptions.settingSources, + settingSources: sdkOptions.settingSources, + }, + null, + 2 + ) + ); + + console.log('[ClaudeProvider] Calling Claude Agent SDK query()...'); + console.log( + '[ClaudeProvider] About to call query() with prompt payload type:', + typeof promptPayload + ); + + const stream = query({ prompt: promptPayload, options: sdkOptions }); + console.log('[ClaudeProvider] query() call returned, stream object type:', typeof stream); + + console.log('[ClaudeProvider] SDK query() returned stream, starting iteration...'); + let streamMessageCount = 0; + + // Add a watchdog timer to detect if stream is hanging + let lastMessageTime = Date.now(); + const watchdogInterval = setInterval(() => { + const timeSinceLastMessage = Date.now() - lastMessageTime; + if (timeSinceLastMessage > 10000) { + console.log( + `[ClaudeProvider] WARNING: No messages received for ${Math.floor(timeSinceLastMessage / 1000)}s` + ); + } + }, 5000); + + try { + // Stream messages directly - they're already in the correct format + for await (const msg of stream) { + lastMessageTime = Date.now(); + streamMessageCount++; + console.log(`[ClaudeProvider] Stream message #${streamMessageCount}:`, { + type: msg.type, + subtype: (msg as any).subtype, + hasMessage: !!(msg as any).message, + hasResult: !!(msg as any).result, + session_id: msg.session_id, + }); + yield msg as ProviderMessage; + } + } finally { + clearInterval(watchdogInterval); } + + console.log( + '[ClaudeProvider] Stream iteration completed, total messages:', + streamMessageCount + ); } catch (error) { - console.error('[ClaudeProvider] executeQuery() error during execution:', error); + console.error('[ClaudeProvider] ERROR: executeQuery() error during execution:', error); + console.error('[ClaudeProvider] ERROR stack:', (error as Error).stack); throw error; } } diff --git a/apps/server/src/routes/agent/routes/send.ts b/apps/server/src/routes/agent/routes/send.ts index 0dd2f424..35c1e88a 100644 --- a/apps/server/src/routes/agent/routes/send.ts +++ b/apps/server/src/routes/agent/routes/send.ts @@ -19,7 +19,16 @@ export function createSendHandler(agentService: AgentService) { model?: string; }; + console.log('[Send Handler] Received request:', { + sessionId, + messageLength: message?.length, + workingDirectory, + imageCount: imagePaths?.length || 0, + model, + }); + if (!sessionId || !message) { + console.log('[Send Handler] ERROR: Validation failed - missing sessionId or message'); res.status(400).json({ success: false, error: 'sessionId and message are required', @@ -27,6 +36,8 @@ export function createSendHandler(agentService: AgentService) { return; } + console.log('[Send Handler] Validation passed, calling agentService.sendMessage()'); + // Start the message processing (don't await - it streams via WebSocket) agentService .sendMessage({ @@ -37,12 +48,16 @@ export function createSendHandler(agentService: AgentService) { model, }) .catch((error) => { + console.error('[Send Handler] ERROR: Background error in sendMessage():', error); logError(error, 'Send message failed (background)'); }); + console.log('[Send Handler] Returning immediate response to client'); + // Return immediately - responses come via WebSocket res.json({ success: true, message: 'Message sent' }); } catch (error) { + console.error('[Send Handler] ERROR: Synchronous error:', error); logError(error, 'Send message failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } diff --git a/apps/server/src/services/agent-service.ts b/apps/server/src/services/agent-service.ts index 5afddcd9..966519d4 100644 --- a/apps/server/src/services/agent-service.ts +++ b/apps/server/src/services/agent-service.ts @@ -17,7 +17,11 @@ import { ProviderFactory } from '../providers/provider-factory.js'; import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js'; import { PathNotAllowedError } from '@automaker/platform'; import type { SettingsService } from './settings-service.js'; -import { getAutoLoadClaudeMdSetting, filterClaudeMdFromContext } from '../lib/settings-helpers.js'; +import { + getAutoLoadClaudeMdSetting, + getEnableSandboxModeSetting, + filterClaudeMdFromContext, +} from '../lib/settings-helpers.js'; interface Message { id: string; @@ -140,12 +144,29 @@ export class AgentService { imagePaths?: string[]; model?: string; }) { + console.log('[AgentService] sendMessage() called:', { + sessionId, + messageLength: message?.length, + workingDirectory, + imageCount: imagePaths?.length || 0, + model, + }); + const session = this.sessions.get(sessionId); if (!session) { + console.error('[AgentService] ERROR: Session not found:', sessionId); throw new Error(`Session ${sessionId} not found`); } + console.log('[AgentService] Session found:', { + sessionId, + messageCount: session.messages.length, + isRunning: session.isRunning, + workingDirectory: session.workingDirectory, + }); + if (session.isRunning) { + console.error('[AgentService] ERROR: Agent already running for session:', sessionId); throw new Error('Agent is already processing a message'); } @@ -192,16 +213,19 @@ export class AgentService { session.abortController = new AbortController(); // Emit started event so UI can show thinking indicator + console.log('[AgentService] Emitting "started" event for session:', sessionId); this.emitAgentEvent(sessionId, { type: 'started', }); // Emit user message event + console.log('[AgentService] Emitting "message" event for session:', sessionId); this.emitAgentEvent(sessionId, { type: 'message', message: userMessage, }); + console.log('[AgentService] Saving session messages'); await this.saveSession(sessionId, session.messages); try { @@ -215,6 +239,12 @@ export class AgentService { '[AgentService]' ); + // Load enableSandboxMode setting (global setting only) + const enableSandboxMode = await getEnableSandboxModeSetting( + this.settingsService, + '[AgentService]' + ); + // Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) const contextResult = await loadContextFiles({ projectPath: effectiveWorkDir, @@ -239,6 +269,7 @@ export class AgentService { systemPrompt: combinedSystemPrompt, abortController: session.abortController!, autoLoadClaudeMd, + enableSandboxMode, }); // Extract model, maxTurns, and allowedTools from SDK options @@ -247,6 +278,7 @@ export class AgentService { const allowedTools = sdkOptions.allowedTools as string[] | undefined; // Get provider for this model + console.log('[AgentService] Getting provider for model:', effectiveModel); const provider = ProviderFactory.getProviderForModel(effectiveModel); console.log( @@ -267,6 +299,7 @@ export class AgentService { sdkSessionId: session.sdkSessionId, // Pass SDK session ID for resuming }; + console.log('[AgentService] Building prompt with images...'); // Build prompt content with images const { content: promptContent } = await buildPromptWithImages( message, @@ -278,14 +311,32 @@ export class AgentService { // Set the prompt in options options.prompt = promptContent; + console.log('[AgentService] Executing query via provider:', { + model: effectiveModel, + promptLength: typeof promptContent === 'string' ? promptContent.length : 'array', + hasConversationHistory: !!conversationHistory.length, + sdkSessionId: session.sdkSessionId, + }); + // Execute via provider const stream = provider.executeQuery(options); + console.log('[AgentService] Stream created, starting to iterate...'); let currentAssistantMessage: Message | null = null; let responseText = ''; const toolUses: Array<{ name: string; input: unknown }> = []; + let messageCount = 0; + console.log('[AgentService] Entering stream loop...'); for await (const msg of stream) { + messageCount++; + console.log(`[AgentService] Stream message #${messageCount}:`, { + type: msg.type, + subtype: (msg as any).subtype, + hasContent: !!(msg as any).message?.content, + session_id: msg.session_id, + }); + // Capture SDK session ID from any message and persist it if (msg.session_id && !session.sdkSessionId) { session.sdkSessionId = msg.session_id; @@ -295,6 +346,7 @@ export class AgentService { } if (msg.type === 'assistant') { + console.log('[AgentService] Processing assistant message...'); if (msg.message?.content) { for (const block of msg.message.content) { if (block.type === 'text') { @@ -312,6 +364,10 @@ export class AgentService { currentAssistantMessage.content = responseText; } + console.log( + '[AgentService] Emitting "stream" event, text length:', + responseText.length + ); this.emitAgentEvent(sessionId, { type: 'stream', messageId: currentAssistantMessage.id, @@ -325,6 +381,7 @@ export class AgentService { }; toolUses.push(toolUse); + console.log('[AgentService] Tool use detected:', toolUse.name); this.emitAgentEvent(sessionId, { type: 'tool_use', tool: toolUse, @@ -333,6 +390,7 @@ export class AgentService { } } } else if (msg.type === 'result') { + console.log('[AgentService] Result message received, subtype:', (msg as any).subtype); if (msg.subtype === 'success' && msg.result) { if (currentAssistantMessage) { currentAssistantMessage.content = msg.result; @@ -340,6 +398,7 @@ export class AgentService { } } + console.log('[AgentService] Emitting "complete" event'); this.emitAgentEvent(sessionId, { type: 'complete', messageId: currentAssistantMessage?.id, @@ -349,6 +408,8 @@ export class AgentService { } } + console.log('[AgentService] Stream loop completed, total messages:', messageCount); + await this.saveSession(sessionId, session.messages); session.isRunning = false; @@ -757,7 +818,13 @@ export class AgentService { } private emitAgentEvent(sessionId: string, data: Record): void { + console.log('[AgentService] emitAgentEvent() called:', { + sessionId, + eventType: data.type, + dataKeys: Object.keys(data), + }); this.events.emit('agent:stream', { sessionId, ...data }); + console.log('[AgentService] Event emitted to EventEmitter'); } private getSystemPrompt(): string { diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index bcdb92a8..c0ba7bfb 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -32,7 +32,11 @@ import { } from '../lib/sdk-options.js'; import { FeatureLoader } from './feature-loader.js'; import type { SettingsService } from './settings-service.js'; -import { getAutoLoadClaudeMdSetting, filterClaudeMdFromContext } from '../lib/settings-helpers.js'; +import { + getAutoLoadClaudeMdSetting, + getEnableSandboxModeSetting, + filterClaudeMdFromContext, +} from '../lib/settings-helpers.js'; const execAsync = promisify(exec); @@ -1833,12 +1837,16 @@ 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]'); + // Build SDK options using centralized configuration for feature implementation const sdkOptions = createAutoModeOptions({ cwd: workDir, model: model, abortController, autoLoadClaudeMd, + enableSandboxMode, }); // Extract model, maxTurns, and allowedTools from SDK options diff --git a/apps/ui/src/components/views/settings-view.tsx b/apps/ui/src/components/views/settings-view.tsx index b888c9b6..6ea52add 100644 --- a/apps/ui/src/components/views/settings-view.tsx +++ b/apps/ui/src/components/views/settings-view.tsx @@ -50,6 +50,8 @@ export function SettingsView() { setValidationModel, autoLoadClaudeMd, setAutoLoadClaudeMd, + enableSandboxMode, + setEnableSandboxMode, } = useAppStore(); // Hide usage tracking when using API key (only show for Claude Code CLI users) @@ -108,6 +110,8 @@ export function SettingsView() { {showUsageTracking && } diff --git a/apps/ui/src/components/views/settings-view/claude/claude-md-settings.tsx b/apps/ui/src/components/views/settings-view/claude/claude-md-settings.tsx index 920984be..c2a6a3db 100644 --- a/apps/ui/src/components/views/settings-view/claude/claude-md-settings.tsx +++ b/apps/ui/src/components/views/settings-view/claude/claude-md-settings.tsx @@ -1,11 +1,13 @@ import { Label } from '@/components/ui/label'; import { Checkbox } from '@/components/ui/checkbox'; -import { FileCode } from 'lucide-react'; +import { FileCode, Shield } from 'lucide-react'; import { cn } from '@/lib/utils'; interface ClaudeMdSettingsProps { autoLoadClaudeMd: boolean; onAutoLoadClaudeMdChange: (enabled: boolean) => void; + enableSandboxMode: boolean; + onEnableSandboxModeChange: (enabled: boolean) => void; } /** @@ -25,6 +27,8 @@ interface ClaudeMdSettingsProps { export function ClaudeMdSettings({ autoLoadClaudeMd, onAutoLoadClaudeMdChange, + enableSandboxMode, + onEnableSandboxModeChange, }: ClaudeMdSettingsProps) { return (
+ +
+ onEnableSandboxModeChange(checked === true)} + className="mt-1" + data-testid="enable-sandbox-mode-checkbox" + /> +
+ +

+ Run bash commands in an isolated sandbox environment for additional security. + + Note: On some systems, enabling sandbox mode may cause the agent to hang without + responding. If you experience issues, try disabling this option. + +

+
+
); diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 874e1a6d..c4d7e2ca 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -480,6 +480,7 @@ 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) // Project Analysis projectAnalysis: ProjectAnalysis | null; @@ -756,6 +757,7 @@ export interface AppActions { // Claude Agent SDK Settings actions setAutoLoadClaudeMd: (enabled: boolean) => Promise; + setEnableSandboxMode: (enabled: boolean) => Promise; // AI Profile actions addAIProfile: (profile: Omit) => void; @@ -929,6 +931,7 @@ const initialState: AppState = { enhancementModel: 'sonnet', // Default to sonnet for feature enhancement validationModel: 'opus', // Default to opus for GitHub issue validation autoLoadClaudeMd: false, // Default to disabled (user must opt-in) + enableSandboxMode: true, // Default to enabled for security (can be disabled if issues occur) aiProfiles: DEFAULT_AI_PROFILES, projectAnalysis: null, isAnalyzing: false, @@ -1561,6 +1564,12 @@ export const useAppStore = create()( 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(); + }, // AI Profile actions addAIProfile: (profile) => { @@ -2706,6 +2715,7 @@ export const useAppStore = create()( enhancementModel: state.enhancementModel, validationModel: state.validationModel, autoLoadClaudeMd: state.autoLoadClaudeMd, + enableSandboxMode: state.enableSandboxMode, // Profiles and sessions aiProfiles: state.aiProfiles, chatSessions: state.chatSessions, diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index e73e7269..a0a58e21 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -301,6 +301,8 @@ 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: true, disable if issues occur) */ + enableSandboxMode?: boolean; } /** @@ -459,6 +461,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = { worktreePanelCollapsed: false, lastSelectedSessionByProject: {}, autoLoadClaudeMd: false, + enableSandboxMode: true, }; /** Default credentials (empty strings - user must provide API keys) */ From 94e166636b09e431187c98f873967deb30b331ef Mon Sep 17 00:00:00 2001 From: Stephan Rieche Date: Sat, 27 Dec 2025 12:55:43 +0100 Subject: [PATCH 02/73] fix: set consistent default for enableSandboxMode to true MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default value should be 'true' to match the defaults in libs/types/src/settings.ts and apps/ui/src/store/app-store.ts. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- apps/server/src/lib/settings-helpers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/lib/settings-helpers.ts b/apps/server/src/lib/settings-helpers.ts index 8c6e3073..dc057873 100644 --- a/apps/server/src/lib/settings-helpers.ts +++ b/apps/server/src/lib/settings-helpers.ts @@ -64,7 +64,7 @@ export async function getEnableSandboxModeSetting( try { const globalSettings = await settingsService.getGlobalSettings(); - const result = globalSettings.enableSandboxMode ?? false; + const result = globalSettings.enableSandboxMode ?? true; console.log(`${logPrefix} enableSandboxMode from global settings: ${result}`); return result; } catch (error) { From 348a4d95e920cdab55d867a6e7f3a83367f7ef59 Mon Sep 17 00:00:00 2001 From: Stephan Rieche Date: Sat, 27 Dec 2025 13:06:22 +0100 Subject: [PATCH 03/73] fix: pass sandbox configuration through ExecuteOptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sandbox configuration was set in createChatOptions() and createAutoModeOptions(), but was never passed to the ClaudeProvider. This caused the sandbox to never actually be enabled. Changes: - Add sandbox field to ExecuteOptions interface - Pass sandbox config from AgentService to provider - Pass sandbox config from AutoModeService to provider - Forward sandbox config in ClaudeProvider to SDK options Now the sandbox configuration from settings is properly used. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- apps/server/src/providers/claude-provider.ts | 2 + apps/server/src/providers/types.ts | 1 + apps/server/src/services/agent-service.ts | 1 + apps/server/src/services/auto-mode-service.ts | 2 + libs/types/src/provider.ts | 1 + package-lock.json | 88 +++++++------------ 6 files changed, 37 insertions(+), 58 deletions(-) diff --git a/apps/server/src/providers/claude-provider.ts b/apps/server/src/providers/claude-provider.ts index 563dd70c..919eeb30 100644 --- a/apps/server/src/providers/claude-provider.ts +++ b/apps/server/src/providers/claude-provider.ts @@ -69,6 +69,8 @@ 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 }), }; console.log('[ClaudeProvider] SDK options prepared:', { diff --git a/apps/server/src/providers/types.ts b/apps/server/src/providers/types.ts index 5a594361..17b45066 100644 --- a/apps/server/src/providers/types.ts +++ b/apps/server/src/providers/types.ts @@ -34,6 +34,7 @@ export interface ExecuteOptions { conversationHistory?: ConversationMessage[]; // Previous messages for context sdkSessionId?: string; // Claude SDK session ID for resuming conversations settingSources?: Array<'user' | 'project' | 'local'>; // Claude filesystem settings to load + sandbox?: { enabled: boolean; autoAllowBashIfSandboxed?: boolean }; // Sandbox configuration } /** diff --git a/apps/server/src/services/agent-service.ts b/apps/server/src/services/agent-service.ts index 966519d4..c072803d 100644 --- a/apps/server/src/services/agent-service.ts +++ b/apps/server/src/services/agent-service.ts @@ -296,6 +296,7 @@ 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 }; diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index c0ba7bfb..987554f2 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -1153,6 +1153,7 @@ Format your response as a structured markdown document.`; allowedTools: sdkOptions.allowedTools as string[], abortController, settingSources: sdkOptions.settingSources, + sandbox: sdkOptions.sandbox, // Pass sandbox configuration }; const stream = provider.executeQuery(options); @@ -1887,6 +1888,7 @@ 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 }; // Execute via provider diff --git a/libs/types/src/provider.ts b/libs/types/src/provider.ts index 53c92717..3dca3db5 100644 --- a/libs/types/src/provider.ts +++ b/libs/types/src/provider.ts @@ -43,6 +43,7 @@ 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 } /** diff --git a/package-lock.json b/package-lock.json index b2e61ce0..e51794c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -423,6 +423,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1006,6 +1007,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.4.tgz", "integrity": "sha512-xMF6OfEAUVY5Waega4juo1QGACfNkNF+aJLqpd8oUJz96ms2zbfQ9Gh35/tI3y8akEV31FruKfj7hBnIU/nkqA==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", @@ -1048,6 +1050,7 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", + "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -1868,7 +1871,6 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, - "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -1890,7 +1892,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -1907,7 +1908,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -1922,7 +1922,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">= 10.0.0" } @@ -2678,7 +2677,6 @@ "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=18" } @@ -2803,7 +2801,6 @@ "os": [ "linux" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -2820,7 +2817,6 @@ "os": [ "linux" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -2837,7 +2833,6 @@ "os": [ "linux" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -2946,7 +2941,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -2969,7 +2963,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -2992,7 +2985,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -3078,7 +3070,6 @@ ], "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, - "peer": true, "dependencies": { "@emnapi/runtime": "^1.7.0" }, @@ -3101,7 +3092,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -3121,7 +3111,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -3460,8 +3449,7 @@ "version": "16.0.10", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.10.tgz", "integrity": "sha512-8tuaQkyDVgeONQ1MeT9Mkk8pQmZapMKFh5B+OrFUlG3rVmYTXcXlBetBgTurKXGaIZvkoqRT9JL5K3phXcgang==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@next/swc-darwin-arm64": { "version": "16.0.10", @@ -3475,7 +3463,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 10" } @@ -3492,7 +3479,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 10" } @@ -3509,7 +3495,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10" } @@ -3526,7 +3511,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10" } @@ -3543,7 +3527,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10" } @@ -3560,7 +3543,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10" } @@ -3577,7 +3559,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 10" } @@ -3594,7 +3575,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 10" } @@ -3685,6 +3665,7 @@ "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "playwright": "1.57.0" }, @@ -5125,7 +5106,6 @@ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.8.0" } @@ -5459,6 +5439,7 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.141.6.tgz", "integrity": "sha512-qWFxi2D6eGc1L03RzUuhyEOplZ7Q6q62YOl7Of9Y0q4YjwQwxRm4zxwDVtvUIoy4RLVCpqp5UoE+Nxv2PY9trg==", "license": "MIT", + "peer": true, "dependencies": { "@tanstack/history": "1.141.0", "@tanstack/react-store": "^0.8.0", @@ -6010,6 +5991,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -6020,6 +6002,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -6125,6 +6108,7 @@ "integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.50.0", "@typescript-eslint/types": "8.50.0", @@ -6618,7 +6602,8 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@xyflow/react": { "version": "12.10.0", @@ -6716,6 +6701,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6776,6 +6762,7 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -7335,6 +7322,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -7866,8 +7854,7 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/cliui": { "version": "8.0.1", @@ -8153,8 +8140,7 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/cross-env": { "version": "10.1.0", @@ -8251,6 +8237,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -8552,6 +8539,7 @@ "integrity": "sha512-59CAAjAhTaIMCN8y9kD573vDkxbs1uhDcrFLHSgutYdPcGOU35Rf95725snvzEOy4BFB7+eLJ8djCNPmGwG67w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "26.0.12", "builder-util": "26.0.11", @@ -8878,7 +8866,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -8899,7 +8886,6 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -9150,6 +9136,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -11055,7 +11042,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11117,7 +11103,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -13536,7 +13521,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", @@ -13553,7 +13537,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -13571,7 +13554,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -13760,6 +13742,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -13769,6 +13752,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -14118,7 +14102,6 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -14307,6 +14290,7 @@ "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.4.0.tgz", "integrity": "sha512-BdrNXdzlofomLTiRnwJTSEAaGKyHHZkbMXIywOh7zlzp4uZnXErEwl9XZ+N1hJSNpeTtNxWvVwN0wUzAIQ4Hpg==", "license": "MIT", + "peer": true, "engines": { "node": ">=10" } @@ -14355,7 +14339,6 @@ "hasInstallScript": true, "license": "Apache-2.0", "optional": true, - "peer": true, "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", @@ -14406,7 +14389,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -14429,7 +14411,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -14452,7 +14433,6 @@ "os": [ "darwin" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -14469,7 +14449,6 @@ "os": [ "darwin" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -14486,7 +14465,6 @@ "os": [ "linux" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -14503,7 +14481,6 @@ "os": [ "linux" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -14520,7 +14497,6 @@ "os": [ "linux" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -14537,7 +14513,6 @@ "os": [ "linux" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -14554,7 +14529,6 @@ "os": [ "linux" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -14571,7 +14545,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -14594,7 +14567,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -14617,7 +14589,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -14640,7 +14611,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -14663,7 +14633,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -14686,7 +14655,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -15155,7 +15123,6 @@ "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", "license": "MIT", - "peer": true, "dependencies": { "client-only": "0.0.1" }, @@ -15325,7 +15292,6 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -15389,7 +15355,6 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -15487,6 +15452,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -15691,6 +15657,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16062,6 +16029,7 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -16151,7 +16119,8 @@ "resolved": "https://registry.npmjs.org/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz", "integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/vite/node_modules/fdir": { "version": "6.5.0", @@ -16177,6 +16146,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -16219,6 +16189,7 @@ "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.16", "@vitest/mocker": "4.0.16", @@ -16476,6 +16447,7 @@ "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "dev": true, "license": "ISC", + "peer": true, "bin": { "yaml": "bin.mjs" }, From 01e6b7fa527c36bb07b5350366a7f0ab66b98836 Mon Sep 17 00:00:00 2001 From: Stephan Rieche Date: Sat, 27 Dec 2025 13:13:17 +0100 Subject: [PATCH 04/73] chore: address code review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address suggestions from gemini-code-assist and coderabbit-ai: Logging Improvements: - Remove excessive debug logging from ClaudeProvider - Remove sensitive environment variable logging (API key length, HOME, USER) - Remove verbose per-message stream logging from AgentService - Remove redundant SDK options logging - Remove watchdog timer logging (diagnostic tool) Documentation: - Update JSDoc example in ClaudeMdSettings to include sandbox props Persistence Fix: - Add enableSandboxMode to syncSettingsToServer updates object - Ensures sandbox setting is properly persisted to server storage This reduces log volume significantly while maintaining important error and state transition logging. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- apps/server/src/providers/claude-provider.ts | 100 +----------------- apps/server/src/services/agent-service.ts | 17 --- .../claude/claude-md-settings.tsx | 7 +- apps/ui/src/hooks/use-settings-migration.ts | 1 + 4 files changed, 9 insertions(+), 116 deletions(-) diff --git a/apps/server/src/providers/claude-provider.ts b/apps/server/src/providers/claude-provider.ts index 919eeb30..716e94ca 100644 --- a/apps/server/src/providers/claude-provider.ts +++ b/apps/server/src/providers/claude-provider.ts @@ -23,8 +23,6 @@ export class ClaudeProvider extends BaseProvider { * Execute a query using Claude Agent SDK */ async *executeQuery(options: ExecuteOptions): AsyncGenerator { - console.log('[ClaudeProvider] executeQuery() called'); - const { prompt, model, @@ -37,20 +35,6 @@ export class ClaudeProvider extends BaseProvider { sdkSessionId, } = options; - console.log('[ClaudeProvider] Options:', { - model, - cwd, - maxTurns, - promptType: typeof prompt, - promptLength: typeof prompt === 'string' ? prompt.length : 'array', - hasSystemPrompt: !!systemPrompt, - systemPromptLength: systemPrompt?.length, - hasConversationHistory: !!conversationHistory?.length, - conversationHistoryLength: conversationHistory?.length || 0, - sdkSessionId, - allowedToolsCount: allowedTools?.length, - }); - // Build Claude SDK options const defaultTools = ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch']; const toolsToUse = allowedTools || defaultTools; @@ -73,15 +57,6 @@ export class ClaudeProvider extends BaseProvider { ...(options.sandbox && { sandbox: options.sandbox }), }; - console.log('[ClaudeProvider] SDK options prepared:', { - model: sdkOptions.model, - maxTurns: sdkOptions.maxTurns, - permissionMode: sdkOptions.permissionMode, - sandboxEnabled: sdkOptions.sandbox?.enabled || false, - hasResume: !!(sdkOptions as any).resume, - toolsCount: sdkOptions.allowedTools?.length, - }); - // Build prompt payload let promptPayload: string | AsyncIterable; @@ -106,81 +81,12 @@ export class ClaudeProvider extends BaseProvider { // Execute via Claude Agent SDK try { - console.log('[ClaudeProvider] ANTHROPIC_API_KEY exists:', !!process.env.ANTHROPIC_API_KEY); - console.log( - '[ClaudeProvider] ANTHROPIC_API_KEY length:', - process.env.ANTHROPIC_API_KEY?.length || 0 - ); - console.log('[ClaudeProvider] HOME directory:', process.env.HOME); - console.log('[ClaudeProvider] User:', process.env.USER); - console.log('[ClaudeProvider] Current working directory:', process.cwd()); - - // CRITICAL DEBUG: Log exact SDK options being passed - console.log('[ClaudeProvider] EXACT sdkOptions being passed to query():'); - console.log( - JSON.stringify( - { - model: sdkOptions.model, - maxTurns: sdkOptions.maxTurns, - cwd: sdkOptions.cwd, - allowedTools: sdkOptions.allowedTools, - permissionMode: sdkOptions.permissionMode, - hasSandbox: !!sdkOptions.sandbox, - hasAbortController: !!sdkOptions.abortController, - hasResume: !!(sdkOptions as any).resume, - hasSettingSources: !!sdkOptions.settingSources, - settingSources: sdkOptions.settingSources, - }, - null, - 2 - ) - ); - - console.log('[ClaudeProvider] Calling Claude Agent SDK query()...'); - console.log( - '[ClaudeProvider] About to call query() with prompt payload type:', - typeof promptPayload - ); - const stream = query({ prompt: promptPayload, options: sdkOptions }); - console.log('[ClaudeProvider] query() call returned, stream object type:', typeof stream); - console.log('[ClaudeProvider] SDK query() returned stream, starting iteration...'); - let streamMessageCount = 0; - - // Add a watchdog timer to detect if stream is hanging - let lastMessageTime = Date.now(); - const watchdogInterval = setInterval(() => { - const timeSinceLastMessage = Date.now() - lastMessageTime; - if (timeSinceLastMessage > 10000) { - console.log( - `[ClaudeProvider] WARNING: No messages received for ${Math.floor(timeSinceLastMessage / 1000)}s` - ); - } - }, 5000); - - try { - // Stream messages directly - they're already in the correct format - for await (const msg of stream) { - lastMessageTime = Date.now(); - streamMessageCount++; - console.log(`[ClaudeProvider] Stream message #${streamMessageCount}:`, { - type: msg.type, - subtype: (msg as any).subtype, - hasMessage: !!(msg as any).message, - hasResult: !!(msg as any).result, - session_id: msg.session_id, - }); - yield msg as ProviderMessage; - } - } finally { - clearInterval(watchdogInterval); + // Stream messages directly - they're already in the correct format + for await (const msg of stream) { + yield msg as ProviderMessage; } - - console.log( - '[ClaudeProvider] Stream iteration completed, total messages:', - streamMessageCount - ); } catch (error) { console.error('[ClaudeProvider] ERROR: executeQuery() error during execution:', error); console.error('[ClaudeProvider] ERROR stack:', (error as Error).stack); diff --git a/apps/server/src/services/agent-service.ts b/apps/server/src/services/agent-service.ts index c072803d..b76e9c76 100644 --- a/apps/server/src/services/agent-service.ts +++ b/apps/server/src/services/agent-service.ts @@ -300,7 +300,6 @@ export class AgentService { sdkSessionId: session.sdkSessionId, // Pass SDK session ID for resuming }; - console.log('[AgentService] Building prompt with images...'); // Build prompt content with images const { content: promptContent } = await buildPromptWithImages( message, @@ -321,23 +320,12 @@ export class AgentService { // Execute via provider const stream = provider.executeQuery(options); - console.log('[AgentService] Stream created, starting to iterate...'); let currentAssistantMessage: Message | null = null; let responseText = ''; const toolUses: Array<{ name: string; input: unknown }> = []; - let messageCount = 0; - console.log('[AgentService] Entering stream loop...'); for await (const msg of stream) { - messageCount++; - console.log(`[AgentService] Stream message #${messageCount}:`, { - type: msg.type, - subtype: (msg as any).subtype, - hasContent: !!(msg as any).message?.content, - session_id: msg.session_id, - }); - // Capture SDK session ID from any message and persist it if (msg.session_id && !session.sdkSessionId) { session.sdkSessionId = msg.session_id; @@ -347,7 +335,6 @@ export class AgentService { } if (msg.type === 'assistant') { - console.log('[AgentService] Processing assistant message...'); if (msg.message?.content) { for (const block of msg.message.content) { if (block.type === 'text') { @@ -391,7 +378,6 @@ export class AgentService { } } } else if (msg.type === 'result') { - console.log('[AgentService] Result message received, subtype:', (msg as any).subtype); if (msg.subtype === 'success' && msg.result) { if (currentAssistantMessage) { currentAssistantMessage.content = msg.result; @@ -409,8 +395,6 @@ export class AgentService { } } - console.log('[AgentService] Stream loop completed, total messages:', messageCount); - await this.saveSession(sessionId, session.messages); session.isRunning = false; @@ -825,7 +809,6 @@ export class AgentService { dataKeys: Object.keys(data), }); this.events.emit('agent:stream', { sessionId, ...data }); - console.log('[AgentService] Event emitted to EventEmitter'); } private getSystemPrompt(): string { diff --git a/apps/ui/src/components/views/settings-view/claude/claude-md-settings.tsx b/apps/ui/src/components/views/settings-view/claude/claude-md-settings.tsx index c2a6a3db..ae5a67e4 100644 --- a/apps/ui/src/components/views/settings-view/claude/claude-md-settings.tsx +++ b/apps/ui/src/components/views/settings-view/claude/claude-md-settings.tsx @@ -13,14 +13,17 @@ interface ClaudeMdSettingsProps { /** * ClaudeMdSettings Component * - * UI control for the autoLoadClaudeMd setting which enables automatic loading - * of project instructions from .claude/CLAUDE.md files via the Claude Agent SDK. + * UI controls for Claude Agent SDK settings including: + * - Auto-loading of project instructions from .claude/CLAUDE.md files + * - Sandbox mode for isolated bash command execution * * Usage: * ```tsx * * ``` */ diff --git a/apps/ui/src/hooks/use-settings-migration.ts b/apps/ui/src/hooks/use-settings-migration.ts index 2bca750b..1e989060 100644 --- a/apps/ui/src/hooks/use-settings-migration.ts +++ b/apps/ui/src/hooks/use-settings-migration.ts @@ -224,6 +224,7 @@ export async function syncSettingsToServer(): Promise { enhancementModel: state.enhancementModel, validationModel: state.validationModel, autoLoadClaudeMd: state.autoLoadClaudeMd, + enableSandboxMode: state.enableSandboxMode, keyboardShortcuts: state.keyboardShortcuts, aiProfiles: state.aiProfiles, projects: state.projects, From 23d6756f0370284377cda080036ed4dd456d9a9d Mon Sep 17 00:00:00 2001 From: Stephan Rieche Date: Sat, 27 Dec 2025 13:20:39 +0100 Subject: [PATCH 05/73] test: fix sandbox mode test assertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive test coverage for sandbox mode configuration: - Added tests for enableSandboxMode=false for both createChatOptions and createAutoModeOptions - Added tests for enableSandboxMode not provided for both functions - Updated existing tests to pass enableSandboxMode=true where sandbox assertions exist This addresses the broken test assertions identified by coderabbit-ai review. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- .../server/tests/unit/lib/sdk-options.test.ts | 46 ++++++++++++++++++- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/apps/server/tests/unit/lib/sdk-options.test.ts b/apps/server/tests/unit/lib/sdk-options.test.ts index c7324d6c..e5d4c7c0 100644 --- a/apps/server/tests/unit/lib/sdk-options.test.ts +++ b/apps/server/tests/unit/lib/sdk-options.test.ts @@ -179,7 +179,7 @@ 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' }); + const options = createChatOptions({ cwd: '/test/path', enableSandboxMode: true }); expect(options.cwd).toBe('/test/path'); expect(options.maxTurns).toBe(MAX_TURNS.standard); @@ -212,6 +212,27 @@ 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 not set sandbox when enableSandboxMode is not provided', async () => { + const { createChatOptions } = await import('@/lib/sdk-options.js'); + + const options = createChatOptions({ + cwd: '/test/path', + }); + + expect(options.sandbox).toBeUndefined(); + }); }); describe('createAutoModeOptions', () => { @@ -219,7 +240,7 @@ describe('sdk-options.ts', () => { const { createAutoModeOptions, TOOL_PRESETS, MAX_TURNS } = await import('@/lib/sdk-options.js'); - const options = createAutoModeOptions({ cwd: '/test/path' }); + const options = createAutoModeOptions({ cwd: '/test/path', enableSandboxMode: true }); expect(options.cwd).toBe('/test/path'); expect(options.maxTurns).toBe(MAX_TURNS.maximum); @@ -252,6 +273,27 @@ 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 not set sandbox when enableSandboxMode is not provided', async () => { + const { createAutoModeOptions } = await import('@/lib/sdk-options.js'); + + const options = createAutoModeOptions({ + cwd: '/test/path', + }); + + expect(options.sandbox).toBeUndefined(); + }); }); describe('createCustomOptions', () => { From 296ef20ef7f1cde627cdfbca3a900366a40f38dd Mon Sep 17 00:00:00 2001 From: Stephan Rieche Date: Sat, 27 Dec 2025 13:37:19 +0100 Subject: [PATCH 06/73] test: update claude-provider tests for sandbox changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updated tests to reflect changes made to sandbox mode implementation: 1. Changed permissionMode expectation from 'acceptEdits' to 'default' - ClaudeProvider now uses 'default' permission mode 2. Renamed test "should enable sandbox by default" to "should pass sandbox configuration when provided" - Sandbox is no longer enabled by default in the provider - Provider now forwards sandbox config only when explicitly provided via ExecuteOptions 3. Updated error handling test expectations - Now expects two console.error calls with new format - First call: '[ClaudeProvider] ERROR: executeQuery() error during execution:' - Second call: '[ClaudeProvider] ERROR stack:' with stack trace All 32 tests in claude-provider.test.ts now pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- .../unit/providers/claude-provider.test.ts | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/apps/server/tests/unit/providers/claude-provider.test.ts b/apps/server/tests/unit/providers/claude-provider.test.ts index 45deb0d1..b06642e9 100644 --- a/apps/server/tests/unit/providers/claude-provider.test.ts +++ b/apps/server/tests/unit/providers/claude-provider.test.ts @@ -73,7 +73,7 @@ describe('claude-provider.ts', () => { maxTurns: 10, cwd: '/test/dir', allowedTools: ['Read', 'Write'], - permissionMode: 'acceptEdits', + permissionMode: 'default', }), }); }); @@ -100,7 +100,7 @@ describe('claude-provider.ts', () => { }); }); - it('should enable sandbox by default', async () => { + it('should pass sandbox configuration when provided', async () => { vi.mocked(sdk.query).mockReturnValue( (async function* () { yield { type: 'text', text: 'test' }; @@ -110,6 +110,10 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: 'Test', cwd: '/test', + sandbox: { + enabled: true, + autoAllowBashIfSandboxed: true, + }, }); await collectAsyncGenerator(generator); @@ -242,11 +246,21 @@ describe('claude-provider.ts', () => { }); await expect(collectAsyncGenerator(generator)).rejects.toThrow('SDK execution failed'); - expect(consoleErrorSpy).toHaveBeenCalledWith( - '[ClaudeProvider] executeQuery() error during execution:', + + // Should log error message + expect(consoleErrorSpy).toHaveBeenNthCalledWith( + 1, + '[ClaudeProvider] ERROR: executeQuery() error during execution:', testError ); + // Should log stack trace + expect(consoleErrorSpy).toHaveBeenNthCalledWith( + 2, + '[ClaudeProvider] ERROR stack:', + testError.stack + ); + consoleErrorSpy.mockRestore(); }); }); From 71c17e1fbb093a6ebce32050fc8b2054537ab082 Mon Sep 17 00:00:00 2001 From: Stephan Rieche Date: Sat, 27 Dec 2025 13:45:34 +0100 Subject: [PATCH 07/73] chore: remove debug logging from agent-service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removed all debug console.log statements from agent-service.ts to avoid polluting production logs. This addresses code review feedback from gemini-code-assist. Removed debug logs for: - sendMessage() entry and session state - Event emissions (started, message, stream, complete) - Provider execution - SDK session ID capture - Tool use detection - Queue processing - emitAgentEvent() calls Kept console.error logs for actual errors (session not found, execution errors, etc.) as they are useful for troubleshooting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- apps/server/src/services/agent-service.ts | 44 ----------------------- 1 file changed, 44 deletions(-) diff --git a/apps/server/src/services/agent-service.ts b/apps/server/src/services/agent-service.ts index b76e9c76..dc6d4594 100644 --- a/apps/server/src/services/agent-service.ts +++ b/apps/server/src/services/agent-service.ts @@ -144,27 +144,12 @@ export class AgentService { imagePaths?: string[]; model?: string; }) { - console.log('[AgentService] sendMessage() called:', { - sessionId, - messageLength: message?.length, - workingDirectory, - imageCount: imagePaths?.length || 0, - model, - }); - const session = this.sessions.get(sessionId); if (!session) { console.error('[AgentService] ERROR: Session not found:', sessionId); throw new Error(`Session ${sessionId} not found`); } - console.log('[AgentService] Session found:', { - sessionId, - messageCount: session.messages.length, - isRunning: session.isRunning, - workingDirectory: session.workingDirectory, - }); - if (session.isRunning) { console.error('[AgentService] ERROR: Agent already running for session:', sessionId); throw new Error('Agent is already processing a message'); @@ -213,19 +198,16 @@ export class AgentService { session.abortController = new AbortController(); // Emit started event so UI can show thinking indicator - console.log('[AgentService] Emitting "started" event for session:', sessionId); this.emitAgentEvent(sessionId, { type: 'started', }); // Emit user message event - console.log('[AgentService] Emitting "message" event for session:', sessionId); this.emitAgentEvent(sessionId, { type: 'message', message: userMessage, }); - console.log('[AgentService] Saving session messages'); await this.saveSession(sessionId, session.messages); try { @@ -278,13 +260,8 @@ export class AgentService { const allowedTools = sdkOptions.allowedTools as string[] | undefined; // Get provider for this model - console.log('[AgentService] Getting provider for model:', effectiveModel); const provider = ProviderFactory.getProviderForModel(effectiveModel); - console.log( - `[AgentService] Using provider "${provider.getName()}" for model "${effectiveModel}"` - ); - // Build options for provider const options: ExecuteOptions = { prompt: '', // Will be set below based on images @@ -311,13 +288,6 @@ export class AgentService { // Set the prompt in options options.prompt = promptContent; - console.log('[AgentService] Executing query via provider:', { - model: effectiveModel, - promptLength: typeof promptContent === 'string' ? promptContent.length : 'array', - hasConversationHistory: !!conversationHistory.length, - sdkSessionId: session.sdkSessionId, - }); - // Execute via provider const stream = provider.executeQuery(options); @@ -329,7 +299,6 @@ export class AgentService { // Capture SDK session ID from any message and persist it if (msg.session_id && !session.sdkSessionId) { session.sdkSessionId = msg.session_id; - console.log(`[AgentService] Captured SDK session ID: ${msg.session_id}`); // Persist the SDK session ID to ensure conversation continuity across server restarts await this.updateSession(sessionId, { sdkSessionId: msg.session_id }); } @@ -352,10 +321,6 @@ export class AgentService { currentAssistantMessage.content = responseText; } - console.log( - '[AgentService] Emitting "stream" event, text length:', - responseText.length - ); this.emitAgentEvent(sessionId, { type: 'stream', messageId: currentAssistantMessage.id, @@ -369,7 +334,6 @@ export class AgentService { }; toolUses.push(toolUse); - console.log('[AgentService] Tool use detected:', toolUse.name); this.emitAgentEvent(sessionId, { type: 'tool_use', tool: toolUse, @@ -385,7 +349,6 @@ export class AgentService { } } - console.log('[AgentService] Emitting "complete" event'); this.emitAgentEvent(sessionId, { type: 'complete', messageId: currentAssistantMessage?.id, @@ -783,8 +746,6 @@ export class AgentService { queue: session.promptQueue, }); - console.log(`[AgentService] Processing next queued prompt for session ${sessionId}`); - try { await this.sendMessage({ sessionId, @@ -803,11 +764,6 @@ export class AgentService { } private emitAgentEvent(sessionId: string, data: Record): void { - console.log('[AgentService] emitAgentEvent() called:', { - sessionId, - eventType: data.type, - dataKeys: Object.keys(data), - }); this.events.emit('agent:stream', { sessionId, ...data }); } From b65fccbcf72a5fdd61010ec39244a0b37cd61519 Mon Sep 17 00:00:00 2001 From: Tony Nekola Date: Sat, 27 Dec 2025 13:08:47 +0200 Subject: [PATCH 08/73] fix: add retry mechanisms to context test helpers for flaky test stability Update waitForContextFile, selectContextFile, and waitForFileContentToLoad helpers to use Playwright's expect().toPass() with retry intervals, handling race conditions between API calls completing and UI re-rendering. Also add waitForNetworkIdle after dialog closes in context-file-management test. --- .../context/context-file-management.spec.ts | 3 +- .../tests/context/delete-context-file.spec.ts | 2 +- apps/ui/tests/utils/views/context.ts | 44 ++++++++++--------- 3 files changed, 27 insertions(+), 22 deletions(-) diff --git a/apps/ui/tests/context/context-file-management.spec.ts b/apps/ui/tests/context/context-file-management.spec.ts index 220b345d..cef8a212 100644 --- a/apps/ui/tests/context/context-file-management.spec.ts +++ b/apps/ui/tests/context/context-file-management.spec.ts @@ -50,7 +50,8 @@ test.describe('Context File Management', () => { { timeout: 5000 } ); - await waitForContextFile(page, 'test-context.md', 10000); + await waitForNetworkIdle(page); + await waitForContextFile(page, 'test-context.md'); const fileButton = await getByTestId(page, 'context-file-test-context.md'); await expect(fileButton).toBeVisible(); diff --git a/apps/ui/tests/context/delete-context-file.spec.ts b/apps/ui/tests/context/delete-context-file.spec.ts index 0dc0ade5..4c4d8a53 100644 --- a/apps/ui/tests/context/delete-context-file.spec.ts +++ b/apps/ui/tests/context/delete-context-file.spec.ts @@ -53,7 +53,7 @@ test.describe('Delete Context File', () => { ); // Wait for the file to appear in the list - await waitForContextFile(page, fileName, 10000); + await waitForContextFile(page, fileName); // Select the file await selectContextFile(page, fileName); diff --git a/apps/ui/tests/utils/views/context.ts b/apps/ui/tests/utils/views/context.ts index 6254ebeb..6032e947 100644 --- a/apps/ui/tests/utils/views/context.ts +++ b/apps/ui/tests/utils/views/context.ts @@ -114,47 +114,51 @@ export async function toggleContextPreviewMode(page: Page): Promise { /** * Wait for a specific file to appear in the context file list + * Uses retry mechanism to handle race conditions with API/UI updates */ export async function waitForContextFile( page: Page, filename: string, - timeout: number = 10000 + timeout: number = 15000 ): Promise { - const locator = page.locator(`[data-testid="context-file-${filename}"]`); - await locator.waitFor({ state: 'visible', timeout }); + await expect(async () => { + const locator = page.locator(`[data-testid="context-file-${filename}"]`); + await expect(locator).toBeVisible(); + }).toPass({ timeout, intervals: [500, 1000, 2000] }); } /** * Click a file in the list and wait for it to be selected (toolbar visible) - * Uses JavaScript click to ensure React event handler fires + * Uses retry mechanism to handle race conditions where element is visible but not yet interactive */ export async function selectContextFile( page: Page, filename: string, - timeout: number = 10000 + timeout: number = 15000 ): Promise { const fileButton = await getByTestId(page, `context-file-${filename}`); - await fileButton.waitFor({ state: 'visible', timeout }); - // Use JavaScript click to ensure React onClick handler fires - await fileButton.evaluate((el) => (el as HTMLButtonElement).click()); - - // Wait for the file to be selected (toolbar with delete button becomes visible) - const deleteButton = await getByTestId(page, 'delete-context-file'); - await expect(deleteButton).toBeVisible({ - timeout, - }); + // Retry click + wait for delete button to handle timing issues + await expect(async () => { + // Use JavaScript click to ensure React onClick handler fires + await fileButton.evaluate((el) => (el as HTMLButtonElement).click()); + // Wait for the file to be selected (toolbar with delete button becomes visible) + const deleteButton = await getByTestId(page, 'delete-context-file'); + await expect(deleteButton).toBeVisible(); + }).toPass({ timeout, intervals: [500, 1000, 2000] }); } /** * Wait for file content panel to load (either editor, preview, or image) + * Uses retry mechanism to handle race conditions with file selection */ -export async function waitForFileContentToLoad(page: Page): Promise { - // Wait for either the editor, preview, or image to appear - await page.waitForSelector( - '[data-testid="context-editor"], [data-testid="markdown-preview"], [data-testid="image-preview"]', - { timeout: 10000 } - ); +export async function waitForFileContentToLoad(page: Page, timeout: number = 15000): Promise { + await expect(async () => { + const contentLocator = page.locator( + '[data-testid="context-editor"], [data-testid="markdown-preview"], [data-testid="image-preview"]' + ); + await expect(contentLocator).toBeVisible(); + }).toPass({ timeout, intervals: [500, 1000, 2000] }); } /** From 7b7de2b601b14d09e8e7aa25b3ee1183105c380c Mon Sep 17 00:00:00 2001 From: Test User Date: Sat, 27 Dec 2025 13:55:56 -0500 Subject: [PATCH 09/73] adding button to make when creating a new feature --- apps/ui/src/components/views/board-view.tsx | 24 +++++ .../board-view/dialogs/add-feature-dialog.tsx | 87 ++++++++++++++----- 2 files changed, 88 insertions(+), 23 deletions(-) diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index cfa063fd..01569cfe 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -523,6 +523,29 @@ export function BoardView() { [handleAddFeature, handleStartImplementation, defaultSkipTests] ); + // Handler for "Make" button - creates a feature and immediately starts it + const handleAddAndStartFeature = useCallback( + async (featureData: Parameters[0]) => { + await handleAddFeature(featureData); + + // Find the newly created feature and start it + setTimeout(async () => { + const latestFeatures = useAppStore.getState().features; + const newFeature = latestFeatures.find( + (f) => + f.status === 'backlog' && + f.description === featureData.description && + f.branchName === featureData.branchName + ); + + if (newFeature) { + await handleStartImplementation(newFeature); + } + }, FEATURE_CREATION_SETTLE_DELAY_MS); + }, + [handleAddFeature, handleStartImplementation] + ); + // Client-side auto mode: periodically check for backlog items and move them to in-progress // Use a ref to track the latest auto mode state so async operations always check the current value const autoModeRunningRef = useRef(autoMode.isRunning); @@ -1137,6 +1160,7 @@ export function BoardView() { } }} onAdd={handleAddFeature} + onAddAndStart={handleAddAndStartFeature} categorySuggestions={categorySuggestions} branchSuggestions={branchSuggestions} branchCardCounts={branchCardCounts} diff --git a/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx index a5eea2c5..47990a4e 100644 --- a/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx @@ -19,7 +19,14 @@ import { FeatureTextFilePath as DescriptionTextFilePath, ImagePreviewMap, } from '@/components/ui/description-image-dropzone'; -import { MessageSquare, Settings2, SlidersHorizontal, Sparkles, ChevronDown } from 'lucide-react'; +import { + MessageSquare, + Settings2, + SlidersHorizontal, + Sparkles, + ChevronDown, + Play, +} from 'lucide-react'; import { toast } from 'sonner'; import { getElectronAPI } from '@/lib/electron'; import { modelSupportsThinking } from '@/lib/utils'; @@ -55,25 +62,28 @@ import { type AncestorContext, } from '@automaker/dependency-resolver'; +type FeatureData = { + title: string; + category: string; + description: string; + images: FeatureImage[]; + imagePaths: DescriptionImagePath[]; + textFilePaths: DescriptionTextFilePath[]; + skipTests: boolean; + model: AgentModel; + thinkingLevel: ThinkingLevel; + branchName: string; // Can be empty string to use current branch + priority: number; + planningMode: PlanningMode; + requirePlanApproval: boolean; + dependencies?: string[]; +}; + interface AddFeatureDialogProps { open: boolean; onOpenChange: (open: boolean) => void; - onAdd: (feature: { - title: string; - category: string; - description: string; - images: FeatureImage[]; - imagePaths: DescriptionImagePath[]; - textFilePaths: DescriptionTextFilePath[]; - skipTests: boolean; - model: AgentModel; - thinkingLevel: ThinkingLevel; - branchName: string; // Can be empty string to use current branch - priority: number; - planningMode: PlanningMode; - requirePlanApproval: boolean; - dependencies?: string[]; - }) => void; + onAdd: (feature: FeatureData) => void; + onAddAndStart?: (feature: FeatureData) => void; categorySuggestions: string[]; branchSuggestions: string[]; branchCardCounts?: Record; // Map of branch name to unarchived card count @@ -92,6 +102,7 @@ export function AddFeatureDialog({ open, onOpenChange, onAdd, + onAddAndStart, categorySuggestions, branchSuggestions, branchCardCounts, @@ -188,16 +199,16 @@ export function AddFeatureDialog({ allFeatures, ]); - const handleAdd = () => { + const buildFeatureData = (): FeatureData | null => { if (!newFeature.description.trim()) { setDescriptionError(true); - return; + return null; } // Validate branch selection when "other branch" is selected if (useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()) { toast.error('Please select a branch name'); - return; + return null; } const category = newFeature.category || 'Uncategorized'; @@ -235,7 +246,7 @@ export function AddFeatureDialog({ } } - onAdd({ + return { title: newFeature.title, category, description: finalDescription, @@ -251,9 +262,10 @@ export function AddFeatureDialog({ requirePlanApproval, // In spawn mode, automatically add parent as dependency dependencies: isSpawnMode && parentFeature ? [parentFeature.id] : undefined, - }); + }; + }; - // Reset form + const resetForm = () => { setNewFeature({ title: '', category: '', @@ -276,6 +288,24 @@ export function AddFeatureDialog({ onOpenChange(false); }; + const handleAdd = () => { + const featureData = buildFeatureData(); + if (!featureData) return; + + onAdd(featureData); + resetForm(); + }; + + const handleAddAndStart = () => { + if (!onAddAndStart) return; + + const featureData = buildFeatureData(); + if (!featureData) return; + + onAddAndStart(featureData); + resetForm(); + }; + const handleDialogClose = (open: boolean) => { onOpenChange(open); if (!open) { @@ -575,6 +605,17 @@ export function AddFeatureDialog({ + {onAddAndStart && ( + + )} Date: Sat, 27 Dec 2025 14:20:52 -0500 Subject: [PATCH 10/73] refactor: streamline feature creation and auto-start logic in BoardView - Removed the delay mechanism for starting newly created features, simplifying the process. - Updated the logic to capture existing feature IDs before adding a new feature, allowing for immediate identification of the newly created feature. - Enhanced error handling to notify users if the feature could not be started automatically. --- apps/ui/src/components/views/board-view.tsx | 82 +++++++++---------- .../board-view/dialogs/add-feature-dialog.tsx | 18 ++-- 2 files changed, 46 insertions(+), 54 deletions(-) diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index 01569cfe..0541de9f 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -60,9 +60,6 @@ import { // Stable empty array to avoid infinite loop in selector const EMPTY_WORKTREES: ReturnType['getWorktrees']> = []; -/** Delay before starting a newly created feature to allow state to settle */ -const FEATURE_CREATION_SETTLE_DELAY_MS = 500; - export function BoardView() { const { currentProject, @@ -461,23 +458,22 @@ export function BoardView() { requirePlanApproval: false, }; + // Capture existing feature IDs before adding + const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id)); await handleAddFeature(featureData); - // Find the newly created feature and start it - // We need to wait a moment for the feature to be created - setTimeout(async () => { - const latestFeatures = useAppStore.getState().features; - const newFeature = latestFeatures.find( - (f) => - f.branchName === worktree.branch && - f.status === 'backlog' && - f.description.includes(`PR #${prNumber}`) - ); + // Find the newly created feature by looking for an ID that wasn't in the original set + const latestFeatures = useAppStore.getState().features; + const newFeature = latestFeatures.find((f) => !featuresBeforeIds.has(f.id)); - if (newFeature) { - await handleStartImplementation(newFeature); - } - }, FEATURE_CREATION_SETTLE_DELAY_MS); + if (newFeature) { + await handleStartImplementation(newFeature); + } else { + console.error('Could not find newly created feature to start it automatically.'); + toast.error('Failed to auto-start feature', { + description: 'The feature was created but could not be started automatically.', + }); + } }, [handleAddFeature, handleStartImplementation, defaultSkipTests] ); @@ -503,22 +499,22 @@ export function BoardView() { requirePlanApproval: false, }; + // Capture existing feature IDs before adding + const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id)); await handleAddFeature(featureData); - // Find the newly created feature and start it - setTimeout(async () => { - const latestFeatures = useAppStore.getState().features; - const newFeature = latestFeatures.find( - (f) => - f.branchName === worktree.branch && - f.status === 'backlog' && - f.description.includes('Pull latest from origin/main') - ); + // Find the newly created feature by looking for an ID that wasn't in the original set + const latestFeatures = useAppStore.getState().features; + const newFeature = latestFeatures.find((f) => !featuresBeforeIds.has(f.id)); - if (newFeature) { - await handleStartImplementation(newFeature); - } - }, FEATURE_CREATION_SETTLE_DELAY_MS); + if (newFeature) { + await handleStartImplementation(newFeature); + } else { + console.error('Could not find newly created feature to start it automatically.'); + toast.error('Failed to auto-start feature', { + description: 'The feature was created but could not be started automatically.', + }); + } }, [handleAddFeature, handleStartImplementation, defaultSkipTests] ); @@ -526,22 +522,22 @@ export function BoardView() { // Handler for "Make" button - creates a feature and immediately starts it const handleAddAndStartFeature = useCallback( async (featureData: Parameters[0]) => { + // Capture existing feature IDs before adding + const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id)); await handleAddFeature(featureData); - // Find the newly created feature and start it - setTimeout(async () => { - const latestFeatures = useAppStore.getState().features; - const newFeature = latestFeatures.find( - (f) => - f.status === 'backlog' && - f.description === featureData.description && - f.branchName === featureData.branchName - ); + // Find the newly created feature by looking for an ID that wasn't in the original set + const latestFeatures = useAppStore.getState().features; + const newFeature = latestFeatures.find((f) => !featuresBeforeIds.has(f.id)); - if (newFeature) { - await handleStartImplementation(newFeature); - } - }, FEATURE_CREATION_SETTLE_DELAY_MS); + if (newFeature) { + await handleStartImplementation(newFeature); + } else { + console.error('Could not find newly created feature to start it automatically.'); + toast.error('Failed to auto-start feature', { + description: 'The feature was created but could not be started automatically.', + }); + } }, [handleAddFeature, handleStartImplementation] ); diff --git a/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx index 47990a4e..35a95e91 100644 --- a/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx @@ -288,24 +288,20 @@ export function AddFeatureDialog({ onOpenChange(false); }; - const handleAdd = () => { - const featureData = buildFeatureData(); - if (!featureData) return; - - onAdd(featureData); - resetForm(); - }; - - const handleAddAndStart = () => { - if (!onAddAndStart) return; + const handleAction = (actionFn?: (data: FeatureData) => void) => { + if (!actionFn) return; const featureData = buildFeatureData(); if (!featureData) return; - onAddAndStart(featureData); + actionFn(featureData); resetForm(); }; + const handleAdd = () => handleAction(onAdd); + + const handleAddAndStart = () => handleAction(onAddAndStart); + const handleDialogClose = (open: boolean) => { onOpenChange(open); if (!open) { From 5f328a4c13f78be1e8073058cd2903f3939c2535 Mon Sep 17 00:00:00 2001 From: M Zubair Date: Sun, 28 Dec 2025 00:51:50 +0100 Subject: [PATCH 11/73] feat: add MCP server support for AI agents Add Model Context Protocol (MCP) server integration to extend AI agent capabilities with external tools. This allows users to configure MCP servers (stdio, SSE, HTTP) in global settings and have agents use them. Note: MCP servers are currently configured globally. Per-project MCP server configuration is planned for a future update. Features: - New MCP Servers settings section with full CRUD operations - Import/Export JSON configs (Claude Code format compatible) - Configurable permission settings: - Auto-approve MCP tools (bypass permission prompts) - Unrestricted tools (allow all tools when MCP enabled) - Refresh button to reload from settings file Implementation: - Added MCPServerConfig and MCPToolInfo types - Added store actions for MCP server management - Updated claude-provider to use configurable MCP permissions - Updated sdk-options factory functions for MCP support - Added settings helpers for loading MCP configs --- apps/server/package.json | 1 + apps/server/src/lib/sdk-options.ts | 99 ++- apps/server/src/lib/settings-helpers.ts | 118 ++++ apps/server/src/providers/claude-provider.ts | 24 +- apps/server/src/providers/types.ts | 46 +- apps/server/src/services/agent-service.ts | 14 + apps/server/src/services/auto-mode-service.ts | 23 + apps/ui/src/components/views/context-view.tsx | 7 + .../ui/src/components/views/settings-view.tsx | 3 + .../views/settings-view/config/navigation.ts | 2 + .../settings-view/hooks/use-settings-view.ts | 1 + .../views/settings-view/mcp-servers/index.ts | 1 + .../mcp-servers/mcp-servers-section.tsx | 647 ++++++++++++++++++ apps/ui/src/hooks/use-settings-migration.ts | 43 ++ apps/ui/src/lib/http-api-client.ts | 14 + apps/ui/src/store/app-store.ts | 56 ++ libs/types/src/index.ts | 6 + libs/types/src/provider.ts | 36 +- libs/types/src/settings.ts | 58 ++ package-lock.json | 219 +++++- 20 files changed, 1375 insertions(+), 43 deletions(-) create mode 100644 apps/ui/src/components/views/settings-view/mcp-servers/index.ts create mode 100644 apps/ui/src/components/views/settings-view/mcp-servers/mcp-servers-section.tsx diff --git a/apps/server/package.json b/apps/server/package.json index 081c7f23..9f27c2a3 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -28,6 +28,7 @@ "@automaker/prompts": "^1.0.0", "@automaker/types": "^1.0.0", "@automaker/utils": "^1.0.0", + "@modelcontextprotocol/sdk": "^1.25.1", "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^5.2.1", diff --git a/apps/server/src/lib/sdk-options.ts b/apps/server/src/lib/sdk-options.ts index 80433f5b..269f78ac 100644 --- a/apps/server/src/lib/sdk-options.ts +++ b/apps/server/src/lib/sdk-options.ts @@ -18,7 +18,7 @@ import type { Options } from '@anthropic-ai/claude-agent-sdk'; import path from 'path'; import { resolveModelString } from '@automaker/model-resolver'; -import { DEFAULT_MODELS, CLAUDE_MODEL_MAP } from '@automaker/types'; +import { DEFAULT_MODELS, CLAUDE_MODEL_MAP, type McpServerConfig } from '@automaker/types'; import { isPathAllowed, PathNotAllowedError, getAllowedRootDirectory } from '@automaker/platform'; /** @@ -136,6 +136,53 @@ function getBaseOptions(): Partial { }; } +/** + * MCP permission options result + */ +interface McpPermissionOptions { + /** Whether tools should be restricted to a preset */ + shouldRestrictTools: boolean; + /** Options to spread when MCP bypass is enabled */ + bypassOptions: Partial; + /** 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 + */ +function buildMcpOptions(config: CreateSdkOptionsConfig): McpPermissionOptions { + const hasMcpServers = config.mcpServers && Object.keys(config.mcpServers).length > 0; + // Default to true - this is a deliberate design choice for ease of use with MCP servers. + // Users can disable these in settings for stricter security. + 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; + + 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 } : {}, + }; +} + /** * Build system prompt configuration based on autoLoadClaudeMd setting. * When autoLoadClaudeMd is true: @@ -219,8 +266,25 @@ export interface CreateSdkOptionsConfig { /** 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; } +// Re-export MCP types from @automaker/types for convenience +export type { + McpServerConfig, + McpStdioServerConfig, + McpSSEServerConfig, + McpHttpServerConfig, +} from '@automaker/types'; + /** * Create SDK options for spec generation * @@ -330,12 +394,18 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options { // Build CLAUDE.md auto-loading options if enabled const claudeMdOptions = buildClaudeMdOptions(config); + // Build MCP-related options + const mcpOptions = buildMcpOptions(config); + return { ...getBaseOptions(), model: getModelForUseCase('chat', effectiveModel), maxTurns: MAX_TURNS.standard, cwd: config.cwd, - allowedTools: [...TOOL_PRESETS.chat], + // 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, ...(config.enableSandboxMode && { sandbox: { enabled: true, @@ -344,6 +414,7 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options { }), ...claudeMdOptions, ...(config.abortController && { abortController: config.abortController }), + ...mcpOptions.mcpServerOptions, }; } @@ -364,12 +435,18 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options { // Build CLAUDE.md auto-loading options if enabled const claudeMdOptions = buildClaudeMdOptions(config); + // Build MCP-related options + const mcpOptions = buildMcpOptions(config); + return { ...getBaseOptions(), model: getModelForUseCase('auto', config.model), maxTurns: MAX_TURNS.maximum, cwd: config.cwd, - allowedTools: [...TOOL_PRESETS.fullAccess], + // 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, ...(config.enableSandboxMode && { sandbox: { enabled: true, @@ -378,6 +455,7 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options { }), ...claudeMdOptions, ...(config.abortController && { abortController: config.abortController }), + ...mcpOptions.mcpServerOptions, }; } @@ -400,14 +478,27 @@ export function createCustomOptions( // Build CLAUDE.md auto-loading options if enabled const claudeMdOptions = buildClaudeMdOptions(config); + // Build MCP-related options + const mcpOptions = buildMcpOptions(config); + + // For custom options: use explicit allowedTools if provided, otherwise use preset based on MCP settings + const effectiveAllowedTools = config.allowedTools + ? [...config.allowedTools] + : mcpOptions.shouldRestrictTools + ? [...TOOL_PRESETS.readOnly] + : undefined; + return { ...getBaseOptions(), model: getModelForUseCase('default', config.model), maxTurns: config.maxTurns ?? MAX_TURNS.maximum, cwd: config.cwd, - allowedTools: config.allowedTools ? [...config.allowedTools] : [...TOOL_PRESETS.readOnly], + ...(effectiveAllowedTools && { allowedTools: effectiveAllowedTools }), ...(config.sandbox && { sandbox: config.sandbox }), + // Apply MCP bypass options if configured + ...mcpOptions.bypassOptions, ...claudeMdOptions, ...(config.abortController && { abortController: config.abortController }), + ...mcpOptions.mcpServerOptions, }; } diff --git a/apps/server/src/lib/settings-helpers.ts b/apps/server/src/lib/settings-helpers.ts index dc057873..21211b33 100644 --- a/apps/server/src/lib/settings-helpers.ts +++ b/apps/server/src/lib/settings-helpers.ts @@ -4,6 +4,7 @@ import type { SettingsService } from '../services/settings-service.js'; import type { ContextFilesResult, ContextFileInfo } from '@automaker/utils'; +import type { MCPServerConfig, McpServerConfig } from '@automaker/types'; /** * Get the autoLoadClaudeMd setting, with project settings taking precedence over global. @@ -136,3 +137,120 @@ function formatContextFileEntry(file: ContextFileInfo): string { const descriptionInfo = file.description ? `\n**Purpose:** ${file.description}` : ''; return `${header}\n${pathInfo}${descriptionInfo}\n\n${file.content}`; } + +/** + * Get enabled MCP servers from global settings, converted to SDK format. + * Returns an empty object if settings service is not available or no servers are configured. + * + * @param settingsService - Optional settings service instance + * @param logPrefix - Prefix for log messages (e.g., '[AgentService]') + * @returns Promise resolving to MCP servers in SDK format (keyed by name) + */ +export async function getMCPServersFromSettings( + settingsService?: SettingsService | null, + logPrefix = '[SettingsHelper]' +): Promise> { + if (!settingsService) { + return {}; + } + + try { + const globalSettings = await settingsService.getGlobalSettings(); + const mcpServers = globalSettings.mcpServers || []; + + // Filter to only enabled servers and convert to SDK format + const enabledServers = mcpServers.filter((s) => s.enabled !== false); + + if (enabledServers.length === 0) { + return {}; + } + + // Convert settings format to SDK format (keyed by name) + const sdkServers: Record = {}; + for (const server of enabledServers) { + sdkServers[server.name] = convertToSdkFormat(server); + } + + console.log( + `${logPrefix} Loaded ${enabledServers.length} MCP server(s): ${enabledServers.map((s) => s.name).join(', ')}` + ); + + return sdkServers; + } catch (error) { + console.error(`${logPrefix} Failed to load MCP servers setting:`, error); + return {}; + } +} + +/** + * Get MCP permission settings from global settings. + * + * @param settingsService - Optional settings service instance + * @param logPrefix - Prefix for log messages (e.g., '[AgentService]') + * @returns Promise resolving to MCP permission settings + */ +export async function getMCPPermissionSettings( + settingsService?: SettingsService | null, + logPrefix = '[SettingsHelper]' +): Promise<{ mcpAutoApproveTools: boolean; mcpUnrestrictedTools: boolean }> { + // Default values (both enabled for backwards compatibility) + const defaults = { mcpAutoApproveTools: true, mcpUnrestrictedTools: true }; + + if (!settingsService) { + return defaults; + } + + try { + const globalSettings = await settingsService.getGlobalSettings(); + const result = { + mcpAutoApproveTools: globalSettings.mcpAutoApproveTools ?? true, + mcpUnrestrictedTools: globalSettings.mcpUnrestrictedTools ?? true, + }; + console.log( + `${logPrefix} MCP permission settings: autoApprove=${result.mcpAutoApproveTools}, unrestricted=${result.mcpUnrestrictedTools}` + ); + return result; + } catch (error) { + console.error(`${logPrefix} Failed to load MCP permission settings:`, error); + return defaults; + } +} + +/** + * Convert a settings MCPServerConfig to SDK McpServerConfig format. + * Validates required fields and throws informative errors if missing. + */ +function convertToSdkFormat(server: MCPServerConfig): McpServerConfig { + if (server.type === 'sse') { + if (!server.url) { + throw new Error(`SSE MCP server "${server.name}" is missing a URL.`); + } + return { + type: 'sse', + url: server.url, + headers: server.headers, + }; + } + + if (server.type === 'http') { + if (!server.url) { + throw new Error(`HTTP MCP server "${server.name}" is missing a URL.`); + } + return { + type: 'http', + url: server.url, + headers: server.headers, + }; + } + + // Default to stdio + if (!server.command) { + throw new Error(`Stdio MCP server "${server.name}" is missing a command.`); + } + return { + type: 'stdio', + command: server.command, + args: server.args, + env: server.env, + }; +} diff --git a/apps/server/src/providers/claude-provider.ts b/apps/server/src/providers/claude-provider.ts index 716e94ca..3c765304 100644 --- a/apps/server/src/providers/claude-provider.ts +++ b/apps/server/src/providers/claude-provider.ts @@ -36,16 +36,32 @@ export class ClaudeProvider extends BaseProvider { } = options; // Build Claude SDK options + // MCP permission logic - determines how to handle tool permissions when MCP servers are configured. + // This logic mirrors buildMcpOptions() in sdk-options.ts but is applied here since + // the provider is the final point where SDK options are constructed. + const hasMcpServers = options.mcpServers && Object.keys(options.mcpServers).length > 0; + // Default to true - deliberate design choice for ease of use. Users can disable in settings. + const mcpAutoApprove = options.mcpAutoApproveTools ?? true; + const mcpUnrestricted = options.mcpUnrestrictedTools ?? true; const defaultTools = ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch']; - const toolsToUse = allowedTools || defaultTools; + + // Determine permission mode 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; const sdkOptions: Options = { model, systemPrompt, maxTurns, cwd, - allowedTools: toolsToUse, - permissionMode: 'default', + // Only restrict tools if explicitly set OR (no MCP / unrestricted disabled) + ...(allowedTools && shouldRestrictTools && { allowedTools }), + ...(!allowedTools && shouldRestrictTools && { allowedTools: defaultTools }), + // When MCP servers are configured and auto-approve is enabled, use bypassPermissions + permissionMode: shouldBypassPermissions ? 'bypassPermissions' : 'default', + // Required when using bypassPermissions mode + ...(shouldBypassPermissions && { allowDangerouslySkipPermissions: true }), abortController, // Resume existing SDK session if we have a session ID ...(sdkSessionId && conversationHistory && conversationHistory.length > 0 @@ -55,6 +71,8 @@ export class ClaudeProvider extends BaseProvider { ...(options.settingSources && { settingSources: options.settingSources }), // Forward sandbox configuration ...(options.sandbox && { sandbox: options.sandbox }), + // Forward MCP servers configuration + ...(options.mcpServers && { mcpServers: options.mcpServers }), }; // Build prompt payload diff --git a/apps/server/src/providers/types.ts b/apps/server/src/providers/types.ts index 17b45066..a3dcf58c 100644 --- a/apps/server/src/providers/types.ts +++ b/apps/server/src/providers/types.ts @@ -1,41 +1,19 @@ /** * Shared types for AI model providers + * + * Re-exports types from @automaker/types for consistency across the codebase. */ -/** - * Configuration for a provider instance - */ -export interface ProviderConfig { - apiKey?: string; - cliPath?: string; - env?: Record; -} - -/** - * Message in conversation history - */ -export interface ConversationMessage { - role: 'user' | 'assistant'; - content: string | Array<{ type: string; text?: string; source?: object }>; -} - -/** - * Options for executing a query via a provider - */ -export interface ExecuteOptions { - prompt: string | Array<{ type: string; text?: string; source?: object }>; - model: string; - cwd: string; - systemPrompt?: string | { type: 'preset'; preset: 'claude_code'; append?: string }; - maxTurns?: number; - allowedTools?: string[]; - mcpServers?: Record; - abortController?: AbortController; - conversationHistory?: ConversationMessage[]; // Previous messages for context - sdkSessionId?: string; // Claude SDK session ID for resuming conversations - settingSources?: Array<'user' | 'project' | 'local'>; // Claude filesystem settings to load - sandbox?: { enabled: boolean; autoAllowBashIfSandboxed?: boolean }; // Sandbox configuration -} +// Re-export all provider types from @automaker/types +export type { + ProviderConfig, + ConversationMessage, + ExecuteOptions, + McpServerConfig, + McpStdioServerConfig, + McpSSEServerConfig, + McpHttpServerConfig, +} from '@automaker/types'; /** * Content block in a provider message (matches Claude SDK format) diff --git a/apps/server/src/services/agent-service.ts b/apps/server/src/services/agent-service.ts index dc6d4594..63c220db 100644 --- a/apps/server/src/services/agent-service.ts +++ b/apps/server/src/services/agent-service.ts @@ -21,6 +21,8 @@ import { getAutoLoadClaudeMdSetting, getEnableSandboxModeSetting, filterClaudeMdFromContext, + getMCPServersFromSettings, + getMCPPermissionSettings, } from '../lib/settings-helpers.js'; interface Message { @@ -227,6 +229,12 @@ export class AgentService { '[AgentService]' ); + // Load MCP servers from settings (global setting only) + const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AgentService]'); + + // Load MCP permission settings (global setting only) + const mcpPermissions = await getMCPPermissionSettings(this.settingsService, '[AgentService]'); + // Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) const contextResult = await loadContextFiles({ projectPath: effectiveWorkDir, @@ -252,6 +260,9 @@ export class AgentService { abortController: session.abortController!, autoLoadClaudeMd, enableSandboxMode, + mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, + mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools, + mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools, }); // Extract model, maxTurns, and allowedTools from SDK options @@ -275,6 +286,9 @@ export class AgentService { 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 + mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools, // Pass MCP auto-approve setting + mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools, // Pass MCP unrestricted tools setting }; // Build prompt content with images diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 987554f2..da9afcd1 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -36,6 +36,8 @@ import { getAutoLoadClaudeMdSetting, getEnableSandboxModeSetting, filterClaudeMdFromContext, + getMCPServersFromSettings, + getMCPPermissionSettings, } from '../lib/settings-helpers.js'; const execAsync = promisify(exec); @@ -1841,6 +1843,12 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. // 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]'); + + // Load MCP permission settings (global setting only) + const mcpPermissions = await getMCPPermissionSettings(this.settingsService, '[AutoMode]'); + // Build SDK options using centralized configuration for feature implementation const sdkOptions = createAutoModeOptions({ cwd: workDir, @@ -1848,6 +1856,9 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. abortController, autoLoadClaudeMd, enableSandboxMode, + mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, + mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools, + mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools, }); // Extract model, maxTurns, and allowedTools from SDK options @@ -1889,6 +1900,9 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. systemPrompt: sdkOptions.systemPrompt, settingSources: sdkOptions.settingSources, sandbox: sdkOptions.sandbox, // Pass sandbox configuration + mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration + mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools, // Pass MCP auto-approve setting + mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools, // Pass MCP unrestricted tools setting }; // Execute via provider @@ -2116,6 +2130,9 @@ After generating the revised spec, output: cwd: workDir, allowedTools: allowedTools, abortController, + mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, + mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools, + mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools, }); let revisionText = ''; @@ -2253,6 +2270,9 @@ After generating the revised spec, output: cwd: workDir, allowedTools: allowedTools, abortController, + mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, + mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools, + mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools, }); let taskOutput = ''; @@ -2342,6 +2362,9 @@ Implement all the changes described in the plan above.`; cwd: workDir, allowedTools: allowedTools, abortController, + mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, + mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools, + mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools, }); for await (const msg of continuationStream) { diff --git a/apps/ui/src/components/views/context-view.tsx b/apps/ui/src/components/views/context-view.tsx index 1ea9c06e..d81d6210 100644 --- a/apps/ui/src/components/views/context-view.tsx +++ b/apps/ui/src/components/views/context-view.tsx @@ -178,6 +178,13 @@ export function ContextView() { // Ensure context directory exists await api.mkdir(contextPath); + // Ensure metadata file exists (create empty one if not) + const metadataPath = `${contextPath}/context-metadata.json`; + const metadataExists = await api.exists(metadataPath); + if (!metadataExists) { + await api.writeFile(metadataPath, JSON.stringify({ files: {} }, null, 2)); + } + // Load metadata for descriptions const metadata = await loadMetadata(); diff --git a/apps/ui/src/components/views/settings-view.tsx b/apps/ui/src/components/views/settings-view.tsx index 6ea52add..70d19e5f 100644 --- a/apps/ui/src/components/views/settings-view.tsx +++ b/apps/ui/src/components/views/settings-view.tsx @@ -18,6 +18,7 @@ import { AudioSection } from './settings-view/audio/audio-section'; import { KeyboardShortcutsSection } from './settings-view/keyboard-shortcuts/keyboard-shortcuts-section'; import { FeatureDefaultsSection } from './settings-view/feature-defaults/feature-defaults-section'; import { DangerZoneSection } from './settings-view/danger-zone/danger-zone-section'; +import { MCPServersSection } from './settings-view/mcp-servers'; import type { Project as SettingsProject, Theme } from './settings-view/shared/types'; import type { Project as ElectronProject } from '@/lib/electron'; @@ -116,6 +117,8 @@ export function SettingsView() { {showUsageTracking && } ); + case 'mcp-servers': + return ; case 'ai-enhancement': return ; case 'appearance': diff --git a/apps/ui/src/components/views/settings-view/config/navigation.ts b/apps/ui/src/components/views/settings-view/config/navigation.ts index e32c2223..d478bb4d 100644 --- a/apps/ui/src/components/views/settings-view/config/navigation.ts +++ b/apps/ui/src/components/views/settings-view/config/navigation.ts @@ -9,6 +9,7 @@ import { FlaskConical, Trash2, Sparkles, + Plug, } from 'lucide-react'; import type { SettingsViewId } from '../hooks/use-settings-view'; @@ -22,6 +23,7 @@ export interface NavigationItem { export const NAV_ITEMS: NavigationItem[] = [ { id: 'api-keys', label: 'API Keys', icon: Key }, { id: 'claude', label: 'Claude', icon: Terminal }, + { id: 'mcp-servers', label: 'MCP Servers', icon: Plug }, { id: 'ai-enhancement', label: 'AI Enhancement', icon: Sparkles }, { id: 'appearance', label: 'Appearance', icon: Palette }, { id: 'terminal', label: 'Terminal', icon: SquareTerminal }, diff --git a/apps/ui/src/components/views/settings-view/hooks/use-settings-view.ts b/apps/ui/src/components/views/settings-view/hooks/use-settings-view.ts index 2e3f784f..48c406b2 100644 --- a/apps/ui/src/components/views/settings-view/hooks/use-settings-view.ts +++ b/apps/ui/src/components/views/settings-view/hooks/use-settings-view.ts @@ -3,6 +3,7 @@ import { useState, useCallback } from 'react'; export type SettingsViewId = | 'api-keys' | 'claude' + | 'mcp-servers' | 'ai-enhancement' | 'appearance' | 'terminal' diff --git a/apps/ui/src/components/views/settings-view/mcp-servers/index.ts b/apps/ui/src/components/views/settings-view/mcp-servers/index.ts new file mode 100644 index 00000000..bd267fe2 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/mcp-servers/index.ts @@ -0,0 +1 @@ +export { MCPServersSection } from './mcp-servers-section'; diff --git a/apps/ui/src/components/views/settings-view/mcp-servers/mcp-servers-section.tsx b/apps/ui/src/components/views/settings-view/mcp-servers/mcp-servers-section.tsx new file mode 100644 index 00000000..70863dd1 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/mcp-servers/mcp-servers-section.tsx @@ -0,0 +1,647 @@ +import { useState, useEffect } from 'react'; +import { useAppStore } from '@/store/app-store'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Plug, + Plus, + Pencil, + Trash2, + Terminal, + Globe, + FileJson, + Download, + RefreshCw, +} from 'lucide-react'; +import { Textarea } from '@/components/ui/textarea'; +import { cn } from '@/lib/utils'; +import { toast } from 'sonner'; +import type { MCPServerConfig } from '@automaker/types'; +import { syncSettingsToServer, loadMCPServersFromServer } from '@/hooks/use-settings-migration'; + +type ServerType = 'stdio' | 'sse' | 'http'; + +interface ServerFormData { + name: string; + description: string; + type: ServerType; + command: string; + args: string; + url: string; +} + +const defaultFormData: ServerFormData = { + name: '', + description: '', + type: 'stdio', + command: '', + args: '', + url: '', +}; + +export function MCPServersSection() { + const { + mcpServers, + addMCPServer, + updateMCPServer, + removeMCPServer, + mcpAutoApproveTools, + mcpUnrestrictedTools, + setMcpAutoApproveTools, + setMcpUnrestrictedTools, + } = useAppStore(); + const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); + const [editingServer, setEditingServer] = useState(null); + const [formData, setFormData] = useState(defaultFormData); + const [deleteConfirmId, setDeleteConfirmId] = useState(null); + const [isImportDialogOpen, setIsImportDialogOpen] = useState(false); + const [importJson, setImportJson] = useState(''); + const [isRefreshing, setIsRefreshing] = useState(false); + + // Auto-load MCP servers from settings file on mount + useEffect(() => { + loadMCPServersFromServer().catch((error) => { + console.error('Failed to load MCP servers on mount:', error); + }); + }, []); + + const handleRefresh = async () => { + setIsRefreshing(true); + try { + const success = await loadMCPServersFromServer(); + if (success) { + toast.success('MCP servers refreshed from settings'); + } else { + toast.error('Failed to refresh MCP servers'); + } + } catch (error) { + toast.error('Error refreshing MCP servers'); + } finally { + setIsRefreshing(false); + } + }; + + const handleOpenAddDialog = () => { + setFormData(defaultFormData); + setEditingServer(null); + setIsAddDialogOpen(true); + }; + + const handleOpenEditDialog = (server: MCPServerConfig) => { + setFormData({ + name: server.name, + description: server.description || '', + type: server.type || 'stdio', + command: server.command || '', + args: server.args?.join(' ') || '', + url: server.url || '', + }); + setEditingServer(server); + setIsAddDialogOpen(true); + }; + + const handleCloseDialog = () => { + setIsAddDialogOpen(false); + setEditingServer(null); + setFormData(defaultFormData); + }; + + const handleSave = async () => { + if (!formData.name.trim()) { + toast.error('Server name is required'); + return; + } + + if (formData.type === 'stdio' && !formData.command.trim()) { + toast.error('Command is required for stdio servers'); + return; + } + + if ((formData.type === 'sse' || formData.type === 'http') && !formData.url.trim()) { + toast.error('URL is required for SSE/HTTP servers'); + return; + } + + const serverData: Omit = { + name: formData.name.trim(), + description: formData.description.trim() || undefined, + type: formData.type, + enabled: editingServer?.enabled ?? true, + }; + + if (formData.type === 'stdio') { + serverData.command = formData.command.trim(); + if (formData.args.trim()) { + serverData.args = formData.args.trim().split(/\s+/); + } + } else { + serverData.url = formData.url.trim(); + } + + if (editingServer) { + updateMCPServer(editingServer.id, serverData); + toast.success('MCP server updated'); + } else { + addMCPServer(serverData); + toast.success('MCP server added'); + } + + await syncSettingsToServer(); + handleCloseDialog(); + }; + + const handleToggleEnabled = async (server: MCPServerConfig) => { + updateMCPServer(server.id, { enabled: !server.enabled }); + await syncSettingsToServer(); + toast.success(server.enabled ? 'Server disabled' : 'Server enabled'); + }; + + const handleDelete = async (id: string) => { + removeMCPServer(id); + await syncSettingsToServer(); + setDeleteConfirmId(null); + toast.success('MCP server removed'); + }; + + const getServerIcon = (type: ServerType = 'stdio') => { + if (type === 'stdio') return Terminal; + return Globe; + }; + + const handleImportJson = async () => { + try { + const parsed = JSON.parse(importJson); + + // Support both formats: + // 1. Claude Code format: { "mcpServers": { "name": { command, args, ... } } } + // 2. Direct format: { "name": { command, args, ... } } + const servers = parsed.mcpServers || parsed; + + if (typeof servers !== 'object' || Array.isArray(servers)) { + toast.error('Invalid format: expected object with server configurations'); + return; + } + + let addedCount = 0; + let skippedCount = 0; + + for (const [name, config] of Object.entries(servers)) { + if (typeof config !== 'object' || config === null) continue; + + const serverConfig = config as Record; + + // Check if server with this name already exists + if (mcpServers.some((s) => s.name === name)) { + skippedCount++; + continue; + } + + const serverData: Omit = { + name, + type: (serverConfig.type as ServerType) || 'stdio', + enabled: true, + }; + + if (serverData.type === 'stdio') { + if (!serverConfig.command) { + console.warn(`Skipping ${name}: no command specified`); + skippedCount++; + continue; + } + serverData.command = serverConfig.command as string; + if (Array.isArray(serverConfig.args)) { + serverData.args = serverConfig.args as string[]; + } + if (typeof serverConfig.env === 'object' && serverConfig.env !== null) { + serverData.env = serverConfig.env as Record; + } + } else { + if (!serverConfig.url) { + console.warn(`Skipping ${name}: no url specified`); + skippedCount++; + continue; + } + serverData.url = serverConfig.url as string; + if (typeof serverConfig.headers === 'object' && serverConfig.headers !== null) { + serverData.headers = serverConfig.headers as Record; + } + } + + addMCPServer(serverData); + addedCount++; + } + + await syncSettingsToServer(); + + if (addedCount > 0) { + toast.success(`Imported ${addedCount} MCP server${addedCount > 1 ? 's' : ''}`); + } + if (skippedCount > 0) { + toast.info( + `Skipped ${skippedCount} server${skippedCount > 1 ? 's' : ''} (already exist or invalid)` + ); + } + if (addedCount === 0 && skippedCount === 0) { + toast.warning('No servers found in JSON'); + } + + setIsImportDialogOpen(false); + setImportJson(''); + } catch (error) { + toast.error('Invalid JSON: ' + (error instanceof Error ? error.message : 'Parse error')); + } + }; + + const handleExportJson = () => { + const exportData: Record> = {}; + + for (const server of mcpServers) { + const serverConfig: Record = { + type: server.type || 'stdio', + }; + + if (server.type === 'stdio' || !server.type) { + serverConfig.command = server.command; + if (server.args?.length) serverConfig.args = server.args; + if (server.env && Object.keys(server.env).length > 0) serverConfig.env = server.env; + } else { + serverConfig.url = server.url; + if (server.headers && Object.keys(server.headers).length > 0) + serverConfig.headers = server.headers; + } + + exportData[server.name] = serverConfig; + } + + const json = JSON.stringify({ mcpServers: exportData }, null, 2); + navigator.clipboard.writeText(json); + toast.success('Copied to clipboard'); + }; + + return ( +
+ {/* Header */} +
+
+
+
+
+ +
+

MCP Servers

+
+

+ Configure Model Context Protocol servers to extend agent capabilities. +

+
+
+ + {mcpServers.length > 0 && ( + + )} + + +
+
+
+ + {/* Permission Settings */} + {mcpServers.length > 0 && ( +
+
+
+
+ +

+ Allow MCP tool calls without permission prompts (recommended) +

+
+ { + setMcpAutoApproveTools(checked); + await syncSettingsToServer(); + }} + data-testid="mcp-auto-approve-toggle" + /> +
+
+
+ +

+ Allow all tools when MCP is enabled (don't filter to default set) +

+
+ { + setMcpUnrestrictedTools(checked); + await syncSettingsToServer(); + }} + data-testid="mcp-unrestricted-toggle" + /> +
+
+
+ )} + + {/* Server List */} +
+ {mcpServers.length === 0 ? ( +
+ +

No MCP servers configured

+

Add a server to extend agent capabilities

+
+ ) : ( +
+ {mcpServers.map((server) => { + const Icon = getServerIcon(server.type); + return ( +
+
+
+ +
+
+
{server.name}
+ {server.description && ( +
{server.description}
+ )} +
+ {server.type === 'stdio' + ? `${server.command}${server.args?.length ? ' ' + server.args.join(' ') : ''}` + : server.url} +
+
+
+
+ handleToggleEnabled(server)} + data-testid={`mcp-server-toggle-${server.id}`} + /> + + +
+
+ ); + })} +
+ )} +
+ + {/* Add/Edit Dialog */} + + + + {editingServer ? 'Edit MCP Server' : 'Add MCP Server'} + + Configure an MCP server to extend agent capabilities with custom tools. + + +
+
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="my-mcp-server" + data-testid="mcp-server-name-input" + /> +
+
+ + setFormData({ ...formData, description: e.target.value })} + placeholder="What this server provides..." + data-testid="mcp-server-description-input" + /> +
+
+ + +
+ {formData.type === 'stdio' ? ( + <> +
+ + setFormData({ ...formData, command: e.target.value })} + placeholder="npx, node, python, etc." + data-testid="mcp-server-command-input" + /> +
+
+ + setFormData({ ...formData, args: e.target.value })} + placeholder="-y @modelcontextprotocol/server-filesystem" + data-testid="mcp-server-args-input" + /> +
+ + ) : ( +
+ + setFormData({ ...formData, url: e.target.value })} + placeholder="https://example.com/mcp" + data-testid="mcp-server-url-input" + /> +
+ )} +
+ + + + +
+
+ + {/* Delete Confirmation Dialog */} + setDeleteConfirmId(null)}> + + + Delete MCP Server + + Are you sure you want to delete this MCP server? This action cannot be undone. + + + + + + + + + + {/* Import JSON Dialog */} + + + + Import MCP Servers + + Paste JSON configuration in Claude Code format. Servers with duplicate names will be + skipped. + + +
+