fix(exec): bypass cmd.exe for multi-line python -c commands on Windows
On Windows, cmd.exe /c treats newlines as command separators, silently dropping code after the first line in `python -c "..."` commands. This causes multi-line inline Python to produce no output with exit code 0. Detect multi-line `python -c` commands on Windows, parse them into exec args via `_split_python_c_args`, and use `create_subprocess_exec` to bypass cmd.exe entirely. Same principle as Codex's Rust `Command::args()`. Applied to both the direct execution path and the session spawn path. Added unit tests for the parser and the exec-vs-shell branching logic.
This commit is contained in:
@@ -116,7 +116,7 @@ class TestSpawnUnix:
|
||||
class TestSpawnWindows:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uses_create_subprocess_shell(self):
|
||||
async def test_single_line_uses_shell(self):
|
||||
env = {"COMSPEC": r"C:\Windows\system32\cmd.exe", "PATH": ""}
|
||||
with (
|
||||
patch("nanobot.agent.tools.shell._IS_WINDOWS", True),
|
||||
@@ -132,7 +132,7 @@ class TestSpawnWindows:
|
||||
assert kwargs["stdin"] == asyncio.subprocess.DEVNULL
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_passes_cwd_and_env(self):
|
||||
async def test_single_line_passes_cwd_and_env(self):
|
||||
env = {"PATH": "/usr/bin"}
|
||||
with (
|
||||
patch("nanobot.agent.tools.shell._IS_WINDOWS", True),
|
||||
@@ -145,6 +145,27 @@ class TestSpawnWindows:
|
||||
assert kwargs["cwd"] == r"C:\work"
|
||||
assert kwargs["env"] == env
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiline_uses_powershell(self):
|
||||
env = {"PATH": ""}
|
||||
with (
|
||||
patch("nanobot.agent.tools.shell._IS_WINDOWS", True),
|
||||
patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as mock_exec,
|
||||
):
|
||||
mock_exec.return_value = AsyncMock()
|
||||
await ExecTool._spawn('python -c "print(1)\nprint(2)"', r"C:\work", env)
|
||||
|
||||
args = mock_exec.call_args[0]
|
||||
assert args[0] == "powershell"
|
||||
assert "-NoProfile" in args
|
||||
assert "-Command" in args
|
||||
assert "print(1)" in args[-1]
|
||||
assert "print(2)" in args[-1]
|
||||
|
||||
kwargs = mock_exec.call_args[1]
|
||||
assert kwargs["cwd"] == r"C:\work"
|
||||
assert kwargs["env"] == env
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# path_append
|
||||
@@ -352,3 +373,85 @@ class TestExtractAbsolutePaths:
|
||||
cmd = "echo hello"
|
||||
paths = ExecTool._extract_absolute_paths(cmd)
|
||||
assert paths == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Windows multi-line command PowerShell fallback
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestWindowsMultilineExec:
|
||||
"""Verify multi-line commands on Windows route through PowerShell."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiline_python_uses_powershell(self):
|
||||
mock_proc = AsyncMock()
|
||||
mock_proc.communicate.return_value = (b"1\n2\n", b"")
|
||||
mock_proc.returncode = 0
|
||||
|
||||
with (
|
||||
patch("nanobot.agent.tools.shell._IS_WINDOWS", True),
|
||||
patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as mock_exec,
|
||||
patch.object(ExecTool, "_guard_command", return_value=None),
|
||||
):
|
||||
mock_exec.return_value = mock_proc
|
||||
tool = ExecTool()
|
||||
result = await tool.execute(command='python -c "print(1)\nprint(2)"')
|
||||
|
||||
assert "1" in result
|
||||
assert "2" in result
|
||||
assert "Exit code: 0" in result
|
||||
args = mock_exec.call_args[0]
|
||||
assert args[0] == "powershell"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiline_node_uses_powershell(self):
|
||||
mock_proc = AsyncMock()
|
||||
mock_proc.communicate.return_value = (b"1\n", b"")
|
||||
mock_proc.returncode = 0
|
||||
|
||||
with (
|
||||
patch("nanobot.agent.tools.shell._IS_WINDOWS", True),
|
||||
patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as mock_exec,
|
||||
patch.object(ExecTool, "_guard_command", return_value=None),
|
||||
):
|
||||
mock_exec.return_value = mock_proc
|
||||
tool = ExecTool()
|
||||
result = await tool.execute(command='node -e "console.log(1)\nconsole.log(2)"')
|
||||
|
||||
assert "1" in result
|
||||
args = mock_exec.call_args[0]
|
||||
assert args[0] == "powershell"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_single_line_uses_shell(self):
|
||||
mock_proc = AsyncMock()
|
||||
mock_proc.communicate.return_value = (b"1\n", b"")
|
||||
mock_proc.returncode = 0
|
||||
|
||||
with (
|
||||
patch("nanobot.agent.tools.shell._IS_WINDOWS", True),
|
||||
patch.object(ExecTool, "_spawn", return_value=mock_proc) as mock_spawn,
|
||||
patch.object(ExecTool, "_guard_command", return_value=None),
|
||||
):
|
||||
tool = ExecTool()
|
||||
result = await tool.execute(command='python -c "print(1)"')
|
||||
|
||||
assert "1" in result
|
||||
mock_spawn.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unix_unchanged(self):
|
||||
mock_proc = AsyncMock()
|
||||
mock_proc.communicate.return_value = (b"1\n2\n", b"")
|
||||
mock_proc.returncode = 0
|
||||
|
||||
with (
|
||||
patch("nanobot.agent.tools.shell._IS_WINDOWS", False),
|
||||
patch.object(ExecTool, "_spawn", return_value=mock_proc) as mock_spawn,
|
||||
patch.object(ExecTool, "_guard_command", return_value=None),
|
||||
):
|
||||
tool = ExecTool()
|
||||
result = await tool.execute(command='python -c "print(1)\nprint(2)"')
|
||||
|
||||
assert "1" in result
|
||||
mock_spawn.assert_called_once()
|
||||
|
||||
Reference in New Issue
Block a user