mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-04-08 20:23:41 +00:00
ExecTool hardcoded bash, breaking exec on Windows. Now uses cmd.exe via COMSPEC on Windows with a curated minimal env (PATH, SYSTEMROOT, etc.) that excludes secrets. bwrap sandbox gracefully skips on Windows.
270 lines
9.6 KiB
Python
270 lines
9.6 KiB
Python
"""Tests for cross-platform shell execution.
|
|
|
|
Verifies that ExecTool selects the correct shell, environment, path-append
|
|
strategy, and sandbox behaviour per platform — without actually running
|
|
platform-specific binaries (all subprocess calls are mocked).
|
|
"""
|
|
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
|
|
from nanobot.agent.tools.shell import ExecTool
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _build_env
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestBuildEnvUnix:
|
|
|
|
def test_expected_keys(self):
|
|
with patch("nanobot.agent.tools.shell._IS_WINDOWS", False):
|
|
env = ExecTool()._build_env()
|
|
assert set(env) == {"HOME", "LANG", "TERM"}
|
|
|
|
def test_home_from_environ(self, monkeypatch):
|
|
monkeypatch.setenv("HOME", "/Users/dev")
|
|
with patch("nanobot.agent.tools.shell._IS_WINDOWS", False):
|
|
env = ExecTool()._build_env()
|
|
assert env["HOME"] == "/Users/dev"
|
|
|
|
def test_secrets_excluded(self, monkeypatch):
|
|
monkeypatch.setenv("OPENAI_API_KEY", "sk-secret")
|
|
monkeypatch.setenv("NANOBOT_TOKEN", "tok-secret")
|
|
with patch("nanobot.agent.tools.shell._IS_WINDOWS", False):
|
|
env = ExecTool()._build_env()
|
|
assert "OPENAI_API_KEY" not in env
|
|
assert "NANOBOT_TOKEN" not in env
|
|
for v in env.values():
|
|
assert "secret" not in v.lower()
|
|
|
|
|
|
class TestBuildEnvWindows:
|
|
|
|
_EXPECTED_KEYS = {
|
|
"SYSTEMROOT", "COMSPEC", "USERPROFILE", "HOMEDRIVE",
|
|
"HOMEPATH", "TEMP", "TMP", "PATHEXT", "PATH",
|
|
}
|
|
|
|
def test_expected_keys(self):
|
|
with patch("nanobot.agent.tools.shell._IS_WINDOWS", True):
|
|
env = ExecTool()._build_env()
|
|
assert set(env) == self._EXPECTED_KEYS
|
|
|
|
def test_secrets_excluded(self, monkeypatch):
|
|
monkeypatch.setenv("OPENAI_API_KEY", "sk-secret")
|
|
monkeypatch.setenv("NANOBOT_TOKEN", "tok-secret")
|
|
with patch("nanobot.agent.tools.shell._IS_WINDOWS", True):
|
|
env = ExecTool()._build_env()
|
|
assert "OPENAI_API_KEY" not in env
|
|
assert "NANOBOT_TOKEN" not in env
|
|
for v in env.values():
|
|
assert "secret" not in v.lower()
|
|
|
|
def test_path_has_sensible_default(self):
|
|
with (
|
|
patch("nanobot.agent.tools.shell._IS_WINDOWS", True),
|
|
patch.dict("os.environ", {}, clear=True),
|
|
):
|
|
env = ExecTool()._build_env()
|
|
assert "system32" in env["PATH"].lower()
|
|
|
|
def test_systemroot_forwarded(self, monkeypatch):
|
|
monkeypatch.setenv("SYSTEMROOT", r"D:\Windows")
|
|
with patch("nanobot.agent.tools.shell._IS_WINDOWS", True):
|
|
env = ExecTool()._build_env()
|
|
assert env["SYSTEMROOT"] == r"D:\Windows"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _spawn
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestSpawnUnix:
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_uses_bash(self):
|
|
with (
|
|
patch("nanobot.agent.tools.shell._IS_WINDOWS", False),
|
|
patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as mock_exec,
|
|
):
|
|
mock_exec.return_value = AsyncMock()
|
|
await ExecTool._spawn("echo hi", "/tmp", {"HOME": "/tmp"})
|
|
|
|
args = mock_exec.call_args[0]
|
|
assert "bash" in args[0]
|
|
assert "-l" in args
|
|
assert "-c" in args
|
|
assert "echo hi" in args
|
|
|
|
|
|
class TestSpawnWindows:
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_uses_comspec_from_env(self):
|
|
env = {"COMSPEC": r"C:\Windows\system32\cmd.exe", "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("dir", r"C:\Users", env)
|
|
|
|
args = mock_exec.call_args[0]
|
|
assert "cmd.exe" in args[0]
|
|
assert "/c" in args
|
|
assert "dir" in args
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_falls_back_to_default_comspec(self):
|
|
env = {"PATH": ""}
|
|
with (
|
|
patch("nanobot.agent.tools.shell._IS_WINDOWS", True),
|
|
patch.dict("os.environ", {}, clear=True),
|
|
patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as mock_exec,
|
|
):
|
|
mock_exec.return_value = AsyncMock()
|
|
await ExecTool._spawn("dir", r"C:\Users", env)
|
|
|
|
args = mock_exec.call_args[0]
|
|
assert args[0] == "cmd.exe"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# path_append
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestPathAppendPlatform:
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unix_injects_export(self):
|
|
"""On Unix, path_append is an export statement prepended to command."""
|
|
mock_proc = AsyncMock()
|
|
mock_proc.communicate.return_value = (b"ok", 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(path_append="/opt/bin")
|
|
await tool.execute(command="ls")
|
|
|
|
spawned_cmd = mock_spawn.call_args[0][0]
|
|
assert 'export PATH="$PATH:/opt/bin"' in spawned_cmd
|
|
assert spawned_cmd.endswith("ls")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_windows_modifies_env(self):
|
|
"""On Windows, path_append is appended to PATH in the env dict."""
|
|
mock_proc = AsyncMock()
|
|
mock_proc.communicate.return_value = (b"ok", b"")
|
|
mock_proc.returncode = 0
|
|
|
|
captured_env = {}
|
|
|
|
async def capture_spawn(cmd, cwd, env):
|
|
captured_env.update(env)
|
|
return mock_proc
|
|
|
|
with (
|
|
patch("nanobot.agent.tools.shell._IS_WINDOWS", True),
|
|
patch.object(ExecTool, "_spawn", side_effect=capture_spawn),
|
|
patch.object(ExecTool, "_guard_command", return_value=None),
|
|
):
|
|
tool = ExecTool(path_append=r"C:\tools\bin")
|
|
await tool.execute(command="dir")
|
|
|
|
assert captured_env["PATH"].endswith(r";C:\tools\bin")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# sandbox
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestSandboxPlatform:
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bwrap_skipped_on_windows(self):
|
|
"""bwrap must be silently skipped on Windows, not crash."""
|
|
mock_proc = AsyncMock()
|
|
mock_proc.communicate.return_value = (b"ok", 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(sandbox="bwrap")
|
|
result = await tool.execute(command="dir")
|
|
|
|
assert "ok" in result
|
|
spawned_cmd = mock_spawn.call_args[0][0]
|
|
assert "bwrap" not in spawned_cmd
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bwrap_applied_on_unix(self):
|
|
"""On Unix, sandbox wrapping should still happen normally."""
|
|
mock_proc = AsyncMock()
|
|
mock_proc.communicate.return_value = (b"sandboxed", b"")
|
|
mock_proc.returncode = 0
|
|
|
|
with (
|
|
patch("nanobot.agent.tools.shell._IS_WINDOWS", False),
|
|
patch("nanobot.agent.tools.shell.wrap_command", return_value="bwrap -- sh -c ls") as mock_wrap,
|
|
patch.object(ExecTool, "_spawn", return_value=mock_proc) as mock_spawn,
|
|
patch.object(ExecTool, "_guard_command", return_value=None),
|
|
):
|
|
tool = ExecTool(sandbox="bwrap", working_dir="/workspace")
|
|
await tool.execute(command="ls")
|
|
|
|
mock_wrap.assert_called_once()
|
|
spawned_cmd = mock_spawn.call_args[0][0]
|
|
assert "bwrap" in spawned_cmd
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# end-to-end (mocked subprocess, full execute path)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestExecuteEndToEnd:
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_windows_full_path(self):
|
|
"""Full execute() flow on Windows: env, spawn, output formatting."""
|
|
mock_proc = AsyncMock()
|
|
mock_proc.communicate.return_value = (b"hello world\r\n", b"")
|
|
mock_proc.returncode = 0
|
|
|
|
with (
|
|
patch("nanobot.agent.tools.shell._IS_WINDOWS", True),
|
|
patch.object(ExecTool, "_spawn", return_value=mock_proc),
|
|
patch.object(ExecTool, "_guard_command", return_value=None),
|
|
):
|
|
tool = ExecTool()
|
|
result = await tool.execute(command="echo hello world")
|
|
|
|
assert "hello world" in result
|
|
assert "Exit code: 0" in result
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unix_full_path(self):
|
|
"""Full execute() flow on Unix: env, spawn, output formatting."""
|
|
mock_proc = AsyncMock()
|
|
mock_proc.communicate.return_value = (b"hello world\n", b"")
|
|
mock_proc.returncode = 0
|
|
|
|
with (
|
|
patch("nanobot.agent.tools.shell._IS_WINDOWS", False),
|
|
patch.object(ExecTool, "_spawn", return_value=mock_proc),
|
|
patch.object(ExecTool, "_guard_command", return_value=None),
|
|
):
|
|
tool = ExecTool()
|
|
result = await tool.execute(command="echo hello world")
|
|
|
|
assert "hello world" in result
|
|
assert "Exit code: 0" in result
|