From 220de320fb6855865dc65b74cc9a882b87d79e94 Mon Sep 17 00:00:00 2001 From: chengyongru Date: Wed, 1 Jul 2026 17:45:59 +0800 Subject: [PATCH] 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. --- nanobot/agent/tools/shell.py | 8 ++--- tests/tools/test_exec_platform.py | 49 ++++++++++++++++++++++++++++--- 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py index ccbe98bd..05c612d5 100644 --- a/nanobot/agent/tools/shell.py +++ b/nanobot/agent/tools/shell.py @@ -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, diff --git a/tests/tools/test_exec_platform.py b/tests/tools/test_exec_platform.py index eacbf9da..bc0fe023 100644 --- a/tests/tools/test_exec_platform.py +++ b/tests/tools/test_exec_platform.py @@ -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