feat(exec): add path prepend config
This commit is contained in:
@@ -45,6 +45,28 @@ async def test_exec_path_append_preserves_system_path():
|
||||
assert "Exit code: 0" in result
|
||||
|
||||
|
||||
@_UNIX_ONLY
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_path_prepend_takes_lookup_precedence(tmp_path):
|
||||
"""pathPrepend should win over pathAppend for executable lookup."""
|
||||
preferred = tmp_path / "preferred"
|
||||
fallback = tmp_path / "fallback"
|
||||
preferred.mkdir()
|
||||
fallback.mkdir()
|
||||
preferred_tool = preferred / "pathprobe"
|
||||
fallback_tool = fallback / "pathprobe"
|
||||
preferred_tool.write_text("#!/bin/sh\necho preferred\n", encoding="utf-8")
|
||||
fallback_tool.write_text("#!/bin/sh\necho fallback\n", encoding="utf-8")
|
||||
preferred_tool.chmod(0o755)
|
||||
fallback_tool.chmod(0o755)
|
||||
|
||||
tool = ExecTool(path_prepend=str(preferred), path_append=str(fallback))
|
||||
result = await tool.execute(command="pathprobe")
|
||||
|
||||
assert "preferred" in result
|
||||
assert "fallback" not in result
|
||||
|
||||
|
||||
@_UNIX_ONLY
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_allowed_env_keys_passthrough(monkeypatch):
|
||||
|
||||
@@ -202,6 +202,65 @@ class TestPathAppendPlatform:
|
||||
assert captured_env["NANOBOT_PATH_APPEND"] == "/opt/bin; echo INJECTED"
|
||||
assert "INJECTED" not in captured_cmd
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unix_path_prepend_uses_env_var_in_fixed_export(self):
|
||||
"""On Unix, path_prepend must not be interpolated into shell source."""
|
||||
mock_proc = AsyncMock()
|
||||
mock_proc.communicate.return_value = (b"ok", b"")
|
||||
mock_proc.returncode = 0
|
||||
|
||||
captured_cmd = None
|
||||
captured_env = {}
|
||||
|
||||
async def capture_spawn(cmd, cwd, env, shell_program=None, login=True, *, stdin=None):
|
||||
nonlocal captured_cmd
|
||||
captured_cmd = cmd
|
||||
captured_env.update(env)
|
||||
return mock_proc
|
||||
|
||||
with (
|
||||
patch("nanobot.agent.tools.shell._IS_WINDOWS", False),
|
||||
patch("nanobot.agent.tools.shell.os.pathsep", ":"),
|
||||
patch.object(ExecTool, "_spawn", side_effect=capture_spawn),
|
||||
patch.object(ExecTool, "_guard_command", return_value=None),
|
||||
):
|
||||
tool = ExecTool(path_prepend="/venv/bin; echo INJECTED")
|
||||
await tool.execute(command="python --version")
|
||||
|
||||
assert captured_cmd == 'export PATH="$NANOBOT_PATH_PREPEND:$PATH"; python --version'
|
||||
assert captured_env["NANOBOT_PATH_PREPEND"] == "/venv/bin; echo INJECTED"
|
||||
assert "INJECTED" not in captured_cmd
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unix_path_prepend_and_append_order(self):
|
||||
mock_proc = AsyncMock()
|
||||
mock_proc.communicate.return_value = (b"ok", b"")
|
||||
mock_proc.returncode = 0
|
||||
|
||||
captured_cmd = None
|
||||
captured_env = {}
|
||||
|
||||
async def capture_spawn(cmd, cwd, env, shell_program=None, login=True, *, stdin=None):
|
||||
nonlocal captured_cmd
|
||||
captured_cmd = cmd
|
||||
captured_env.update(env)
|
||||
return mock_proc
|
||||
|
||||
with (
|
||||
patch("nanobot.agent.tools.shell._IS_WINDOWS", False),
|
||||
patch("nanobot.agent.tools.shell.os.pathsep", ":"),
|
||||
patch.object(ExecTool, "_spawn", side_effect=capture_spawn),
|
||||
patch.object(ExecTool, "_guard_command", return_value=None),
|
||||
):
|
||||
tool = ExecTool(path_prepend="/venv/bin", path_append="/usr/sbin")
|
||||
await tool.execute(command="python --version")
|
||||
|
||||
assert captured_cmd == (
|
||||
'export PATH="$NANOBOT_PATH_PREPEND:$PATH:$NANOBOT_PATH_APPEND"; python --version'
|
||||
)
|
||||
assert captured_env["NANOBOT_PATH_PREPEND"] == "/venv/bin"
|
||||
assert captured_env["NANOBOT_PATH_APPEND"] == "/usr/sbin"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_windows_modifies_env(self):
|
||||
"""On Windows, path_append is appended to PATH in the env dict."""
|
||||
@@ -226,6 +285,32 @@ class TestPathAppendPlatform:
|
||||
|
||||
assert captured_env["PATH"].endswith(r";C:\tools\bin")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_windows_path_prepend_and_append_order(self):
|
||||
mock_proc = AsyncMock()
|
||||
mock_proc.communicate.return_value = (b"ok", b"")
|
||||
mock_proc.returncode = 0
|
||||
|
||||
captured_env = {}
|
||||
|
||||
async def capture_spawn(cmd, cwd, env, shell_program=None, login=True, *, stdin=None):
|
||||
captured_env.update(env)
|
||||
return mock_proc
|
||||
|
||||
with (
|
||||
patch("nanobot.agent.tools.shell._IS_WINDOWS", True),
|
||||
patch("nanobot.agent.tools.shell.os.pathsep", ";"),
|
||||
patch.object(ExecTool, "_build_env", return_value={"PATH": r"C:\Windows\System32"}),
|
||||
patch.object(ExecTool, "_spawn", side_effect=capture_spawn),
|
||||
patch.object(ExecTool, "_guard_command", return_value=None),
|
||||
):
|
||||
tool = ExecTool(path_prepend=r"C:\venv\Scripts", path_append=r"C:\tools\bin")
|
||||
await tool.execute(command="python --version")
|
||||
|
||||
assert captured_env["PATH"] == (
|
||||
r"C:\venv\Scripts;C:\Windows\System32;C:\tools\bin"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# sandbox
|
||||
|
||||
@@ -244,6 +244,7 @@ def test_exec_tool_create():
|
||||
mock_config.exec.enable = True
|
||||
mock_config.exec.timeout = 120
|
||||
mock_config.exec.sandbox = ""
|
||||
mock_config.exec.path_prepend = "/venv/bin"
|
||||
mock_config.exec.path_append = ""
|
||||
mock_config.exec.allowed_env_keys = []
|
||||
mock_config.exec.allow_patterns = []
|
||||
@@ -252,6 +253,7 @@ def test_exec_tool_create():
|
||||
ctx = ToolContext(config=mock_config, workspace="/tmp")
|
||||
tool = ExecTool.create(ctx)
|
||||
assert isinstance(tool, ExecTool)
|
||||
assert tool.path_prepend == "/venv/bin"
|
||||
|
||||
|
||||
def test_web_tools_config_cls():
|
||||
@@ -360,7 +362,7 @@ def test_config_round_trip():
|
||||
config_dict = {
|
||||
"tools": {
|
||||
"web": {"enable": True, "search": {"provider": "brave", "api_key": "test"}},
|
||||
"exec": {"enable": False, "timeout": 120},
|
||||
"exec": {"enable": False, "timeout": 120, "pathPrepend": "/venv/bin"},
|
||||
"my": {"allowSet": True},
|
||||
"imageGeneration": {"enabled": True, "provider": "openrouter"},
|
||||
}
|
||||
@@ -370,8 +372,10 @@ def test_config_round_trip():
|
||||
|
||||
assert dumped["tools"]["my"]["allowSet"] is True
|
||||
assert dumped["tools"]["imageGeneration"]["enabled"] is True
|
||||
assert dumped["tools"]["exec"]["pathPrepend"] == "/venv/bin"
|
||||
assert config.tools.exec.enable is False
|
||||
assert config.tools.exec.timeout == 120
|
||||
assert config.tools.exec.path_prepend == "/venv/bin"
|
||||
assert config.tools.web.search.provider == "brave"
|
||||
|
||||
|
||||
@@ -382,6 +386,7 @@ def test_config_defaults():
|
||||
config = Config.model_validate({})
|
||||
assert config.tools.exec.enable is True
|
||||
assert config.tools.exec.timeout == 60
|
||||
assert config.tools.exec.path_prepend == ""
|
||||
assert config.tools.web.enable is True
|
||||
assert config.tools.web.search.provider == "duckduckgo"
|
||||
assert config.tools.my.enable is True
|
||||
@@ -403,6 +408,7 @@ def test_loader_registers_same_tools_as_old_hardcoded():
|
||||
mock_config.exec.enable = True
|
||||
mock_config.exec.timeout = 60
|
||||
mock_config.exec.sandbox = ""
|
||||
mock_config.exec.path_prepend = ""
|
||||
mock_config.exec.path_append = ""
|
||||
mock_config.exec.allowed_env_keys = []
|
||||
mock_config.exec.allow_patterns = []
|
||||
|
||||
Reference in New Issue
Block a user