From 965b9de5cf1adb5e9b1d387992d8a6d0ccd8e007 Mon Sep 17 00:00:00 2001 From: "openai-code-agent[bot]" <242516109+Codex@users.noreply.github.com> Date: Mon, 16 Mar 2026 02:34:29 +0000 Subject: [PATCH 1/2] Initial plan From e8652e61bbf304627d0ee18ab40bc5b1fe4c8b9c Mon Sep 17 00:00:00 2001 From: "openai-code-agent[bot]" <242516109+Codex@users.noreply.github.com> Date: Mon, 16 Mar 2026 02:41:09 +0000 Subject: [PATCH 2/2] fix: add wsl support for claude code --- electron/src/ipc/cc-import.ts | 11 +++- electron/src/ipc/claude-sessions.ts | 12 ++++- .../src/lib/__tests__/claude-binary.test.ts | 47 +++++++++++++++++ electron/src/lib/__tests__/wsl-path.test.ts | 26 ++++++++++ electron/src/lib/claude-binary.ts | 51 ++++++++++++++++++- electron/src/lib/wsl-path.ts | 39 ++++++++++++++ 6 files changed, 181 insertions(+), 5 deletions(-) create mode 100644 electron/src/lib/__tests__/wsl-path.test.ts create mode 100644 electron/src/lib/wsl-path.ts diff --git a/electron/src/ipc/cc-import.ts b/electron/src/ipc/cc-import.ts index 78a60ca..75a44d7 100644 --- a/electron/src/ipc/cc-import.ts +++ b/electron/src/ipc/cc-import.ts @@ -4,6 +4,7 @@ import fs from "fs"; import crypto from "crypto"; import os from "os"; import { reportError } from "../lib/error-utils"; +import { getLikelyWslHome, getWslWindowsPrefix, parseWslPath } from "../lib/wsl-path"; interface SessionPreview { firstUserMessage: string; @@ -27,7 +28,15 @@ interface UIMessage { } function getCCProjectDir(projectPath: string): string { - const hash = projectPath.replace(/\//g, "-"); + const wsl = parseWslPath(projectPath); + if (process.platform === "win32" && wsl) { + const hash = wsl.unixPath.replace(/\//g, "-"); + const wslHome = getLikelyWslHome(wsl.unixPath); + const wslPrefix = getWslWindowsPrefix(wsl.distro); + return path.join(wslPrefix, wslHome.replace(/\//g, "\\"), ".claude", "projects", hash); + } + + const hash = projectPath.replace(/[\\/]/g, "-"); return path.join(os.homedir(), ".claude", "projects", hash); } diff --git a/electron/src/ipc/claude-sessions.ts b/electron/src/ipc/claude-sessions.ts index 0b00b6d..58cb9e9 100644 --- a/electron/src/ipc/claude-sessions.ts +++ b/electron/src/ipc/claude-sessions.ts @@ -11,6 +11,7 @@ import { getClaudeModelsCache, setClaudeModelsCache } from "../lib/claude-model- import { reportError } from "../lib/error-utils"; import { getClaudeBinaryMetadata, getClaudeBinaryPath, getClaudeBinaryStatus, getClaudeVersion } from "../lib/claude-binary"; import { captureEvent } from "../lib/posthog"; +import { maybeToWslPath } from "../lib/wsl-path"; /** SDK options for file checkpointing — enables Write/Edit/NotebookEdit revert support */ function fileCheckpointOptions(): Record { @@ -45,6 +46,12 @@ interface SessionEntry { export const sessions = new Map(); +function resolveCwd(raw?: string): string { + const candidate = raw && raw.trim() ? raw : process.cwd(); + const translated = maybeToWslPath(candidate); + return translated ?? candidate; +} + function applyPermissionModeOptions( queryOptions: Record, permissionMode?: string, @@ -498,7 +505,7 @@ async function restartSession( const opts = session.startOptions; const mcpServers = mcpServersOverride ?? opts.mcpServers; - const cwd = cwdOverride || opts.cwd || process.cwd(); + const cwd = resolveCwd(cwdOverride ?? opts.cwd); const query = await getSDK(); const newChannel = new AsyncChannel(); const cliPath = await getClaudeBinaryPath(); @@ -631,8 +638,9 @@ export function register(getMainWindow: () => BrowserWindow | null): void { const cliPath = await getClaudeBinaryPath(); logSdkCliPath(`start session=${sessionId.slice(0, 8)}`, cliPath); + const cwd = resolveCwd(options.cwd); const queryOptions: Record = { - cwd: options.cwd || process.cwd(), + cwd, includePartialMessages: true, thinking: buildThinkingConfig(), canUseTool, diff --git a/electron/src/lib/__tests__/claude-binary.test.ts b/electron/src/lib/__tests__/claude-binary.test.ts index 98244c8..2cf837a 100644 --- a/electron/src/lib/__tests__/claude-binary.test.ts +++ b/electron/src/lib/__tests__/claude-binary.test.ts @@ -7,6 +7,11 @@ const { mockGetCliPath, mockLog, mockSpawn, + mockExistsSync, + mockReadFileSync, + mockWriteFileSync, + mockMkdirSync, + mockTmpdir, } = vi.hoisted(() => ({ mockAccessSync: vi.fn(), mockExecFileSync: vi.fn(), @@ -18,18 +23,28 @@ const { mockGetCliPath: vi.fn(() => "/app.asar.unpacked/node_modules/@anthropic-ai/claude-agent-sdk/cli.js"), mockLog: vi.fn(), mockSpawn: vi.fn(), + mockExistsSync: vi.fn(), + mockReadFileSync: vi.fn(), + mockWriteFileSync: vi.fn(), + mockMkdirSync: vi.fn(), + mockTmpdir: vi.fn(() => "/tmp"), })); vi.mock("fs", () => ({ default: { accessSync: mockAccessSync, constants: { X_OK: 1 }, + existsSync: mockExistsSync, + readFileSync: mockReadFileSync, + writeFileSync: mockWriteFileSync, + mkdirSync: mockMkdirSync, }, })); vi.mock("os", () => ({ default: { homedir: () => "/Users/tester", + tmpdir: mockTmpdir, }, })); @@ -77,6 +92,13 @@ describe("claude binary resolution", () => { mockGetCliPath.mockReturnValue("/app.asar.unpacked/node_modules/@anthropic-ai/claude-agent-sdk/cli.js"); mockLog.mockReset(); mockSpawn.mockReset(); + mockExistsSync.mockReset(); + mockExistsSync.mockReturnValue(false); + mockReadFileSync.mockReset(); + mockWriteFileSync.mockReset(); + mockMkdirSync.mockReset(); + mockTmpdir.mockReset(); + mockTmpdir.mockReturnValue("/tmp"); }); it("uses a valid custom executable path", async () => { @@ -138,6 +160,31 @@ describe("claude binary resolution", () => { ); }); + it("wraps a WSL-installed Claude binary on Windows", async () => { + const platform = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + try { + mockExecFileSync.mockImplementation((command: string, args?: string[]) => { + if (command === "where") throw new Error("missing"); + if (command === "wsl.exe") { + expect(args).toEqual(["-e", "which", "claude"]); + return "/home/user/.local/bin/claude\n"; + } + throw new Error("unexpected"); + }); + mockReadFileSync.mockImplementation(() => { + throw new Error("missing"); + }); + const wrapperPath = "/tmp/harnss-claude-wsl/claude-wsl-wrapper.cmd"; + allowExecutable(wrapperPath); + + const mod = await loadModule(); + + await expect(mod.getClaudeBinaryPath({ installIfMissing: false })).resolves.toBe(wrapperPath); + } finally { + platform.mockRestore(); + } + }); + it("reports status without triggering install", async () => { allowExecutable("/Users/tester/.local/bin/claude"); const mod = await loadModule(); diff --git a/electron/src/lib/__tests__/wsl-path.test.ts b/electron/src/lib/__tests__/wsl-path.test.ts new file mode 100644 index 0000000..829440e --- /dev/null +++ b/electron/src/lib/__tests__/wsl-path.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { getLikelyWslHome, maybeToWslPath, parseWslPath, getWslWindowsPrefix } from "../wsl-path"; + +describe("wsl path helpers", () => { + it("parses UNC WSL paths into distro and unix paths", () => { + const info = parseWslPath("\\\\wsl$\\Ubuntu\\home\\user\\project"); + expect(info).toEqual({ distro: "Ubuntu", unixPath: "/home/user/project" }); + + const localhost = parseWslPath("//wsl.localhost/Ubuntu/home/user/project"); + expect(localhost).toEqual({ distro: "Ubuntu", unixPath: "/home/user/project" }); + }); + + it("returns the original path when not a WSL UNC path", () => { + expect(maybeToWslPath("C:\\\\Users\\\\tester")).toBe("C:\\\\Users\\\\tester"); + expect(maybeToWslPath(undefined)).toBeUndefined(); + }); + + it("guesses the user home directory from a WSL path", () => { + expect(getLikelyWslHome("/home/alice/code")).toBe("/home/alice"); + expect(getLikelyWslHome("/var/www")).toBe("/root"); + }); + + it("builds the UNC prefix for a distro using wsl.localhost", () => { + expect(getWslWindowsPrefix("Ubuntu")).toBe("\\\\wsl.localhost\\Ubuntu"); + }); +}); diff --git a/electron/src/lib/claude-binary.ts b/electron/src/lib/claude-binary.ts index 3ed0c45..57061ef 100644 --- a/electron/src/lib/claude-binary.ts +++ b/electron/src/lib/claude-binary.ts @@ -8,7 +8,7 @@ import { log } from "./logger"; import { getCliPath } from "./sdk"; export type ClaudeBinarySource = "auto" | "managed" | "custom"; -export type ClaudeBinaryResolutionStrategy = "custom" | "env" | "known" | "path" | "sdk-fallback"; +export type ClaudeBinaryResolutionStrategy = "custom" | "env" | "known" | "path" | "wsl" | "sdk-fallback"; interface ResolveClaudeBinaryOptions { installIfMissing?: boolean; @@ -62,6 +62,35 @@ function getKnownPaths(): string[] { return [path.join(os.homedir(), ".local", "bin", "claude")]; } +const WSL_WRAPPER_DIR = path.join(os.tmpdir(), "harnss-claude-wsl"); +const WSL_WRAPPER_NAME = "claude-wsl-wrapper.cmd"; + +function getWslWrapperPath(): string { + return path.join(WSL_WRAPPER_DIR, WSL_WRAPPER_NAME); +} + +function ensureWslWrapper(wslPath: string): string | null { + const wrapperPath = getWslWrapperPath(); + const script = `@echo off\r\nwsl.exe -e ${wslPath} %*\r\n`; + + try { + const existing = fs.readFileSync(wrapperPath, "utf-8"); + if (existing === script) return wrapperPath; + } catch { + // Missing or unreadable — fall through to rewrite + } + + try { + fs.mkdirSync(WSL_WRAPPER_DIR, { recursive: true }); + fs.writeFileSync(wrapperPath, script, { encoding: "utf-8" }); + } catch { + // If we fail to create the wrapper, fall back to null so other strategies can try + return null; + } + + return wrapperPath; +} + function isScriptExecutable(filePath: string): boolean { return [".js", ".mjs", ".cjs", ".ts", ".mts", ".cts"].includes(path.extname(filePath)); } @@ -106,6 +135,23 @@ function resolveFromPathLookup(): ClaudeBinaryResolution | null { } } +function resolveFromWsl(): ClaudeBinaryResolution | null { + if (process.platform !== "win32") return null; + try { + const output = execFileSync("wsl.exe", ["-e", "which", "claude"], { + encoding: "utf-8", + timeout: 10000, + windowsHide: true, + }).trim(); + if (!output || !output.startsWith("/")) return null; + const wrapper = ensureWslWrapper(output); + if (!wrapper) return null; + return { strategy: "wsl", path: wrapper }; + } catch { + return null; + } +} + function resolveSdkFallback(): ClaudeBinaryResolution | null { const cliPath = getCliPath(); return cliPath ? { strategy: "sdk-fallback", path: cliPath } : null; @@ -122,7 +168,8 @@ function resolveClaudeBinarySync(options?: ResolveClaudeBinaryOptions): ClaudeBi const resolution = resolveFromEnv() ?? resolveFromKnownPaths() ?? - resolveFromPathLookup(); + resolveFromPathLookup() ?? + resolveFromWsl(); if (resolution) return resolution; if (allowSdkFallback && source === "auto") { diff --git a/electron/src/lib/wsl-path.ts b/electron/src/lib/wsl-path.ts new file mode 100644 index 0000000..e0cbea3 --- /dev/null +++ b/electron/src/lib/wsl-path.ts @@ -0,0 +1,39 @@ +export interface WslPathInfo { + distro: string; + unixPath: string; +} + +const WSL_PATH_REGEX = /^\/\/wsl(?:\.localhost|\$)\/([^/]+)(\/.*)/i; + +/** + * Detect a Windows WSL UNC path (\\wsl$\\Distro\\... or \\wsl.localhost\\Distro\\...) + * and return the distro name plus the corresponding WSL unix path. + */ +export function parseWslPath(rawPath?: string): WslPathInfo | null { + if (!rawPath) return null; + const normalized = rawPath.replace(/\\/g, "/"); + const match = normalized.match(WSL_PATH_REGEX); + if (!match) return null; + + const distro = match[1]; + const unixPath = match[2] || "/"; + return { distro, unixPath }; +} + +/** Best-effort guess of the WSL user's home directory from a project path. */ +export function getLikelyWslHome(unixPath: string): string { + const homeMatch = unixPath.match(/^\/home\/[^/]+/); + if (homeMatch) return homeMatch[0]; + return "/root"; +} + +/** UNC prefix for a given WSL distro (wsl.localhost used for consistency). */ +export function getWslWindowsPrefix(distro: string): string { + return `\\\\wsl.localhost\\${distro}`; +} + +/** Translate a WSL UNC path to its unix equivalent; leave other paths untouched. */ +export function maybeToWslPath(rawPath?: string): string | undefined { + const info = parseWslPath(rawPath); + return info ? info.unixPath : rawPath; +}