diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 04d336d957..34ae2e5c72 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2,6 +2,7 @@ import os from "os" import * as path from "path" import fs from "fs/promises" import EventEmitter from "events" +import { isWSL, getWindowsHomeFromWSL } from "../../utils/wsl" import { Anthropic } from "@anthropic-ai/sdk" import delay from "delay" @@ -1318,14 +1319,26 @@ export class ClineProvider async ensureMcpServersDirectoryExists(): Promise { // Get platform-specific application data directory let mcpServersDir: string - if (process.platform === "win32") { - // Windows: %APPDATA%\Roo-Code\MCP + + // Check if we're running in WSL + if (isWSL()) { + // In WSL, use Windows paths for better compatibility + const windowsHome = getWindowsHomeFromWSL() + if (windowsHome) { + // Use Windows AppData directory accessible from WSL + mcpServersDir = path.join(windowsHome, "AppData", "Roaming", "Roo-Code", "MCP") + } else { + // Fallback to a WSL-friendly location that's easily accessible from Windows + mcpServersDir = path.join("/mnt/c", "ProgramData", "Roo-Code", "MCP") + } + } else if (process.platform === "win32") { + // Native Windows: %APPDATA%\Roo-Code\MCP mcpServersDir = path.join(os.homedir(), "AppData", "Roaming", "Roo-Code", "MCP") } else if (process.platform === "darwin") { // macOS: ~/Documents/Cline/MCP mcpServersDir = path.join(os.homedir(), "Documents", "Cline", "MCP") } else { - // Linux: ~/.local/share/Cline/MCP + // Native Linux: ~/.local/share/Roo-Code/MCP mcpServersDir = path.join(os.homedir(), ".local", "share", "Roo-Code", "MCP") } diff --git a/src/services/mcp/McpHub.ts b/src/services/mcp/McpHub.ts index 271c6e1fb3..1a8e47840b 100644 --- a/src/services/mcp/McpHub.ts +++ b/src/services/mcp/McpHub.ts @@ -18,6 +18,7 @@ import * as path from "path" import * as vscode from "vscode" import { z } from "zod" import { t } from "../../i18n" +import { isWSL } from "../../utils/wsl" import { ClineProvider } from "../../core/webview/ClineProvider" import { GlobalFileNames } from "../../shared/globalFileNames" @@ -1657,7 +1658,8 @@ export class McpHub { // Normalize path for cross-platform compatibility // Use a consistent path format for both reading and writing - const normalizedPath = process.platform === "win32" ? configPath.replace(/\\/g, "/") : configPath + // In WSL, we need to handle paths differently + const normalizedPath = process.platform === "win32" || isWSL() ? configPath.replace(/\\/g, "/") : configPath // Read the appropriate config file const content = await fs.readFile(normalizedPath, "utf-8") diff --git a/src/utils/__tests__/wsl.spec.ts b/src/utils/__tests__/wsl.spec.ts new file mode 100644 index 0000000000..c29f6dcc6e --- /dev/null +++ b/src/utils/__tests__/wsl.spec.ts @@ -0,0 +1,231 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest" +import * as fs from "fs" +import * as os from "os" +import { isWSL, getWindowsHomeFromWSL } from "../wsl" + +// Mock fs module +vi.mock("fs", () => ({ + readFileSync: vi.fn(), + accessSync: vi.fn(), + constants: { + F_OK: 0, + }, +})) + +// Mock os module +vi.mock("os", () => ({ + userInfo: vi.fn(() => ({ username: "testuser" })), +})) + +describe("WSL Detection", () => { + const originalEnv = process.env + const originalPlatform = process.platform + + beforeEach(() => { + // Reset environment variables + process.env = { ...originalEnv } + // Reset platform + Object.defineProperty(process, "platform", { + value: originalPlatform, + writable: true, + }) + // Clear all mocks + vi.clearAllMocks() + }) + + afterEach(() => { + // Restore original environment + process.env = originalEnv + Object.defineProperty(process, "platform", { + value: originalPlatform, + writable: false, + }) + }) + + describe("isWSL", () => { + it("should detect WSL when WSL_DISTRO_NAME is set", () => { + process.env.WSL_DISTRO_NAME = "Ubuntu" + expect(isWSL()).toBe(true) + }) + + it("should detect WSL when WSL_INTEROP is set", () => { + process.env.WSL_INTEROP = "/run/WSL/123_interop" + expect(isWSL()).toBe(true) + }) + + it("should detect WSL when /proc/version contains Microsoft", () => { + Object.defineProperty(process, "platform", { value: "linux", writable: true }) + vi.mocked(fs.readFileSync).mockReturnValue( + "Linux version 5.10.16.3-microsoft-standard-WSL2 (gcc version 9.3.0)", + ) + expect(isWSL()).toBe(true) + }) + + it("should detect WSL when /proc/version contains WSL", () => { + Object.defineProperty(process, "platform", { value: "linux", writable: true }) + vi.mocked(fs.readFileSync).mockReturnValue("Linux version 5.10.16.3-WSL2") + expect(isWSL()).toBe(true) + }) + + it("should detect WSL when /proc/sys/fs/binfmt_misc/WSLInterop exists", () => { + Object.defineProperty(process, "platform", { value: "linux", writable: true }) + vi.mocked(fs.readFileSync).mockImplementation(() => { + throw new Error("File not found") + }) + // accessSync should not throw for WSLInterop file + vi.mocked(fs.accessSync).mockImplementation((path) => { + if (path === "/proc/sys/fs/binfmt_misc/WSLInterop") { + return undefined + } + throw new Error("File not found") + }) + expect(isWSL()).toBe(true) + }) + + it("should detect WSL when PATH contains /mnt/c/", () => { + Object.defineProperty(process, "platform", { value: "linux", writable: true }) + process.env.PATH = "/usr/bin:/mnt/c/Windows/System32:/mnt/c/Windows" + vi.mocked(fs.readFileSync).mockImplementation(() => { + throw new Error("File not found") + }) + vi.mocked(fs.accessSync).mockImplementation(() => { + throw new Error("File not found") + }) + expect(isWSL()).toBe(true) + }) + + it("should detect WSL when WSLENV is set", () => { + Object.defineProperty(process, "platform", { value: "linux", writable: true }) + process.env.WSLENV = "WT_SESSION:WT_PROFILE_ID" + vi.mocked(fs.readFileSync).mockImplementation(() => { + throw new Error("File not found") + }) + vi.mocked(fs.accessSync).mockImplementation(() => { + throw new Error("File not found") + }) + expect(isWSL()).toBe(true) + }) + + it("should return false on native Windows", () => { + Object.defineProperty(process, "platform", { value: "win32", writable: true }) + vi.mocked(fs.readFileSync).mockImplementation(() => { + throw new Error("File not found") + }) + vi.mocked(fs.accessSync).mockImplementation(() => { + throw new Error("File not found") + }) + expect(isWSL()).toBe(false) + }) + + it("should return false on native Linux without WSL indicators", () => { + Object.defineProperty(process, "platform", { value: "linux", writable: true }) + process.env.PATH = "/usr/bin:/usr/local/bin" + vi.mocked(fs.readFileSync).mockReturnValue("Linux version 5.10.0-generic") + vi.mocked(fs.accessSync).mockImplementation(() => { + throw new Error("File not found") + }) + expect(isWSL()).toBe(false) + }) + + it("should return false on macOS", () => { + Object.defineProperty(process, "platform", { value: "darwin", writable: true }) + vi.mocked(fs.readFileSync).mockImplementation(() => { + throw new Error("File not found") + }) + vi.mocked(fs.accessSync).mockImplementation(() => { + throw new Error("File not found") + }) + expect(isWSL()).toBe(false) + }) + }) + + describe("getWindowsHomeFromWSL", () => { + beforeEach(() => { + // Set up WSL environment + Object.defineProperty(process, "platform", { value: "linux", writable: true }) + process.env.WSL_DISTRO_NAME = "Ubuntu" + }) + + it("should return null when not in WSL", () => { + delete process.env.WSL_DISTRO_NAME + delete process.env.WSL_INTEROP + Object.defineProperty(process, "platform", { value: "win32", writable: true }) + expect(getWindowsHomeFromWSL()).toBe(null) + }) + + it("should find Windows home directory at /mnt/c/Users/username", () => { + process.env.USER = "testuser" + vi.mocked(fs.accessSync).mockImplementation((path) => { + if (path === "/mnt/c/Users/testuser") { + return undefined + } + throw new Error("File not found") + }) + expect(getWindowsHomeFromWSL()).toBe("/mnt/c/Users/testuser") + }) + + it("should find Windows home directory at /mnt/c/users/username (lowercase)", () => { + process.env.USER = "testuser" + vi.mocked(fs.accessSync).mockImplementation((path) => { + if (path === "/mnt/c/users/testuser") { + return undefined + } + throw new Error("File not found") + }) + expect(getWindowsHomeFromWSL()).toBe("/mnt/c/users/testuser") + }) + + it("should check D: drive if C: drive not found", () => { + process.env.USER = "testuser" + vi.mocked(fs.accessSync).mockImplementation((path) => { + if (path === "/mnt/d/Users/testuser") { + return undefined + } + throw new Error("File not found") + }) + expect(getWindowsHomeFromWSL()).toBe("/mnt/d/Users/testuser") + }) + + it("should use WSL_USER_NAME if available", () => { + process.env.WSL_USER_NAME = "wsluser" + vi.mocked(fs.accessSync).mockImplementation((path) => { + if (path === "/mnt/c/Users/wsluser") { + return undefined + } + throw new Error("File not found") + }) + expect(getWindowsHomeFromWSL()).toBe("/mnt/c/Users/wsluser") + }) + + it("should convert USERPROFILE Windows path to WSL path", () => { + process.env.USERPROFILE = "C:\\Users\\winuser" + vi.mocked(fs.accessSync).mockImplementation((path) => { + if (path === "/mnt/c/Users/winuser") { + return undefined + } + throw new Error("File not found") + }) + expect(getWindowsHomeFromWSL()).toBe("/mnt/c/Users/winuser") + }) + + it("should handle USERPROFILE with different drive letter", () => { + process.env.USERPROFILE = "D:\\Users\\winuser" + vi.mocked(fs.accessSync).mockImplementation((path) => { + if (path === "/mnt/d/Users/winuser") { + return undefined + } + throw new Error("File not found") + }) + expect(getWindowsHomeFromWSL()).toBe("/mnt/d/Users/winuser") + }) + + it("should return null when no Windows home directory is found", () => { + process.env.USER = "testuser" + delete process.env.USERPROFILE + vi.mocked(fs.accessSync).mockImplementation(() => { + throw new Error("File not found") + }) + expect(getWindowsHomeFromWSL()).toBe(null) + }) + }) +}) diff --git a/src/utils/wsl.ts b/src/utils/wsl.ts new file mode 100644 index 0000000000..222bccc1c2 --- /dev/null +++ b/src/utils/wsl.ts @@ -0,0 +1,98 @@ +import * as fs from "fs" +import * as os from "os" + +/** + * Detects if the current environment is running inside WSL (Windows Subsystem for Linux) + * @returns true if running in WSL, false otherwise + */ +export function isWSL(): boolean { + // WSL detection based on multiple indicators + + // 1. Check for WSL environment variable (WSL 2) + if (process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP) { + return true + } + + // 2. Check for /proc/version containing Microsoft or WSL + try { + const procVersion = fs.readFileSync("/proc/version", "utf8") + if (procVersion.toLowerCase().includes("microsoft") || procVersion.toLowerCase().includes("wsl")) { + return true + } + } catch { + // File doesn't exist or can't be read, not WSL + } + + // 3. Check for /proc/sys/fs/binfmt_misc/WSLInterop (WSL 2) + try { + fs.accessSync("/proc/sys/fs/binfmt_misc/WSLInterop", fs.constants.F_OK) + return true + } catch { + // File doesn't exist, might not be WSL 2 + } + + // 4. Check if running on Linux but with Windows-style paths in environment + if (process.platform === "linux") { + // Check for Windows paths in PATH environment variable + const pathEnv = process.env.PATH || "" + if (pathEnv.includes("/mnt/c/") || pathEnv.includes("\\")) { + return true + } + + // Check for WSLENV variable (used for sharing environment variables between Windows and WSL) + if (process.env.WSLENV) { + return true + } + } + + return false +} + +/** + * Gets the Windows user home directory from within WSL + * @returns The Windows home directory path or null if not in WSL or cannot determine + */ +export function getWindowsHomeFromWSL(): string | null { + if (!isWSL()) { + return null + } + + // Try to get Windows username from environment + const windowsUsername = process.env.WSL_USER_NAME || process.env.USER || os.userInfo().username + + // Common Windows home directory patterns in WSL + const possiblePaths = [ + `/mnt/c/Users/${windowsUsername}`, + `/mnt/c/users/${windowsUsername}`, + `/mnt/d/Users/${windowsUsername}`, + `/mnt/d/users/${windowsUsername}`, + ] + + // Check which path exists + for (const path of possiblePaths) { + try { + fs.accessSync(path, fs.constants.F_OK) + return path + } catch { + // Path doesn't exist, try next + } + } + + // Fallback: try to read from USERPROFILE if it's set (might be shared from Windows) + if (process.env.USERPROFILE) { + // Convert Windows path to WSL path (C:\Users\username -> /mnt/c/Users/username) + const windowsPath = process.env.USERPROFILE + const wslPath = windowsPath + .replace(/^([A-Z]):/i, (_, drive) => `/mnt/${drive.toLowerCase()}`) + .replace(/\\/g, "/") + + try { + fs.accessSync(wslPath, fs.constants.F_OK) + return wslPath + } catch { + // Path doesn't exist + } + } + + return null +}