Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion electron/src/ipc/cc-import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Comment on lines +33 to +36
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getLikelyWslHome() infers the WSL user home from the project path and falls back to /root. For WSL projects located outside /home/<user> (e.g. /mnt/c/...), this will point session import at \\wsl.localhost\<distro>\root\.claude\..., which is usually wrong and will still yield “No sessions found”. Consider resolving the actual WSL $HOME for the distro via wsl.exe -d <distro> -e sh -lc 'printf %s "$HOME"' (with caching) instead of guessing from the project path.

Copilot uses AI. Check for mistakes.
}

const hash = projectPath.replace(/[\\/]/g, "-");
return path.join(os.homedir(), ".claude", "projects", hash);
}

Expand Down
12 changes: 10 additions & 2 deletions electron/src/ipc/claude-sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> {
Expand Down Expand Up @@ -45,6 +46,12 @@ interface SessionEntry {

export const sessions = new Map<string, SessionEntry>();

function resolveCwd(raw?: string): string {
const candidate = raw && raw.trim() ? raw : process.cwd();
const translated = maybeToWslPath(candidate);
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolveCwd() always translates WSL UNC paths to a Unix path. That’s correct when the Claude executable is the WSL wrapper, but it will break if a Windows-native claude binary is selected (spawn cwd becomes /home/... on Windows). Consider only translating when the resolved Claude binary strategy/path indicates WSL (e.g., when cliPath is the WSL wrapper or resolution strategy is wsl).

Suggested change
const translated = maybeToWslPath(candidate);
let shouldTranslate = false;
try {
const cliInfo: unknown = getCliPath();
if (cliInfo && typeof cliInfo === "object") {
const info = cliInfo as {
strategy?: string;
resolutionStrategy?: string;
cliPath?: string;
path?: string;
};
const strategy = info.strategy ?? info.resolutionStrategy;
const cliPath = info.cliPath ?? info.path;
if (strategy === "wsl") {
shouldTranslate = true;
} else if (typeof cliPath === "string") {
const lowerPath = cliPath.toLowerCase();
if (lowerPath.includes("wsl")) {
shouldTranslate = true;
}
}
}
} catch {
// If CLI resolution fails for any reason, fall back to no translation.
}
const translated = shouldTranslate ? maybeToWslPath(candidate) : null;

Copilot uses AI. Check for mistakes.
return translated ?? candidate;
}

function applyPermissionModeOptions(
queryOptions: Record<string, unknown>,
permissionMode?: string,
Expand Down Expand Up @@ -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<unknown>();
const cliPath = await getClaudeBinaryPath();
Expand Down Expand Up @@ -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<string, unknown> = {
cwd: options.cwd || process.cwd(),
cwd,
includePartialMessages: true,
thinking: buildThinkingConfig(),
canUseTool,
Expand Down
47 changes: 47 additions & 0 deletions electron/src/lib/__tests__/claude-binary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ const {
mockGetCliPath,
mockLog,
mockSpawn,
mockExistsSync,
mockReadFileSync,
mockWriteFileSync,
mockMkdirSync,
mockTmpdir,
} = vi.hoisted(() => ({
mockAccessSync: vi.fn(),
mockExecFileSync: vi.fn(),
Expand All @@ -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,
},
}));

Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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();
Expand Down
26 changes: 26 additions & 0 deletions electron/src/lib/__tests__/wsl-path.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
51 changes: 49 additions & 2 deletions electron/src/lib/claude-binary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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`;
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generated .cmd wrapper runs wsl.exe -e ${wslPath} %* without quoting/escaping wslPath. If the resolved path contains spaces or special characters (e.g. under /mnt/c/Program Files/...), the wrapper will fail to execute. Quote the command path (and consider a -- separator) when building the wrapper script.

Suggested change
const script = `@echo off\r\nwsl.exe -e ${wslPath} %*\r\n`;
const quotedWslPath = `"${wslPath.replace(/"/g, '""')}"`;
const script = `@echo off\r\nwsl.exe -e ${quotedWslPath} -- %*\r\n`;

Copilot uses AI. Check for mistakes.

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));
}
Expand Down Expand Up @@ -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;
Expand All @@ -122,7 +168,8 @@ function resolveClaudeBinarySync(options?: ResolveClaudeBinaryOptions): ClaudeBi
const resolution =
resolveFromEnv() ??
resolveFromKnownPaths() ??
resolveFromPathLookup();
resolveFromPathLookup() ??
resolveFromWsl();

if (resolution) return resolution;
if (allowSdkFallback && source === "auto") {
Expand Down
39 changes: 39 additions & 0 deletions electron/src/lib/wsl-path.ts
Original file line number Diff line number Diff line change
@@ -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";
Comment on lines +24 to +27
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Resolve WSL home reliably for non-/home project paths

Avoid defaulting WSL home to /root when the project path is outside /home/<user>. In cc-import.ts, this helper is used to build the ~/.claude/projects directory, so a project like \\wsl.localhost\Ubuntu\mnt\c\repo maps to \\wsl.localhost\Ubuntu\root\.claude\projects\..., which is typically wrong for normal users and causes cc-sessions:list/import to return no sessions even though they exist under the real user home (for example /home/alice/.claude/projects).

Useful? React with 👍 / 👎.

}

/** 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;
}
Loading