Unverified 提交 89aa6105 authored 作者: Will Chen's avatar Will Chen 提交者: GitHub

Fix WSL PATH contamination causing git command failures on Windows (#2282)

## Summary - Adds `getWindowsSanitizedEnv()` function that filters WSL-related PATH entries on Windows - Creates `execGit()` wrapper that applies sanitized environment to all dugite exec calls - Prevents WSL relay from intercepting git commands when WSL is installed but misconfigured Fixes #2194 ## Test plan - [ ] Test on Windows machine with WSL installed - [ ] Verify git commit operations work with native Git enabled - [ ] Verify no regression on macOS/Linux (sanitization only activates on Windows) 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Sanitize PATH on Windows to stop WSL from hijacking git commands, ensuring native Git runs reliably. Fixes #2194. - **Bug Fixes** - Added getWindowsSanitizedEnv to filter WSL PATH entries (\\wsl$, \\wsl.localhost, windowsapps, /mnt, /usr, /bin, /home) and handle PATH key casing. - Wrapped all dugite exec calls with execGit on Windows; added '--' separators and remote URL validation. - Prevents WSL hijacking and errors like "execvpe(/bin/bash) failed: No such file or directory". <sup>Written for commit ad0a93ceb660831d8c4abf3e05dd91b8523dd9f1. Summary will update on new commits.</sup> <!-- End of auto-generated description by cubic. --> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Fixes Windows WSL PATH contamination by introducing a sanitized env and routing all native Git calls through a wrapper. > > - **Add** `getWindowsSanitizedEnv()` to filter WSL-related PATH entries on Windows and handle PATH key casing > - **Introduce** `execGit()` wrapper and replace all `dugite.exec` usages to apply sanitized env on Windows > - **Harden** native Git calls: add `--` separators for path/URL args (e.g., `clone`, `check-ignore`), and validate `remoteUrl` to prevent option injection > - Touches most native Git flows: status, commit, checkout, branch ops, clone, push/pull/fetch/merge/rebase, file retrieval, and conflict detection > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit ad0a93ceb660831d8c4abf3e05dd91b8523dd9f1. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/2282"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end --> --------- Co-authored-by: 's avatarClaude Opus 4.5 <noreply@anthropic.com>
上级 6ba51165
import { getGitAuthor } from "./git_author";
import git from "isomorphic-git";
import http from "isomorphic-git/http/node";
import { exec } from "dugite";
import {
exec,
type IGitStringExecutionOptions,
type IGitStringResult,
} from "dugite";
import fs from "node:fs";
import { promises as fsPromises } from "node:fs";
import pathModule from "node:path";
import { platform } from "node:os";
import { readSettings } from "../../main/settings";
import log from "electron-log";
import { normalizePath } from "../../../shared/normalizePath";
import type { UncommittedFile, UncommittedFileStatus } from "@/ipc/types";
const logger = log.scope("git_utils");
/**
* Returns a sanitized environment for git commands on Windows.
* Filters out WSL-related PATH entries that can cause WSL interop issues.
* On non-Windows platforms, returns undefined (use default environment).
*
* Issue: https://github.com/dyad-sh/dyad/issues/2194
* When WSL is installed on Windows, the PATH can contain entries that cause
* git commands to be intercepted by WSL's relay system, resulting in errors
* like "execvpe(/bin/bash) failed: No such file or directory".
*/
function getWindowsSanitizedEnv():
| Record<string, string | undefined>
| undefined {
if (platform() !== "win32") {
return undefined;
}
// On Windows, the PATH environment variable can be stored with different casings
// (e.g., "PATH", "Path", "path"). We need to find the actual key used to avoid
// creating duplicate entries with different casings.
const pathKey =
Object.keys(process.env).find((key) => key.toUpperCase() === "PATH") ??
"PATH";
const currentPath = process.env[pathKey] ?? "";
const pathSeparator = ";";
// Filter out PATH entries that could trigger WSL interop
const sanitizedPathEntries = currentPath
.split(pathSeparator)
.filter((entry) => {
const lowerEntry = entry.toLowerCase();
// Filter out WSL-related paths:
// - \\wsl$\ or \\wsl.localhost\ network paths
// - Paths containing 'windowsapps' that might have WSL shims
// - Linux-style paths that somehow got into Windows PATH
if (
lowerEntry.includes("\\wsl$\\") ||
lowerEntry.includes("\\wsl.localhost\\") ||
lowerEntry.includes("windowsapps") ||
lowerEntry.startsWith("/mnt/") ||
lowerEntry.startsWith("/usr/") ||
lowerEntry.startsWith("/bin/") ||
lowerEntry.startsWith("/home/")
) {
logger.debug(`Filtering WSL-related PATH entry: ${entry}`);
return false;
}
return true;
});
return {
...process.env,
[pathKey]: sanitizedPathEntries.join(pathSeparator),
};
}
/**
* Wrapper around dugite's exec that uses a sanitized environment on Windows
* to prevent WSL interop issues.
*/
async function execGit(
args: string[],
path: string,
options?: IGitStringExecutionOptions,
): Promise<IGitStringResult> {
const sanitizedEnv = getWindowsSanitizedEnv();
// Only create execOptions if we need to modify the environment
// On Windows: merge sanitized env with any caller-provided env, ensuring sanitized PATH takes precedence
// On non-Windows: pass through options unchanged (dugite will use process.env by default)
if (sanitizedEnv) {
// Find the PATH key used in the sanitized env
const pathKey =
Object.keys(sanitizedEnv).find((key) => key.toUpperCase() === "PATH") ??
"PATH";
const execOptions: IGitStringExecutionOptions = {
...options,
env: {
...sanitizedEnv,
...options?.env,
// Ensure sanitized PATH always takes precedence to prevent WSL contamination
[pathKey]: sanitizedEnv[pathKey],
},
};
return exec(args, path, execOptions);
}
// On non-Windows, pass options through unchanged
return exec(args, path, options);
}
import type {
GitBaseParams,
GitFileParams,
......@@ -39,7 +135,7 @@ async function execOrThrow(
path: string,
errorMessage?: string,
): Promise<void> {
const result = await exec(args, path);
const result = await execGit(args, path);
if (result.exitCode !== 0) {
const errorDetails = result.stderr.trim() || result.stdout.trim();
const error = errorMessage
......@@ -82,7 +178,7 @@ export async function gitAddSafeDirectory(directory: string): Promise<void> {
try {
// First check if the directory is already in the safe.directory list
const checkResult = await exec(
const checkResult = await execGit(
["config", "--global", "--get-all", "safe.directory"],
".",
);
......@@ -99,7 +195,7 @@ export async function gitAddSafeDirectory(directory: string): Promise<void> {
return;
}
const result = await exec(
const result = await execGit(
["config", "--global", "--add", "safe.directory", directory],
".",
);
......@@ -123,7 +219,7 @@ export async function getCurrentCommitHash({
}: GitInitParams): Promise<string> {
const settings = readSettings();
if (settings.enableNativeGit) {
const result = await exec(["rev-parse", ref], path);
const result = await execGit(["rev-parse", ref], path);
if (result.exitCode !== 0) {
throw new Error(
`Failed to resolve ref '${ref}': ${result.stderr.trim() || result.stdout.trim()}`,
......@@ -146,7 +242,7 @@ export async function isGitStatusClean({
}): Promise<boolean> {
const settings = readSettings();
if (settings.enableNativeGit) {
const result = await exec(["status", "--porcelain"], path);
const result = await execGit(["status", "--porcelain"], path);
if (result.exitCode !== 0) {
throw new Error(`Failed to get status: ${result.stderr}`);
......@@ -178,7 +274,7 @@ export async function gitCommit({
const args = await withGitAuthor(commitArgs);
await execOrThrow(args, path, "Failed to create commit");
// Get the new commit hash
const result = await exec(["rev-parse", "HEAD"], path);
const result = await execGit(["rev-parse", "HEAD"], path);
if (result.exitCode !== 0) {
throw new Error(
`Failed to get commit hash: ${result.stderr.trim() || result.stdout.trim()}`,
......@@ -220,7 +316,7 @@ export async function gitStageToRevert({
const settings = readSettings();
if (settings.enableNativeGit) {
// Get the current HEAD commit hash
const currentHeadResult = await exec(["rev-parse", "HEAD"], path);
const currentHeadResult = await execGit(["rev-parse", "HEAD"], path);
if (currentHeadResult.exitCode !== 0) {
throw new Error(
`Failed to get current commit: ${currentHeadResult.stderr.trim() || currentHeadResult.stdout.trim()}`,
......@@ -235,7 +331,7 @@ export async function gitStageToRevert({
}
// Safety: refuse to run if the work-tree isn't clean.
const statusResult = await exec(["status", "--porcelain"], path);
const statusResult = await execGit(["status", "--porcelain"], path);
if (statusResult.exitCode !== 0) {
throw new Error(
`Failed to get status: ${statusResult.stderr.trim() || statusResult.stdout.trim()}`,
......@@ -399,7 +495,7 @@ export async function getGitUncommittedFiles({
}: GitBaseParams): Promise<string[]> {
const settings = readSettings();
if (settings.enableNativeGit) {
const result = await exec(["status", "--porcelain"], path);
const result = await execGit(["status", "--porcelain"], path);
if (result.exitCode !== 0) {
throw new Error(
`Failed to get uncommitted files: ${result.stderr.trim() || result.stdout.trim()}`,
......@@ -427,7 +523,7 @@ export async function getGitUncommittedFilesWithStatus({
}: GitBaseParams): Promise<UncommittedFile[]> {
const settings = readSettings();
if (settings.enableNativeGit) {
const result = await exec(["status", "--porcelain"], path);
const result = await execGit(["status", "--porcelain"], path);
if (result.exitCode !== 0) {
throw new Error(
`Failed to get uncommitted files: ${result.stderr.trim() || result.stdout.trim()}`,
......@@ -507,7 +603,7 @@ export async function getFileAtCommit({
const settings = readSettings();
if (settings.enableNativeGit) {
try {
const result = await exec(["show", `${commitHash}:${filePath}`], path);
const result = await execGit(["show", `${commitHash}:${filePath}`], path);
if (result.exitCode !== 0) {
// File doesn't exist at this commit or other error
return null;
......@@ -545,7 +641,7 @@ export async function gitListBranches({
const settings = readSettings();
if (settings.enableNativeGit) {
const result = await exec(["branch", "--list"], path);
const result = await execGit(["branch", "--list"], path);
if (result.exitCode !== 0) {
throw new Error(result.stderr.toString());
......@@ -572,7 +668,7 @@ export async function gitListRemoteBranches({
const settings = readSettings();
if (settings.enableNativeGit) {
const result = await exec(["branch", "-r", "--list"], path);
const result = await execGit(["branch", "-r", "--list"], path);
if (result.exitCode !== 0) {
throw new Error(result.stderr.toString());
......@@ -613,7 +709,7 @@ export async function gitRenameBranch({
if (settings.enableNativeGit) {
// git branch -m oldBranch newBranch
const result = await exec(["branch", "-m", oldBranch, newBranch], path);
const result = await execGit(["branch", "-m", oldBranch, newBranch], path);
if (result.exitCode !== 0) {
throw new Error(result.stderr.toString());
}
......@@ -680,8 +776,8 @@ export async function gitClone({
if (singleBranch) {
args.push("--single-branch");
}
args.push(finalUrl, path);
const result = await exec(args, ".");
args.push("--", finalUrl, path);
const result = await execGit(args, ".");
if (result.exitCode !== 0) {
throw new Error(result.stderr.toString());
......@@ -713,15 +809,24 @@ export async function gitSetRemoteUrl({
}: GitSetRemoteUrlParams): Promise<void> {
const settings = readSettings();
// Validate remoteUrl to prevent argument injection attacks
// URLs starting with "-" could be interpreted as command-line options
if (remoteUrl.startsWith("-")) {
throw new Error("Invalid remote URL");
}
if (settings.enableNativeGit) {
// Dugite version
try {
// Try to add the remote
const result = await exec(["remote", "add", "origin", remoteUrl], path);
const result = await execGit(
["remote", "add", "origin", remoteUrl],
path,
);
// If remote already exists, update it instead
if (result.exitCode !== 0 && result.stderr.includes("already exists")) {
const updateResult = await exec(
const updateResult = await execGit(
["remote", "set-url", "origin", remoteUrl],
path,
);
......@@ -774,7 +879,7 @@ export async function gitPush({
} else if (force) {
args.push("--force");
}
const result = await exec(args, path);
const result = await execGit(args, path);
if (result.exitCode !== 0) {
const errorMsg = result.stderr.toString() || result.stdout.toString();
throw new Error(`Git push failed: ${errorMsg}`);
......@@ -886,7 +991,7 @@ export async function gitCurrentBranch({
const settings = readSettings();
if (settings.enableNativeGit) {
// Dugite version
const result = await exec(["branch", "--show-current"], path);
const result = await execGit(["branch", "--show-current"], path);
if (result.exitCode !== 0) {
throw new Error(
`Failed to get current branch: ${result.stderr.trim() || result.stdout.trim()}`,
......@@ -932,7 +1037,7 @@ export async function gitIsIgnored({
if (settings.enableNativeGit) {
// Dugite version
// git check-ignore file
const result = await exec(["check-ignore", filepath], path);
const result = await execGit(["check-ignore", "--", filepath], path);
// If exitCode == 0 → file is ignored
if (result.exitCode === 0) return true;
......@@ -967,7 +1072,7 @@ export async function gitLogNative(
"HEAD",
];
const logResult = await exec(logArgs, path);
const logResult = await execGit(logArgs, path);
if (logResult.exitCode !== 0) {
throw new Error(logResult.stderr.toString());
......@@ -1224,7 +1329,7 @@ export async function gitGetMergeConflicts({
const settings = readSettings();
if (settings.enableNativeGit) {
// git diff --name-only --diff-filter=U
const result = (await exec(
const result = (await execGit(
["diff", "--name-only", "--diff-filter=U"],
path,
)) as unknown as {
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论