diff --git a/nanobot/agent/tools/exec_session.py b/nanobot/agent/tools/exec_session.py index 8b53f250f..a1d84827c 100644 --- a/nanobot/agent/tools/exec_session.py +++ b/nanobot/agent/tools/exec_session.py @@ -3,17 +3,20 @@ from __future__ import annotations import asyncio -import shutil import time import uuid from contextlib import suppress from dataclasses import dataclass from typing import Any -from nanobot.agent.tools.context import current_request_session_key from nanobot.agent.tools.base import Tool, tool_parameters -from nanobot.agent.tools.schema import BooleanSchema, IntegerSchema, StringSchema, tool_parameters_schema - +from nanobot.agent.tools.context import current_request_session_key +from nanobot.agent.tools.schema import ( + BooleanSchema, + IntegerSchema, + StringSchema, + tool_parameters_schema, +) DEFAULT_YIELD_MS = 1000 MAX_YIELD_MS = 30_000 @@ -289,29 +292,11 @@ class ExecSessionManager: shell_program: str | None, login: bool, ) -> asyncio.subprocess.Process: - from nanobot.agent.tools import shell + from nanobot.agent.tools.shell import ExecTool - if shell._IS_WINDOWS: - return await asyncio.create_subprocess_shell( - command, - stdin=asyncio.subprocess.PIPE, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - cwd=cwd, - env=env, - ) - shell_program = shell_program or shutil.which("bash") or "/bin/bash" - args = [shell_program] - if login and shell_program.rsplit("/", 1)[-1] in {"bash", "zsh"}: - args.append("-l") - args.extend(["-c", command]) - return await asyncio.create_subprocess_exec( - *args, + return await ExecTool._spawn( + command, cwd, env, shell_program, login, stdin=asyncio.subprocess.PIPE, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - cwd=cwd, - env=env, ) diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py index 082f8cce4..0ecfadc00 100644 --- a/nanobot/agent/tools/shell.py +++ b/nanobot/agent/tools/shell.py @@ -16,21 +16,26 @@ from loguru import logger from pydantic import Field from nanobot.agent.tools.base import Tool, tool_parameters +from nanobot.agent.tools.context import current_request_session_key from nanobot.agent.tools.exec_session import ( + DEFAULT_EXEC_SESSION_MANAGER, DEFAULT_MAX_OUTPUT_CHARS, DEFAULT_YIELD_MS, - DEFAULT_EXEC_SESSION_MANAGER, MAX_OUTPUT_CHARS, MAX_YIELD_MS, clamp_session_int, format_session_poll, ) -from nanobot.agent.tools.context import current_request_session_key from nanobot.agent.tools.sandbox import wrap_command -from nanobot.agent.tools.schema import BooleanSchema, IntegerSchema, StringSchema, tool_parameters_schema -from nanobot.security.workspace_access import current_scope_allows_loopback, current_tool_workspace +from nanobot.agent.tools.schema import ( + BooleanSchema, + IntegerSchema, + StringSchema, + tool_parameters_schema, +) from nanobot.config.paths import get_media_dir from nanobot.config.schema import Base +from nanobot.security.workspace_access import current_scope_allows_loopback, current_tool_workspace from nanobot.security.workspace_policy import is_path_within _IS_WINDOWS = sys.platform == "win32" @@ -431,16 +436,23 @@ class ExecTool(Tool): command: str, cwd: str, env: dict[str, str], shell_program: str | None = None, login: bool = True, + *, + stdin: int = asyncio.subprocess.DEVNULL, ) -> asyncio.subprocess.Process: """Launch *command* in a platform-appropriate shell.""" if _IS_WINDOWS: - # create_subprocess_exec re-quotes args via list2cmdline, which - # breaks commands containing paths with spaces (e.g. "D:\Program - # Files\python.exe" "script.py"). create_subprocess_shell passes - # the raw command string to COMSPEC without re-quoting. + if "\n" in command: + return await asyncio.create_subprocess_exec( + "powershell", "-NoProfile", "-Command", command, + stdin=stdin, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=cwd, + env=env, + ) return await asyncio.create_subprocess_shell( command, - stdin=asyncio.subprocess.DEVNULL, + stdin=stdin, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, cwd=cwd, @@ -454,7 +466,7 @@ class ExecTool(Tool): args.extend(["-c", command]) return await asyncio.create_subprocess_exec( *args, - stdin=asyncio.subprocess.DEVNULL, + stdin=stdin, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, cwd=cwd, diff --git a/tests/tools/test_exec_platform.py b/tests/tools/test_exec_platform.py index ffb25f985..e09838492 100644 --- a/tests/tools/test_exec_platform.py +++ b/tests/tools/test_exec_platform.py @@ -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()