UNPKG

@wonderwhy-er/desktop-commander

Version:

MCP server for terminal operations and file editing

353 lines (352 loc) 14.2 kB
import fs from "fs/promises"; import path from "path"; import os from 'os'; import fetch from 'cross-fetch'; import { capture, withTimeout } from '../utils.js'; import { configManager } from '../config-manager.js'; // Initialize allowed directories from configuration async function getAllowedDirs() { try { let allowedDirectories; const config = await configManager.getConfig(); if (config.allowedDirectories && Array.isArray(config.allowedDirectories)) { allowedDirectories = config.allowedDirectories; } else { // Fall back to default directories if not configured allowedDirectories = [ os.homedir() // User's home directory ]; // Update config with default await configManager.setValue('allowedDirectories', allowedDirectories); } return allowedDirectories; } catch (error) { console.error('Failed to initialize allowed directories:', error); // Keep the default permissive path } return []; } // Normalize all paths consistently function normalizePath(p) { return path.normalize(expandHome(p)).toLowerCase(); } function expandHome(filepath) { if (filepath.startsWith('~/') || filepath === '~') { return path.join(os.homedir(), filepath.slice(1)); } return filepath; } /** * Recursively validates parent directories until it finds a valid one * This function handles the case where we need to create nested directories * and we need to check if any of the parent directories exist * * @param directoryPath The path to validate * @returns Promise<boolean> True if a valid parent directory was found */ async function validateParentDirectories(directoryPath) { const parentDir = path.dirname(directoryPath); // Base case: we've reached the root or the same directory (shouldn't happen normally) if (parentDir === directoryPath || parentDir === path.dirname(parentDir)) { return false; } try { // Check if the parent directory exists await fs.realpath(parentDir); return true; } catch { // Parent doesn't exist, recursively check its parent return validateParentDirectories(parentDir); } } /** * Checks if a path is within any of the allowed directories * * @param pathToCheck Path to check * @returns boolean True if path is allowed */ async function isPathAllowed(pathToCheck) { // If root directory is allowed, all paths are allowed const allowedDirectories = await getAllowedDirs(); if (allowedDirectories.includes('/') || allowedDirectories.length === 0) { return true; } let normalizedPathToCheck = normalizePath(pathToCheck); if (normalizedPathToCheck.slice(-1) === path.sep) { normalizedPathToCheck = normalizedPathToCheck.slice(0, -1); } // Check if the path is within any allowed directory const isAllowed = allowedDirectories.some(allowedDir => { let normalizedAllowedDir = normalizePath(allowedDir); if (normalizedAllowedDir.slice(-1) === path.sep) { normalizedAllowedDir = normalizedAllowedDir.slice(0, -1); } // Check if path is exactly the allowed directory if (normalizedPathToCheck === normalizedAllowedDir) { return true; } // Check if path is a subdirectory of the allowed directory // Make sure to add a separator to prevent partial directory name matches // e.g. /home/user vs /home/username const subdirCheck = normalizedPathToCheck.startsWith(normalizedAllowedDir + path.sep); if (subdirCheck) { return true; } // If allowed directory is the root (C:\ on Windows), allow access to the entire drive if (normalizedAllowedDir === 'c:' && process.platform === 'win32') { return normalizedPathToCheck.startsWith('c:'); } return false; }); return isAllowed; } /** * Validates a path to ensure it can be accessed or created. * For existing paths, returns the real path (resolving symlinks). * For non-existent paths, validates parent directories to ensure they exist. * * @param requestedPath The path to validate * @returns Promise<string> The validated path * @throws Error if the path or its parent directories don't exist or if the path is not allowed */ export async function validatePath(requestedPath) { const PATH_VALIDATION_TIMEOUT = 10000; // 10 seconds timeout const validationOperation = async () => { // Expand home directory if present const expandedPath = expandHome(requestedPath); // Convert to absolute path const absolute = path.isAbsolute(expandedPath) ? path.resolve(expandedPath) : path.resolve(process.cwd(), expandedPath); // Check if path is allowed if (!(await isPathAllowed(absolute))) { throw (`Path not allowed: ${requestedPath}. Must be within one of these directories: ${(await getAllowedDirs()).join(', ')}`); } // Check if path exists try { const stats = await fs.stat(absolute); // If path exists, resolve any symlinks return await fs.realpath(absolute); } catch (error) { // Path doesn't exist - validate parent directories if (await validateParentDirectories(absolute)) { // Return the path if a valid parent exists // This will be used for folder creation and many other file operations return absolute; } // If no valid parent found, return the absolute path anyway return absolute; } }; // Execute with timeout const result = await withTimeout(validationOperation(), PATH_VALIDATION_TIMEOUT, `Path validation for ${requestedPath}`, null); if (result === null) { // Return a path with an error indicator instead of throwing throw new Error(`Path validation failed for path: ${requestedPath}`); } return result; } /** * Read file content from a URL * @param url URL to fetch content from * @returns File content or file result with metadata */ export async function readFileFromUrl(url) { // Import the MIME type utilities const { isImageFile } = await import('./mime-types.js'); // Set up fetch with timeout const FETCH_TIMEOUT_MS = 30000; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); try { const response = await fetch(url, { signal: controller.signal }); // Clear the timeout since fetch completed clearTimeout(timeoutId); if (!response.ok) { throw new Error(`HTTP error! Status: ${response.status}`); } // Get MIME type from Content-Type header const contentType = response.headers.get('content-type') || 'text/plain'; const isImage = isImageFile(contentType); if (isImage) { // For images, convert to base64 const buffer = await response.arrayBuffer(); const content = Buffer.from(buffer).toString('base64'); return { content, mimeType: contentType, isImage }; } else { // For text content const content = await response.text(); return { content, mimeType: contentType, isImage }; } } catch (error) { // Clear the timeout to prevent memory leaks clearTimeout(timeoutId); // Return error information instead of throwing const errorMessage = error instanceof DOMException && error.name === 'AbortError' ? `URL fetch timed out after ${FETCH_TIMEOUT_MS}ms: ${url}` : `Failed to fetch URL: ${error instanceof Error ? error.message : String(error)}`; throw new Error(errorMessage); } } /** * Read file content from the local filesystem * @param filePath Path to the file * @param returnMetadata Whether to return metadata with the content * @returns File content or file result with metadata */ export async function readFileFromDisk(filePath) { // Import the MIME type utilities const { getMimeType, isImageFile } = await import('./mime-types.js'); const validPath = await validatePath(filePath); // Check file size before attempting to read try { const stats = await fs.stat(validPath); const MAX_SIZE = 100 * 1024; // 100KB limit if (stats.size > MAX_SIZE) { const message = `File too large (${(stats.size / 1024).toFixed(2)}KB > ${MAX_SIZE / 1024}KB limit)`; return { content: message, mimeType: 'text/plain', isImage: false }; } } catch (error) { console.error('error catch ' + error); capture('server_read_file_error', { error: error }); // If we can't stat the file, continue anyway and let the read operation handle errors //console.error(`Failed to stat file ${validPath}:`, error); } // Detect the MIME type based on file extension const mimeType = getMimeType(validPath); const isImage = isImageFile(mimeType); const FILE_READ_TIMEOUT = 30000; // 30 seconds timeout for file operations // Use withTimeout to handle potential hangs const readOperation = async () => { if (isImage) { // For image files, read as Buffer and convert to base64 const buffer = await fs.readFile(validPath); const content = buffer.toString('base64'); return { content, mimeType, isImage }; } else { // For all other files, try to read as UTF-8 text try { const content = await fs.readFile(validPath, "utf-8"); return { content, mimeType, isImage }; } catch (error) { // If UTF-8 reading fails, treat as binary and return base64 but still as text const buffer = await fs.readFile(validPath); const content = `Binary file content (base64 encoded):\n${buffer.toString('base64')}`; return { content, mimeType: 'text/plain', isImage: false }; } } }; // Execute with timeout const result = await withTimeout(readOperation(), FILE_READ_TIMEOUT, `Read file operation for ${filePath}`, null); if (result == null) { // Handles the impossible case where withTimeout resolves to null instead of throwing throw new Error('Failed to read the file'); } return result; } /** * Read a file from either the local filesystem or a URL * @param filePath Path to the file or URL * @param returnMetadata Whether to return metadata with the content * @param isUrl Whether the path is a URL * @returns File content or file result with metadata */ export async function readFile(filePath, isUrl) { return isUrl ? readFileFromUrl(filePath) : readFileFromDisk(filePath); } export async function writeFile(filePath, content) { const validPath = await validatePath(filePath); await fs.writeFile(validPath, content, "utf-8"); } export async function readMultipleFiles(paths) { return Promise.all(paths.map(async (filePath) => { try { const validPath = await validatePath(filePath); const fileResult = await readFile(validPath); return { path: filePath, content: typeof fileResult === 'string' ? fileResult : fileResult.content, mimeType: typeof fileResult === 'string' ? "text/plain" : fileResult.mimeType, isImage: typeof fileResult === 'string' ? false : fileResult.isImage }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { path: filePath, error: errorMessage }; } })); } export async function createDirectory(dirPath) { const validPath = await validatePath(dirPath); await fs.mkdir(validPath, { recursive: true }); } export async function listDirectory(dirPath) { const validPath = await validatePath(dirPath); const entries = await fs.readdir(validPath, { withFileTypes: true }); return entries.map((entry) => `${entry.isDirectory() ? "[DIR]" : "[FILE]"} ${entry.name}`); } export async function moveFile(sourcePath, destinationPath) { const validSourcePath = await validatePath(sourcePath); const validDestPath = await validatePath(destinationPath); await fs.rename(validSourcePath, validDestPath); } export async function searchFiles(rootPath, pattern) { const results = []; async function search(currentPath) { const entries = await fs.readdir(currentPath, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(currentPath, entry.name); try { await validatePath(fullPath); if (entry.name.toLowerCase().includes(pattern.toLowerCase())) { results.push(fullPath); } if (entry.isDirectory()) { await search(fullPath); } } catch (error) { continue; } } } // if path not exist, it will throw an error const validPath = await validatePath(rootPath); await search(validPath); return results; } export async function getFileInfo(filePath) { const validPath = await validatePath(filePath); const stats = await fs.stat(validPath); return { size: stats.size, created: stats.birthtime, modified: stats.mtime, accessed: stats.atime, isDirectory: stats.isDirectory(), isFile: stats.isFile(), permissions: stats.mode.toString(8).slice(-3), }; } // This function has been replaced with configManager.getConfig() // Use get_config tool to retrieve allowedDirectories