Skip to content
Draft
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);
}

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);
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`;

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";
}

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