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:
chengyongru 2026-05-29 18:42:35 +08:00 committed by Xubin Ren
parent 8e421eb976
commit 7c86223643
3 changed files with 137 additions and 37 deletions

View File

@ -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,
)

View File

@ -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,

View File

@ -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()