fix: harden Windows exec shell edge cases

Maintainer edit: preserve raw cmd.exe quoting for shell='cmd', propagate native exit codes through the default PowerShell path, and keep quoted Windows executable paths invokable under PowerShell.
This commit is contained in:
chengyongru
2026-07-06 12:11:25 +08:00
committed by Xubin Ren
parent 8c4a74ee3c
commit 122cf4213b
2 changed files with 98 additions and 23 deletions
+29 -3
View File
@@ -491,14 +491,17 @@ class ExecTool(Tool):
program = shell_program or default_program
program_name = PureWindowsPath(program).name.lower()
if program_name in ("cmd", "cmd.exe"):
return await asyncio.create_subprocess_exec(
program, "/c", command,
cmd_env = {**env, "COMSPEC": program}
return await asyncio.create_subprocess_shell(
command,
stdin=stdin,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd,
env=env,
env=cmd_env,
)
command = ExecTool._normalize_powershell_command(command)
command = f"{command}\nif ($LASTEXITCODE -ne $null) {{ exit $LASTEXITCODE }}"
return await asyncio.create_subprocess_exec(
program, "-NoProfile", "-NonInteractive", "-Command", command,
stdin=stdin,
@@ -522,6 +525,29 @@ class ExecTool(Tool):
env=env,
)
@staticmethod
def _normalize_powershell_command(command: str) -> str:
stripped = command.lstrip()
if not stripped or stripped[0] not in {"'", '"'}:
return command
quote = stripped[0]
end = stripped.find(quote, 1)
if end == -1 or end + 1 >= len(stripped) or not stripped[end + 1].isspace():
return command
executable = stripped[1:end]
looks_like_windows_executable = (
bool(re.match(r"^[A-Za-z]:[\\/]", executable))
or executable.startswith(r"\\")
or executable.lower().endswith((".exe", ".cmd", ".bat", ".ps1"))
)
if not looks_like_windows_executable:
return command
leading = command[: len(command) - len(stripped)]
return f"{leading}& {stripped}"
@staticmethod
def _resolve_shell(shell: str | None) -> tuple[str | None, str | None]:
if not shell: