From 8d542b8e45b014bc405e5be162933ca27c1ecf78 Mon Sep 17 00:00:00 2001 From: catlog22 Date: Sun, 14 Dec 2025 16:17:40 +0800 Subject: [PATCH] fix(ccw): prevent settings.json fields from being overwritten by hook operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit readSettingsFile() in hooks-routes and mcp-routes was returning { hooks: {} } as default value when file not found or parse failed, causing writeFileSync() to overwrite other fields (mcpServers, projects, statusLine) in settings.json. Changed default return to {} to preserve all existing fields when updating hooks. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- ccw/src/core/routes/hooks-routes.ts | 257 ++++++++++++++++ ccw/src/core/routes/mcp-routes.ts | 452 ++++++++++++++++++++++++++++ 2 files changed, 709 insertions(+) create mode 100644 ccw/src/core/routes/hooks-routes.ts create mode 100644 ccw/src/core/routes/mcp-routes.ts diff --git a/ccw/src/core/routes/hooks-routes.ts b/ccw/src/core/routes/hooks-routes.ts new file mode 100644 index 00000000..199b404a --- /dev/null +++ b/ccw/src/core/routes/hooks-routes.ts @@ -0,0 +1,257 @@ +// @ts-nocheck +/** + * Hooks Routes Module + * Handles all hooks-related API endpoints + */ +import type { IncomingMessage, ServerResponse } from 'http'; +import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; +import { join, dirname } from 'path'; +import { homedir } from 'os'; + +export interface RouteContext { + pathname: string; + url: URL; + req: IncomingMessage; + res: ServerResponse; + initialPath: string; + handlePostRequest: (req: IncomingMessage, res: ServerResponse, handler: (body: unknown) => Promise) => void; + broadcastToClients: (data: unknown) => void; + extractSessionIdFromPath: (filePath: string) => string | null; +} + +// ======================================== +// Helper Functions +// ======================================== + +const GLOBAL_SETTINGS_PATH = join(homedir(), '.claude', 'settings.json'); + +/** + * Get project settings path + * @param {string} projectPath + * @returns {string} + */ +function getProjectSettingsPath(projectPath) { + const normalizedPath = projectPath.replace(/\//g, '\\').replace(/^\\([a-zA-Z])\\/, '$1:\\'); + return join(normalizedPath, '.claude', 'settings.json'); +} + +/** + * Read settings file safely + * @param {string} filePath + * @returns {Object} + */ +function readSettingsFile(filePath) { + try { + if (!existsSync(filePath)) { + return {}; + } + const content = readFileSync(filePath, 'utf8'); + return JSON.parse(content); + } catch (error: unknown) { + console.error(`Error reading settings file ${filePath}:`, error); + return {}; + } +} + +/** + * Get hooks configuration from global and project settings + * @param {string} projectPath + * @returns {Object} + */ +function getHooksConfig(projectPath) { + const globalSettings = readSettingsFile(GLOBAL_SETTINGS_PATH); + const projectSettingsPath = projectPath ? getProjectSettingsPath(projectPath) : null; + const projectSettings = projectSettingsPath ? readSettingsFile(projectSettingsPath) : {}; + + return { + global: { + path: GLOBAL_SETTINGS_PATH, + hooks: globalSettings.hooks || {} + }, + project: { + path: projectSettingsPath, + hooks: projectSettings.hooks || {} + } + }; +} + +/** + * Save a hook to settings file + * @param {string} projectPath + * @param {string} scope - 'global' or 'project' + * @param {string} event - Hook event type + * @param {Object} hookData - Hook configuration + * @returns {Object} + */ +function saveHookToSettings(projectPath, scope, event, hookData) { + try { + const filePath = scope === 'global' ? GLOBAL_SETTINGS_PATH : getProjectSettingsPath(projectPath); + const settings = readSettingsFile(filePath); + + // Ensure hooks object exists + if (!settings.hooks) { + settings.hooks = {}; + } + + // Ensure the event array exists + if (!settings.hooks[event]) { + settings.hooks[event] = []; + } + + // Ensure it's an array + if (!Array.isArray(settings.hooks[event])) { + settings.hooks[event] = [settings.hooks[event]]; + } + + // Check if we're replacing an existing hook + if (hookData.replaceIndex !== undefined) { + const index = hookData.replaceIndex; + delete hookData.replaceIndex; + if (index >= 0 && index < settings.hooks[event].length) { + settings.hooks[event][index] = hookData; + } + } else { + // Add new hook + settings.hooks[event].push(hookData); + } + + // Ensure directory exists and write file + const dirPath = dirname(filePath); + if (!existsSync(dirPath)) { + mkdirSync(dirPath, { recursive: true }); + } + writeFileSync(filePath, JSON.stringify(settings, null, 2), 'utf8'); + + return { + success: true, + event, + hookData + }; + } catch (error: unknown) { + console.error('Error saving hook:', error); + return { error: (error as Error).message }; + } +} + +/** + * Delete a hook from settings file + * @param {string} projectPath + * @param {string} scope - 'global' or 'project' + * @param {string} event - Hook event type + * @param {number} hookIndex - Index of hook to delete + * @returns {Object} + */ +function deleteHookFromSettings(projectPath, scope, event, hookIndex) { + try { + const filePath = scope === 'global' ? GLOBAL_SETTINGS_PATH : getProjectSettingsPath(projectPath); + const settings = readSettingsFile(filePath); + + if (!settings.hooks || !settings.hooks[event]) { + return { error: 'Hook not found' }; + } + + // Ensure it's an array + if (!Array.isArray(settings.hooks[event])) { + settings.hooks[event] = [settings.hooks[event]]; + } + + if (hookIndex < 0 || hookIndex >= settings.hooks[event].length) { + return { error: 'Invalid hook index' }; + } + + // Remove the hook + settings.hooks[event].splice(hookIndex, 1); + + // Remove empty event arrays + if (settings.hooks[event].length === 0) { + delete settings.hooks[event]; + } + + writeFileSync(filePath, JSON.stringify(settings, null, 2), 'utf8'); + + return { + success: true, + event, + hookIndex + }; + } catch (error: unknown) { + console.error('Error deleting hook:', error); + return { error: (error as Error).message }; + } +} + +// ======================================== +// Route Handler +// ======================================== + +/** + * Handle hooks routes + * @returns true if route was handled, false otherwise + */ +export async function handleHooksRoutes(ctx: RouteContext): Promise { + const { pathname, url, req, res, initialPath, handlePostRequest, broadcastToClients, extractSessionIdFromPath } = ctx; + + // API: Hook endpoint for Claude Code notifications + if (pathname === '/api/hook' && req.method === 'POST') { + handlePostRequest(req, res, async (body) => { + const { type, filePath, sessionId, ...extraData } = body; + + // Determine session ID from file path if not provided + let resolvedSessionId = sessionId; + if (!resolvedSessionId && filePath) { + resolvedSessionId = extractSessionIdFromPath(filePath); + } + + // Broadcast to all connected WebSocket clients + const notification = { + type: type || 'session_updated', + payload: { + sessionId: resolvedSessionId, + filePath: filePath, + timestamp: new Date().toISOString(), + ...extraData // Pass through toolName, status, result, params, error, etc. + } + }; + + broadcastToClients(notification); + + return { success: true, notification }; + }); + return true; + } + + // API: Get hooks configuration + if (pathname === '/api/hooks' && req.method === 'GET') { + const projectPathParam = url.searchParams.get('path'); + const hooksData = getHooksConfig(projectPathParam); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(hooksData)); + return true; + } + + // API: Save hook + if (pathname === '/api/hooks' && req.method === 'POST') { + handlePostRequest(req, res, async (body) => { + const { projectPath, scope, event, hookData } = body; + if (!scope || !event || !hookData) { + return { error: 'scope, event, and hookData are required', status: 400 }; + } + return saveHookToSettings(projectPath, scope, event, hookData); + }); + return true; + } + + // API: Delete hook + if (pathname === '/api/hooks' && req.method === 'DELETE') { + handlePostRequest(req, res, async (body) => { + const { projectPath, scope, event, hookIndex } = body; + if (!scope || !event || hookIndex === undefined) { + return { error: 'scope, event, and hookIndex are required', status: 400 }; + } + return deleteHookFromSettings(projectPath, scope, event, hookIndex); + }); + return true; + } + + return false; +} diff --git a/ccw/src/core/routes/mcp-routes.ts b/ccw/src/core/routes/mcp-routes.ts new file mode 100644 index 00000000..82339801 --- /dev/null +++ b/ccw/src/core/routes/mcp-routes.ts @@ -0,0 +1,452 @@ +// @ts-nocheck +/** + * MCP Routes Module + * Handles all MCP-related API endpoints + */ +import type { IncomingMessage, ServerResponse } from 'http'; +import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; +import { join, dirname } from 'path'; +import { homedir } from 'os'; + +// Claude config file path +const CLAUDE_CONFIG_PATH = join(homedir(), '.claude.json'); + +export interface RouteContext { + pathname: string; + url: URL; + req: IncomingMessage; + res: ServerResponse; + initialPath: string; + handlePostRequest: (req: IncomingMessage, res: ServerResponse, handler: (body: unknown) => Promise) => void; + broadcastToClients: (data: unknown) => void; +} + +// ======================================== +// Helper Functions +// ======================================== + +/** + * Get enterprise managed MCP path (platform-specific) + */ +function getEnterpriseMcpPath(): string { + const platform = process.platform; + if (platform === 'darwin') { + return '/Library/Application Support/ClaudeCode/managed-mcp.json'; + } else if (platform === 'win32') { + return 'C:\\Program Files\\ClaudeCode\\managed-mcp.json'; + } else { + // Linux and WSL + return '/etc/claude-code/managed-mcp.json'; + } +} + +/** + * Safely read and parse JSON file + */ +function safeReadJson(filePath) { + try { + if (!existsSync(filePath)) return null; + const content = readFileSync(filePath, 'utf8'); + return JSON.parse(content); + } catch { + return null; + } +} + +/** + * Get MCP servers from a JSON file (expects mcpServers key at top level) + * @param {string} filePath + * @returns {Object} mcpServers object or empty object + */ +function getMcpServersFromFile(filePath) { + const config = safeReadJson(filePath); + if (!config) return {}; + return config.mcpServers || {}; +} + +/** + * Get MCP configuration from multiple sources (per official Claude Code docs): + * + * Priority (highest to lowest): + * 1. Enterprise managed-mcp.json (cannot be overridden) + * 2. Local scope (project-specific private in ~/.claude.json) + * 3. Project scope (.mcp.json in project root) + * 4. User scope (mcpServers in ~/.claude.json) + * + * Note: ~/.claude/settings.json is for MCP PERMISSIONS, NOT definitions! + * + * @returns {Object} + */ +function getMcpConfig() { + try { + const result = { + projects: {}, + userServers: {}, // User-level servers from ~/.claude.json mcpServers + enterpriseServers: {}, // Enterprise managed servers (highest priority) + configSources: [] // Track where configs came from for debugging + }; + + // 1. Read Enterprise managed MCP servers (highest priority) + const enterprisePath = getEnterpriseMcpPath(); + if (existsSync(enterprisePath)) { + const enterpriseConfig = safeReadJson(enterprisePath); + if (enterpriseConfig?.mcpServers) { + result.enterpriseServers = enterpriseConfig.mcpServers; + result.configSources.push({ type: 'enterprise', path: enterprisePath, count: Object.keys(enterpriseConfig.mcpServers).length }); + } + } + + // 2. Read from ~/.claude.json + if (existsSync(CLAUDE_CONFIG_PATH)) { + const claudeConfig = safeReadJson(CLAUDE_CONFIG_PATH); + if (claudeConfig) { + // 2a. User-level mcpServers (top-level mcpServers key) + if (claudeConfig.mcpServers) { + result.userServers = claudeConfig.mcpServers; + result.configSources.push({ type: 'user', path: CLAUDE_CONFIG_PATH, count: Object.keys(claudeConfig.mcpServers).length }); + } + + // 2b. Project-specific configurations (projects[path].mcpServers) + if (claudeConfig.projects) { + result.projects = claudeConfig.projects; + } + } + } + + // 3. For each known project, check for .mcp.json (project-level config) + const projectPaths = Object.keys(result.projects); + for (const projectPath of projectPaths) { + const mcpJsonPath = join(projectPath, '.mcp.json'); + if (existsSync(mcpJsonPath)) { + const mcpJsonConfig = safeReadJson(mcpJsonPath); + if (mcpJsonConfig?.mcpServers) { + // Merge .mcp.json servers into project config + // Project's .mcp.json has lower priority than ~/.claude.json projects[path].mcpServers + const existingServers = result.projects[projectPath]?.mcpServers || {}; + result.projects[projectPath] = { + ...result.projects[projectPath], + mcpServers: { + ...mcpJsonConfig.mcpServers, // .mcp.json (lower priority) + ...existingServers // ~/.claude.json projects[path] (higher priority) + }, + mcpJsonPath: mcpJsonPath // Track source for debugging + }; + result.configSources.push({ type: 'project-mcp-json', path: mcpJsonPath, count: Object.keys(mcpJsonConfig.mcpServers).length }); + } + } + } + + // Build globalServers by merging user and enterprise servers + // Enterprise servers override user servers + result.globalServers = { + ...result.userServers, + ...result.enterpriseServers + }; + + return result; + } catch (error: unknown) { + console.error('Error reading MCP config:', error); + return { projects: {}, globalServers: {}, userServers: {}, enterpriseServers: {}, configSources: [], error: (error as Error).message }; + } +} + +/** + * Normalize project path for .claude.json (Windows backslash format) + * @param {string} path + * @returns {string} + */ +function normalizeProjectPathForConfig(path) { + // Convert forward slashes to backslashes for Windows .claude.json format + let normalized = path.replace(/\//g, '\\'); + + // Handle /d/path format -> D:\path + if (normalized.match(/^\\[a-zA-Z]\\/)) { + normalized = normalized.charAt(1).toUpperCase() + ':' + normalized.slice(2); + } + + return normalized; +} + +/** + * Toggle MCP server enabled/disabled + * @param {string} projectPath + * @param {string} serverName + * @param {boolean} enable + * @returns {Object} + */ +function toggleMcpServerEnabled(projectPath, serverName, enable) { + try { + if (!existsSync(CLAUDE_CONFIG_PATH)) { + return { error: '.claude.json not found' }; + } + + const content = readFileSync(CLAUDE_CONFIG_PATH, 'utf8'); + const config = JSON.parse(content); + + const normalizedPath = normalizeProjectPathForConfig(projectPath); + + if (!config.projects || !config.projects[normalizedPath]) { + return { error: `Project not found: ${normalizedPath}` }; + } + + const projectConfig = config.projects[normalizedPath]; + + // Ensure disabledMcpServers array exists + if (!projectConfig.disabledMcpServers) { + projectConfig.disabledMcpServers = []; + } + + if (enable) { + // Remove from disabled list + projectConfig.disabledMcpServers = projectConfig.disabledMcpServers.filter(s => s !== serverName); + } else { + // Add to disabled list if not already there + if (!projectConfig.disabledMcpServers.includes(serverName)) { + projectConfig.disabledMcpServers.push(serverName); + } + } + + // Write back to file + writeFileSync(CLAUDE_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8'); + + return { + success: true, + serverName, + enabled: enable, + disabledMcpServers: projectConfig.disabledMcpServers + }; + } catch (error: unknown) { + console.error('Error toggling MCP server:', error); + return { error: (error as Error).message }; + } +} + +/** + * Add MCP server to project + * @param {string} projectPath + * @param {string} serverName + * @param {Object} serverConfig + * @returns {Object} + */ +function addMcpServerToProject(projectPath, serverName, serverConfig) { + try { + if (!existsSync(CLAUDE_CONFIG_PATH)) { + return { error: '.claude.json not found' }; + } + + const content = readFileSync(CLAUDE_CONFIG_PATH, 'utf8'); + const config = JSON.parse(content); + + const normalizedPath = normalizeProjectPathForConfig(projectPath); + + // Create project entry if it doesn't exist + if (!config.projects) { + config.projects = {}; + } + + if (!config.projects[normalizedPath]) { + config.projects[normalizedPath] = { + allowedTools: [], + mcpContextUris: [], + mcpServers: {}, + enabledMcpjsonServers: [], + disabledMcpjsonServers: [], + hasTrustDialogAccepted: false, + projectOnboardingSeenCount: 0, + hasClaudeMdExternalIncludesApproved: false, + hasClaudeMdExternalIncludesWarningShown: false + }; + } + + const projectConfig = config.projects[normalizedPath]; + + // Ensure mcpServers exists + if (!projectConfig.mcpServers) { + projectConfig.mcpServers = {}; + } + + // Add the server + projectConfig.mcpServers[serverName] = serverConfig; + + // Write back to file + writeFileSync(CLAUDE_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8'); + + return { + success: true, + serverName, + serverConfig + }; + } catch (error: unknown) { + console.error('Error adding MCP server:', error); + return { error: (error as Error).message }; + } +} + +/** + * Remove MCP server from project + * @param {string} projectPath + * @param {string} serverName + * @returns {Object} + */ +function removeMcpServerFromProject(projectPath, serverName) { + try { + if (!existsSync(CLAUDE_CONFIG_PATH)) { + return { error: '.claude.json not found' }; + } + + const content = readFileSync(CLAUDE_CONFIG_PATH, 'utf8'); + const config = JSON.parse(content); + + const normalizedPath = normalizeProjectPathForConfig(projectPath); + + if (!config.projects || !config.projects[normalizedPath]) { + return { error: `Project not found: ${normalizedPath}` }; + } + + const projectConfig = config.projects[normalizedPath]; + + if (!projectConfig.mcpServers || !projectConfig.mcpServers[serverName]) { + return { error: `Server not found: ${serverName}` }; + } + + // Remove the server + delete projectConfig.mcpServers[serverName]; + + // Also remove from disabled list if present + if (projectConfig.disabledMcpServers) { + projectConfig.disabledMcpServers = projectConfig.disabledMcpServers.filter(s => s !== serverName); + } + + // Write back to file + writeFileSync(CLAUDE_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8'); + + return { + success: true, + serverName, + removed: true + }; + } catch (error: unknown) { + console.error('Error removing MCP server:', error); + return { error: (error as Error).message }; + } +} + +/** + * Read settings file safely + * @param {string} filePath + * @returns {Object} + */ +function readSettingsFile(filePath) { + try { + if (!existsSync(filePath)) { + return {}; + } + const content = readFileSync(filePath, 'utf8'); + return JSON.parse(content); + } catch (error: unknown) { + console.error(`Error reading settings file ${filePath}:`, error); + return {}; + } +} + +/** + * Write settings file safely + * @param {string} filePath + * @param {Object} settings + */ +function writeSettingsFile(filePath, settings) { + const dirPath = dirname(filePath); + // Ensure directory exists + if (!existsSync(dirPath)) { + mkdirSync(dirPath, { recursive: true }); + } + writeFileSync(filePath, JSON.stringify(settings, null, 2), 'utf8'); +} + +/** + * Get project settings path + * @param {string} projectPath + * @returns {string} + */ +function getProjectSettingsPath(projectPath) { + const normalizedPath = projectPath.replace(/\//g, '\\').replace(/^\\([a-zA-Z])\\/, '$1:\\'); + return join(normalizedPath, '.claude', 'settings.json'); +} + +// ======================================== +// Route Handlers +// ======================================== + +/** + * Handle MCP routes + * @returns true if route was handled, false otherwise + */ +export async function handleMcpRoutes(ctx: RouteContext): Promise { + const { pathname, url, req, res, initialPath, handlePostRequest, broadcastToClients } = ctx; + + // API: Get MCP configuration + if (pathname === '/api/mcp-config') { + const mcpData = getMcpConfig(); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(mcpData)); + return true; + } + + // API: Toggle MCP server enabled/disabled + if (pathname === '/api/mcp-toggle' && req.method === 'POST') { + handlePostRequest(req, res, async (body) => { + const { projectPath, serverName, enable } = body; + if (!projectPath || !serverName) { + return { error: 'projectPath and serverName are required', status: 400 }; + } + return toggleMcpServerEnabled(projectPath, serverName, enable); + }); + return true; + } + + // API: Copy MCP server to project + if (pathname === '/api/mcp-copy-server' && req.method === 'POST') { + handlePostRequest(req, res, async (body) => { + const { projectPath, serverName, serverConfig } = body; + if (!projectPath || !serverName || !serverConfig) { + return { error: 'projectPath, serverName, and serverConfig are required', status: 400 }; + } + return addMcpServerToProject(projectPath, serverName, serverConfig); + }); + return true; + } + + // API: Install CCW MCP server to project + if (pathname === '/api/mcp-install-ccw' && req.method === 'POST') { + handlePostRequest(req, res, async (body) => { + const { projectPath } = body; + if (!projectPath) { + return { error: 'projectPath is required', status: 400 }; + } + + // Generate CCW MCP server config + const ccwMcpConfig = { + command: "ccw-mcp", + args: [] + }; + + // Use existing addMcpServerToProject to install CCW MCP + return addMcpServerToProject(projectPath, 'ccw-mcp', ccwMcpConfig); + }); + return true; + } + + // API: Remove MCP server from project + if (pathname === '/api/mcp-remove-server' && req.method === 'POST') { + handlePostRequest(req, res, async (body) => { + const { projectPath, serverName } = body; + if (!projectPath || !serverName) { + return { error: 'projectPath and serverName are required', status: 400 }; + } + return removeMcpServerFromProject(projectPath, serverName); + }); + return true; + } + + return false; +}