fix: harden Windows exec shell default

Maintainer edit: prefer pwsh when available so single-line commands support modern PowerShell operators, add -NonInteractive, and parse explicit cmd.exe paths with Windows path semantics for cross-platform tests.
This commit is contained in:
chengyongru
2026-07-06 12:11:25 +08:00
committed by Xubin Ren
parent 33b1c6f601
commit 220de320fb
2 changed files with 49 additions and 8 deletions
+4 -4
View File
@@ -9,7 +9,7 @@ import shutil
import sys
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
from pathlib import Path, PureWindowsPath
from typing import Any
from loguru import logger
@@ -471,9 +471,9 @@ class ExecTool(Tool):
# Default to PowerShell so single-line and multi-line commands
# share the same shell semantics. cmd.exe is reachable via the
# explicit shell="cmd" parameter (see _resolve_shell).
default_program = shutil.which("powershell") or "powershell"
default_program = shutil.which("pwsh") or shutil.which("powershell") or "powershell"
program = shell_program or default_program
program_name = Path(program).name.lower()
program_name = PureWindowsPath(program).name.lower()
if program_name in ("cmd", "cmd.exe"):
return await asyncio.create_subprocess_shell(
command,
@@ -484,7 +484,7 @@ class ExecTool(Tool):
env=env,
)
return await asyncio.create_subprocess_exec(
program, "-NoProfile", "-Command", command,
program, "-NoProfile", "-NonInteractive", "-Command", command,
stdin=stdin,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
+45 -4
View File
@@ -6,6 +6,7 @@ platform-specific binaries (all subprocess calls are mocked).
"""
import asyncio
import shutil
import sys
from unittest.mock import AsyncMock, patch
@@ -127,8 +128,9 @@ class TestSpawnWindows:
await ExecTool._spawn("dir", r"C:\work", env)
args = mock_exec.call_args[0]
assert "powershell" in args[0].lower()
assert any(shell in args[0].lower() for shell in ("pwsh", "powershell"))
assert "-NoProfile" in args
assert "-NonInteractive" in args
assert "-Command" in args
assert "dir" in args[-1]
@@ -161,8 +163,9 @@ class TestSpawnWindows:
await ExecTool._spawn('python -c "print(1)\nprint(2)"', r"C:\work", env)
args = mock_exec.call_args[0]
assert "powershell" in args[0].lower()
assert any(shell in args[0].lower() for shell in ("pwsh", "powershell"))
assert "-NoProfile" in args
assert "-NonInteractive" in args
assert "-Command" in args
assert "print(1)" in args[-1]
assert "print(2)" in args[-1]
@@ -189,6 +192,28 @@ class TestSpawnWindows:
kwargs = mock_shell.call_args[1]
assert kwargs["cwd"] == r"C:\work"
@pytest.mark.asyncio
async def test_prefers_pwsh_when_available(self):
env = {"PATH": ""}
def fake_which(command):
if command == "pwsh":
return r"C:\Program Files\PowerShell\7\pwsh.exe"
if command == "powershell":
return r"C:\Windows\system32\WindowsPowerShell\v1.0\powershell.exe"
return None
with (
patch("nanobot.agent.tools.shell._IS_WINDOWS", True),
patch("nanobot.agent.tools.shell.shutil.which", side_effect=fake_which),
patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as mock_exec,
):
mock_exec.return_value = AsyncMock()
await ExecTool._spawn("dir", r"C:\work", env)
args = mock_exec.call_args[0]
assert "pwsh" in args[0].lower()
# ---------------------------------------------------------------------------
# path_append
@@ -532,7 +557,7 @@ class TestWindowsMultilineExec:
assert "2" in result
assert "Exit code: 0" in result
args = mock_exec.call_args[0]
assert "powershell" in args[0].lower()
assert any(shell in args[0].lower() for shell in ("pwsh", "powershell"))
@pytest.mark.asyncio
async def test_multiline_node_uses_powershell(self):
@@ -551,7 +576,7 @@ class TestWindowsMultilineExec:
assert "1" in result
args = mock_exec.call_args[0]
assert "powershell" in args[0].lower()
assert any(shell in args[0].lower() for shell in ("pwsh", "powershell"))
@pytest.mark.asyncio
async def test_single_line_uses_powershell(self):
@@ -615,6 +640,7 @@ class TestResolveShellWindows:
assert "hello" in result
args = mock_exec.call_args[0]
assert "powershell" in args[0].lower()
assert "-NonInteractive" in args
@pytest.mark.asyncio
async def test_shell_cmd_accepted(self):
@@ -644,3 +670,18 @@ class TestResolveShellWindows:
assert "Error: unsupported shell" in result
assert "Allowed: powershell, pwsh, cmd" in result
@pytest.mark.skipif(
sys.platform != "win32" or shutil.which("pwsh") is None,
reason="requires Windows with PowerShell 7",
)
class TestWindowsRealExec:
@pytest.mark.asyncio
async def test_single_line_and_separator_uses_pwsh(self):
result = await ExecTool(timeout=10).execute(command="echo before && echo after")
assert "before" in result
assert "after" in result
assert "Exit code: 0" in result