1726 lines
53 KiB
Python
1726 lines
53 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Tests for pineagents.agents.tools.shell.
|
|
|
|
Covers:
|
|
- _collapse_newlines_outside_quotes
|
|
- _collapse_embedded_newlines
|
|
- _sanitize_win_cmd
|
|
- _read_temp_file
|
|
- _shell_basename
|
|
- _is_powershell / _is_cmd
|
|
- _extract_powershell_command
|
|
- smart_decode
|
|
- execute_shell_command (mocked subprocess)
|
|
"""
|
|
# pylint: disable=protected-access,unused-argument
|
|
|
|
import os
|
|
import shlex
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from pineagents.agents.tools.shell import (
|
|
_cancel_stderr_message,
|
|
_collapse_embedded_newlines,
|
|
_collapse_newlines_outside_quotes,
|
|
_execute_in_sandbox,
|
|
_execute_subprocess_sync,
|
|
_execute_windows_host,
|
|
_extract_powershell_command,
|
|
_is_cmd,
|
|
_is_dangerous_self_kill,
|
|
_is_powershell,
|
|
_read_temp_file,
|
|
_sanitize_win_cmd,
|
|
_shell_basename,
|
|
smart_decode,
|
|
)
|
|
from pineagents.sandbox import (
|
|
ExecutionResult,
|
|
MountSpec,
|
|
SandboxConfig,
|
|
SandboxMode,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _shell_basename
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestShellBasename:
|
|
"""Tests for _shell_basename."""
|
|
|
|
def test_unix_path(self):
|
|
assert _shell_basename("/usr/bin/bash") == "bash"
|
|
|
|
def test_windows_path(self):
|
|
assert _shell_basename("C:\\Windows\\cmd.exe") == "cmd.exe"
|
|
|
|
def test_powershell_path(self):
|
|
assert (
|
|
_shell_basename(
|
|
"/usr/local/bin/pwsh",
|
|
)
|
|
== "pwsh"
|
|
)
|
|
|
|
def test_lowercase(self):
|
|
assert _shell_basename("/bin/BASH") == "bash"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _is_powershell / _is_cmd
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestIsPowershell:
|
|
"""Tests for _is_powershell."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"exe",
|
|
["powershell", "powershell.exe", "pwsh", "pwsh.exe"],
|
|
)
|
|
def test_powershell_variants(self, exe):
|
|
assert _is_powershell(exe) is True
|
|
|
|
def test_non_powershell(self):
|
|
assert _is_powershell("/bin/bash") is False
|
|
|
|
def test_cmd_is_not_powershell(self):
|
|
assert _is_powershell("cmd") is False
|
|
|
|
|
|
class TestIsCmd:
|
|
"""Tests for _is_cmd."""
|
|
|
|
@pytest.mark.parametrize("exe", ["cmd", "cmd.exe"])
|
|
def test_cmd_variants(self, exe):
|
|
assert _is_cmd(exe) is True
|
|
|
|
def test_non_cmd(self):
|
|
assert _is_cmd("/bin/bash") is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _collapse_newlines_outside_quotes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCollapseNewlinesOutsideQuotes:
|
|
"""Tests for _collapse_newlines_outside_quotes."""
|
|
|
|
def test_no_newlines(self):
|
|
assert _collapse_newlines_outside_quotes("echo hello") == "echo hello"
|
|
|
|
def test_unquoted_newline_to_space(self):
|
|
assert _collapse_newlines_outside_quotes("echo\nhello") == "echo hello"
|
|
|
|
def test_crlf_to_space(self):
|
|
assert (
|
|
_collapse_newlines_outside_quotes("echo\r\nhello") == "echo hello"
|
|
)
|
|
|
|
def test_single_quoted_newline_preserved(self):
|
|
result = _collapse_newlines_outside_quotes("echo 'hello\nworld'")
|
|
assert "\n" in result
|
|
|
|
def test_double_quoted_newline_preserved(self):
|
|
result = _collapse_newlines_outside_quotes('echo "hello\nworld"')
|
|
assert "\n" in result
|
|
|
|
def test_backslash_newline_continuation(self):
|
|
result = _collapse_newlines_outside_quotes("echo \\\nhello")
|
|
assert result == "echo hello"
|
|
|
|
def test_backslash_before_normal_char_kept(self):
|
|
result = _collapse_newlines_outside_quotes(r"echo \nhello")
|
|
assert result == r"echo \nhello"
|
|
|
|
def test_mixed_quoted_and_unquoted(self):
|
|
cmd = 'echo "line1\nline2" && \necho second'
|
|
result = _collapse_newlines_outside_quotes(cmd)
|
|
# First \n inside double quotes preserved
|
|
assert "line1\nline2" in result
|
|
# Second \n outside quotes collapsed to space
|
|
assert "echo second" in result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _collapse_embedded_newlines
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCollapseEmbeddedNewlines:
|
|
"""Tests for _collapse_embedded_newlines."""
|
|
|
|
def test_no_newlines_unchanged(self):
|
|
command = "echo hello"
|
|
assert (
|
|
_collapse_embedded_newlines(command, "powershell.exe") == command
|
|
)
|
|
|
|
@patch("pineagents.agents.tools.shell.sys")
|
|
def test_windows_cmd_collapses_all(self, mock_sys):
|
|
mock_sys.platform = "win32"
|
|
result = _collapse_embedded_newlines(
|
|
'echo "hello\r\nworld"',
|
|
r"C:\Windows\System32\cmd.exe",
|
|
)
|
|
assert result == 'echo "hello world"'
|
|
|
|
@patch("pineagents.agents.tools.shell.sys")
|
|
def test_windows_default_shell_collapses_all(self, mock_sys):
|
|
mock_sys.platform = "win32"
|
|
result = _collapse_embedded_newlines('echo "hello\nworld"')
|
|
assert result == 'echo "hello world"'
|
|
|
|
@pytest.mark.parametrize("shell", ["powershell.exe", "pwsh.exe"])
|
|
@pytest.mark.parametrize("newline", ["\n", "\r\n"])
|
|
@patch("pineagents.agents.tools.shell.sys")
|
|
def test_windows_powershell_preserves_here_string(
|
|
self,
|
|
mock_sys,
|
|
newline,
|
|
shell,
|
|
):
|
|
mock_sys.platform = "win32"
|
|
command = (
|
|
f'$content = @"{newline}hello{newline}'
|
|
f'world{newline}"@{newline}$content'
|
|
)
|
|
assert _collapse_embedded_newlines(command, shell) == command
|
|
|
|
@patch("pineagents.agents.tools.shell.sys")
|
|
def test_unix_preserves_quoted_newlines(self, mock_sys):
|
|
mock_sys.platform = "linux"
|
|
command = 'echo "hello\nworld"'
|
|
assert _collapse_embedded_newlines(command, "/bin/bash") == command
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _sanitize_win_cmd
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSanitizeWinCmd:
|
|
"""Tests for _sanitize_win_cmd."""
|
|
|
|
def test_no_escaped_quotes(self):
|
|
assert _sanitize_win_cmd("echo hello") == "echo hello"
|
|
|
|
def test_all_escaped_quotes_stripped(self):
|
|
# Every " is preceded by \ — double-escape artefact
|
|
result = _sanitize_win_cmd('echo \\"hello\\"')
|
|
assert result == 'echo "hello"'
|
|
|
|
def test_mixed_quotes_not_stripped(self):
|
|
# Mix of escaped and unescaped — don't strip
|
|
cmd = 'echo \\"hello" world'
|
|
assert _sanitize_win_cmd(cmd) == cmd
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _read_temp_file
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestReadTempFile:
|
|
"""Tests for _read_temp_file."""
|
|
|
|
def test_read_existing_file(self, tmp_path):
|
|
f = tmp_path / "out.txt"
|
|
f.write_text("hello world", encoding="utf-8")
|
|
result = _read_temp_file(str(f))
|
|
assert result == "hello world"
|
|
|
|
def test_read_nonexistent_file(self):
|
|
result = _read_temp_file("/nonexistent/file.txt")
|
|
assert result == ""
|
|
|
|
def test_read_utf8_bytes(self, tmp_path):
|
|
f = tmp_path / "out.txt"
|
|
f.write_bytes("你好".encode("utf-8"))
|
|
result = _read_temp_file(str(f))
|
|
assert "你好" in result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _extract_powershell_command
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestExtractPowershellCommand:
|
|
"""Tests for _extract_powershell_command."""
|
|
|
|
def test_powershell_command(self):
|
|
ps_exe, inner = _extract_powershell_command(
|
|
'powershell -Command "Get-Process"',
|
|
)
|
|
assert ps_exe == "powershell"
|
|
assert inner == "Get-Process"
|
|
|
|
def test_pwsh_command(self):
|
|
ps_exe, _ = _extract_powershell_command(
|
|
'pwsh -Command "Get-Process"',
|
|
)
|
|
assert ps_exe == "pwsh"
|
|
|
|
def test_powershell_with_flags(self):
|
|
ps_exe, inner = _extract_powershell_command(
|
|
"powershell -NoProfile -NonInteractive -Command Get-Process",
|
|
)
|
|
assert ps_exe == "powershell"
|
|
assert inner == "Get-Process"
|
|
|
|
def test_non_powershell(self):
|
|
ps_exe, inner = _extract_powershell_command("echo hello")
|
|
assert ps_exe is None
|
|
assert inner == "echo hello"
|
|
|
|
def test_pwsh_exe(self):
|
|
ps_exe, _ = _extract_powershell_command(
|
|
"pwsh.exe -Command test",
|
|
)
|
|
assert ps_exe == "pwsh.exe"
|
|
|
|
def test_execution_policy_flag(self):
|
|
ps_exe, inner = _extract_powershell_command(
|
|
"powershell -ExecutionPolicy Bypass -Command echo hi",
|
|
)
|
|
assert ps_exe == "powershell"
|
|
assert inner == "echo hi"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# smart_decode
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSmartDecode:
|
|
"""Tests for smart_decode."""
|
|
|
|
def test_utf8_bytes(self):
|
|
result = smart_decode("hello".encode("utf-8"))
|
|
assert result == "hello"
|
|
|
|
def test_strips_trailing_newlines(self):
|
|
result = smart_decode("hello\n\n".encode("utf-8"))
|
|
assert result == "hello"
|
|
|
|
def test_non_utf8_fallback(self):
|
|
# Bytes that are invalid UTF-8 should fall back to
|
|
# locale encoding with error replacement
|
|
data = b"\xff\xfe" # BOM for UTF-16-LE, invalid UTF-8
|
|
result = smart_decode(data)
|
|
assert isinstance(result, str)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _is_dangerous_self_kill
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestIsDangerousSelfKill:
|
|
"""Tests for _is_dangerous_self_kill."""
|
|
|
|
def test_taskkill_by_image_name_python(self):
|
|
assert _is_dangerous_self_kill("taskkill /F /IM python.exe")
|
|
|
|
def test_taskkill_by_image_name_pythonw(self):
|
|
assert _is_dangerous_self_kill("taskkill /F /IM pythonw.exe")
|
|
|
|
def test_taskkill_by_image_name_cmd(self):
|
|
assert _is_dangerous_self_kill("taskkill /F /IM cmd.exe")
|
|
|
|
def test_taskkill_by_image_name_powershell(self):
|
|
assert _is_dangerous_self_kill("taskkill /F /IM powershell.exe")
|
|
|
|
def test_taskkill_by_image_name_pwsh(self):
|
|
assert _is_dangerous_self_kill("taskkill /F /IM pwsh.exe")
|
|
|
|
def test_taskkill_by_image_name_conhost(self):
|
|
assert _is_dangerous_self_kill("taskkill /F /IM conhost.exe")
|
|
|
|
def test_taskkill_by_image_name_without_exe(self):
|
|
assert _is_dangerous_self_kill("taskkill /F /IM python")
|
|
|
|
def test_taskkill_by_pid_self(self):
|
|
assert _is_dangerous_self_kill(f"taskkill /F /PID {os.getpid()}")
|
|
|
|
def test_taskkill_by_pid_parent(self):
|
|
if hasattr(os, "getppid"):
|
|
assert _is_dangerous_self_kill(
|
|
f"taskkill /F /PID {os.getppid()}",
|
|
)
|
|
|
|
def test_taskkill_by_pid_other_is_safe(self):
|
|
assert not _is_dangerous_self_kill("taskkill /F /PID 99999")
|
|
|
|
def test_kill_unix_pid_self(self):
|
|
assert _is_dangerous_self_kill(f"kill -9 {os.getpid()}")
|
|
|
|
def test_kill_unix_pid_other_is_safe(self):
|
|
assert not _is_dangerous_self_kill("kill -9 99999")
|
|
|
|
def test_kill_shell_var_dollar_dollar(self):
|
|
assert _is_dangerous_self_kill("kill -9 $$")
|
|
|
|
def test_kill_shell_var_ppid(self):
|
|
assert _is_dangerous_self_kill("kill $PPID")
|
|
|
|
def test_kill_shell_var_pid(self):
|
|
assert _is_dangerous_self_kill("kill $PID")
|
|
|
|
def test_false_positive_command_contains_cmd(self):
|
|
"""'command' contains 'cmd' but should not be blocked."""
|
|
assert not _is_dangerous_self_kill("echo 'run a command'")
|
|
|
|
def test_false_positive_echo_kill_python(self):
|
|
"""echo with 'kill python' in text should not be blocked."""
|
|
assert not _is_dangerous_self_kill(
|
|
'echo "do not kill python"',
|
|
)
|
|
|
|
def test_false_positive_cat_file(self):
|
|
"""Reading a file named kill_list_python.txt should not be blocked."""
|
|
assert not _is_dangerous_self_kill("cat kill_list_python.txt")
|
|
|
|
def test_safe_command(self):
|
|
assert not _is_dangerous_self_kill("echo hello")
|
|
|
|
def test_stop_process_by_name(self):
|
|
assert _is_dangerous_self_kill("Stop-Process -Name python")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# execute_shell_command (mocked)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestExecuteShellCommand:
|
|
"""Tests for execute_shell_command with mocked subprocess."""
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("pineagents.agents.tools.shell.get_current_shell_command_timeout")
|
|
@patch("pineagents.agents.tools.shell.get_current_workspace_dir")
|
|
@patch("pineagents.agents.tools.shell.get_current_shell_command_executable")
|
|
async def test_simple_command_success(
|
|
self,
|
|
mock_shell_exe,
|
|
mock_workspace,
|
|
mock_timeout,
|
|
):
|
|
mock_shell_exe.return_value = None
|
|
mock_workspace.return_value = None
|
|
mock_timeout.return_value = None
|
|
|
|
async def fake_wait_for(coro, timeout=None):
|
|
return await coro
|
|
|
|
mock_proc = MagicMock()
|
|
mock_proc.communicate = AsyncMock(
|
|
return_value=(b"hello\n", b""),
|
|
)
|
|
mock_proc.returncode = 0
|
|
mock_proc.pid = 12345
|
|
|
|
with (
|
|
patch(
|
|
"pineagents.agents.tools.shell.asyncio.create_subprocess_shell",
|
|
AsyncMock(return_value=mock_proc),
|
|
),
|
|
patch(
|
|
"pineagents.agents.tools.shell.asyncio.wait_for",
|
|
side_effect=fake_wait_for,
|
|
),
|
|
):
|
|
from pineagents.agents.tools.shell import (
|
|
execute_shell_command,
|
|
)
|
|
|
|
result = await execute_shell_command("echo hello")
|
|
assert result.content is not None
|
|
text = result.content[0].text
|
|
assert "hello" in text
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("pineagents.agents.tools.shell.get_current_shell_command_timeout")
|
|
@patch("pineagents.agents.tools.shell.get_current_workspace_dir")
|
|
@patch("pineagents.agents.tools.shell.get_current_shell_command_executable")
|
|
async def test_command_failure(
|
|
self,
|
|
mock_shell_exe,
|
|
mock_workspace,
|
|
mock_timeout,
|
|
):
|
|
mock_shell_exe.return_value = None
|
|
mock_workspace.return_value = None
|
|
mock_timeout.return_value = None
|
|
|
|
async def fake_wait_for(coro, timeout=None):
|
|
return await coro
|
|
|
|
mock_proc = MagicMock()
|
|
mock_proc.communicate = AsyncMock(
|
|
return_value=(b"", b"error msg\n"),
|
|
)
|
|
mock_proc.returncode = 1
|
|
mock_proc.pid = 12345
|
|
|
|
with (
|
|
patch(
|
|
"pineagents.agents.tools.shell.asyncio.create_subprocess_shell",
|
|
AsyncMock(return_value=mock_proc),
|
|
),
|
|
patch(
|
|
"pineagents.agents.tools.shell.asyncio.wait_for",
|
|
side_effect=fake_wait_for,
|
|
),
|
|
):
|
|
from pineagents.agents.tools.shell import (
|
|
execute_shell_command,
|
|
)
|
|
|
|
result = await execute_shell_command("false")
|
|
text = result.content[0].text
|
|
assert "failed" in text.lower() or "error" in text.lower()
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("pineagents.agents.tools.shell.get_current_shell_command_timeout")
|
|
@patch("pineagents.agents.tools.shell.get_current_workspace_dir")
|
|
@patch("pineagents.agents.tools.shell.get_current_shell_command_executable")
|
|
async def test_empty_command(
|
|
self,
|
|
mock_shell_exe,
|
|
mock_workspace,
|
|
mock_timeout,
|
|
):
|
|
mock_shell_exe.return_value = None
|
|
mock_workspace.return_value = None
|
|
mock_timeout.return_value = None
|
|
|
|
async def fake_wait_for(coro, timeout=None):
|
|
return await coro
|
|
|
|
mock_proc = MagicMock()
|
|
mock_proc.communicate = AsyncMock(return_value=(b"", b""))
|
|
mock_proc.returncode = 0
|
|
mock_proc.pid = 12345
|
|
|
|
with (
|
|
patch(
|
|
"pineagents.agents.tools.shell.asyncio.create_subprocess_shell",
|
|
AsyncMock(return_value=mock_proc),
|
|
),
|
|
patch(
|
|
"pineagents.agents.tools.shell.asyncio.wait_for",
|
|
side_effect=fake_wait_for,
|
|
),
|
|
):
|
|
from pineagents.agents.tools.shell import (
|
|
execute_shell_command,
|
|
)
|
|
|
|
result = await execute_shell_command("")
|
|
text = result.content[0].text
|
|
assert "successfully" in text.lower()
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("pineagents.agents.tools.shell.get_current_shell_command_timeout")
|
|
@patch("pineagents.agents.tools.shell.get_current_workspace_dir")
|
|
@patch("pineagents.agents.tools.shell.get_current_shell_command_executable")
|
|
async def test_timeout_string_converted(
|
|
self,
|
|
mock_shell_exe,
|
|
mock_workspace,
|
|
mock_timeout,
|
|
):
|
|
mock_shell_exe.return_value = None
|
|
mock_workspace.return_value = None
|
|
mock_timeout.return_value = None
|
|
|
|
async def fake_wait_for(coro, timeout=None):
|
|
return await coro
|
|
|
|
mock_proc = MagicMock()
|
|
mock_proc.communicate = AsyncMock(return_value=(b"ok", b""))
|
|
mock_proc.returncode = 0
|
|
mock_proc.pid = 12345
|
|
|
|
with (
|
|
patch(
|
|
"pineagents.agents.tools.shell.asyncio.create_subprocess_shell",
|
|
AsyncMock(return_value=mock_proc),
|
|
),
|
|
patch(
|
|
"pineagents.agents.tools.shell.asyncio.wait_for",
|
|
side_effect=fake_wait_for,
|
|
),
|
|
):
|
|
from pineagents.agents.tools.shell import (
|
|
execute_shell_command,
|
|
)
|
|
|
|
# timeout as string "30" should be converted to float
|
|
result = await execute_shell_command("echo ok", timeout="30")
|
|
assert result.content is not None
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("pineagents.agents.tools.shell.get_current_shell_command_timeout")
|
|
@patch("pineagents.agents.tools.shell.get_current_workspace_dir")
|
|
@patch("pineagents.agents.tools.shell.get_current_shell_command_executable")
|
|
async def test_invalid_timeout_defaults(
|
|
self,
|
|
mock_shell_exe,
|
|
mock_workspace,
|
|
mock_timeout,
|
|
):
|
|
mock_shell_exe.return_value = None
|
|
mock_workspace.return_value = None
|
|
mock_timeout.return_value = None
|
|
|
|
async def fake_wait_for(coro, timeout=None):
|
|
return await coro
|
|
|
|
mock_proc = MagicMock()
|
|
mock_proc.communicate = AsyncMock(return_value=(b"ok", b""))
|
|
mock_proc.returncode = 0
|
|
mock_proc.pid = 12345
|
|
|
|
with (
|
|
patch(
|
|
"pineagents.agents.tools.shell.asyncio.create_subprocess_shell",
|
|
AsyncMock(return_value=mock_proc),
|
|
),
|
|
patch(
|
|
"pineagents.agents.tools.shell.asyncio.wait_for",
|
|
side_effect=fake_wait_for,
|
|
),
|
|
):
|
|
from pineagents.agents.tools.shell import (
|
|
execute_shell_command,
|
|
)
|
|
|
|
# Invalid timeout string falls back to 60.0 default
|
|
result = await execute_shell_command(
|
|
"echo ok",
|
|
timeout="invalid",
|
|
)
|
|
assert result.content is not None
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.skipif(
|
|
sys.platform == "win32",
|
|
reason="NoneSandbox currently requires a POSIX shell",
|
|
)
|
|
async def test_sandbox_path_starts_with_running_python_bin(
|
|
self,
|
|
monkeypatch,
|
|
tmp_path,
|
|
):
|
|
from pineagents.agents.tools.shell import execute_shell_command
|
|
|
|
system_bin = tmp_path / "system-bin"
|
|
system_bin.mkdir()
|
|
monkeypatch.setenv("PATH", str(system_bin))
|
|
if sys.platform != "win32":
|
|
monkeypatch.setenv("SHELL", "/bin/sh")
|
|
|
|
script = "import os; print(os.environ.get('PATH', ''))"
|
|
args = [sys.executable, "-c", script]
|
|
command = (
|
|
subprocess.list2cmdline(args)
|
|
if sys.platform == "win32"
|
|
else shlex.join(args)
|
|
)
|
|
config = SandboxConfig(
|
|
mode=SandboxMode.NONE,
|
|
workspace_dir=str(tmp_path),
|
|
mounts=[MountSpec(path=str(tmp_path), writable=True)],
|
|
)
|
|
|
|
result = await execute_shell_command(
|
|
command,
|
|
cwd=tmp_path,
|
|
sandbox_config=config,
|
|
)
|
|
|
|
path_entries = result.content[0].text.strip().split(os.pathsep)
|
|
assert Path(path_entries[0]) == Path(sys.executable).parent
|
|
assert config.env_vars == {}
|
|
assert config.timeout_seconds == 30
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sandbox_uses_explicit_path_without_mutating_config(
|
|
self,
|
|
tmp_path,
|
|
):
|
|
configured_path = os.pathsep.join(["custom", "bin"])
|
|
config = SandboxConfig(
|
|
mode=SandboxMode.NONE,
|
|
workspace_dir=str(tmp_path),
|
|
env_vars={"PATH": configured_path, "MASKED_SECRET": ""},
|
|
)
|
|
sandbox = AsyncMock()
|
|
sandbox.execute.return_value = ExecutionResult(0, "ok", "")
|
|
context_manager = MagicMock()
|
|
context_manager.__aenter__ = AsyncMock(return_value=sandbox)
|
|
context_manager.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
with patch(
|
|
"pineagents.sandbox.create_sandbox",
|
|
return_value=context_manager,
|
|
) as create_sandbox:
|
|
await _execute_in_sandbox(
|
|
"echo ok",
|
|
config,
|
|
12.9,
|
|
str(tmp_path),
|
|
{"PATH": os.pathsep.join(["venv", "system"])},
|
|
)
|
|
|
|
effective_config = create_sandbox.call_args.args[0]
|
|
assert effective_config.env_vars == {
|
|
"PATH": configured_path,
|
|
"MASKED_SECRET": "",
|
|
}
|
|
assert effective_config.timeout_seconds == 12
|
|
assert config.env_vars == {
|
|
"PATH": configured_path,
|
|
"MASKED_SECRET": "",
|
|
}
|
|
assert config.timeout_seconds == 30
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sandbox_under_ctx_does_not_freeze_original_timeout(tmp_path):
|
|
"""With ToolCallContext, sandbox wait must not freeze the tool timeout."""
|
|
import asyncio
|
|
|
|
from pineagents.tool_calls import (
|
|
COORDINATOR_OWNED_EXEC_TIMEOUT_SECS,
|
|
reset_call_context,
|
|
set_call_context,
|
|
)
|
|
from pineagents.tool_calls._context import ToolCallContext
|
|
|
|
loop = asyncio.get_running_loop()
|
|
ctx = ToolCallContext(
|
|
tool_call_id="tc-sandbox-extend",
|
|
tool_name="execute_shell_command",
|
|
session_id="s",
|
|
agent_id="a",
|
|
root_session_id="r",
|
|
started_at=loop.time(),
|
|
offload_deadline=loop.time() + 30,
|
|
cancel_event=asyncio.Event(),
|
|
)
|
|
token = set_call_context(ctx)
|
|
|
|
sandbox = AsyncMock()
|
|
sandbox.execute.return_value = ExecutionResult(0, "ok", "")
|
|
context_manager = MagicMock()
|
|
context_manager.__aenter__ = AsyncMock(return_value=sandbox)
|
|
context_manager.__aexit__ = AsyncMock(return_value=None)
|
|
config = SandboxConfig(
|
|
mode=SandboxMode.NONE,
|
|
workspace_dir=str(tmp_path),
|
|
)
|
|
|
|
try:
|
|
with patch(
|
|
"pineagents.sandbox.create_sandbox",
|
|
return_value=context_manager,
|
|
) as create_sandbox:
|
|
await _execute_in_sandbox(
|
|
"echo ok",
|
|
config,
|
|
12.0,
|
|
str(tmp_path),
|
|
{"PATH": "/bin"},
|
|
)
|
|
effective_config = create_sandbox.call_args.args[0]
|
|
assert (
|
|
effective_config.timeout_seconds
|
|
== COORDINATOR_OWNED_EXEC_TIMEOUT_SECS
|
|
)
|
|
finally:
|
|
reset_call_context(token)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sandbox_extend_kill_survives_then_cancel_stops(tmp_path):
|
|
"""extend_kill keeps sandbox past original timeout; cancel calls stop()."""
|
|
import asyncio
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, AsyncGenerator
|
|
|
|
from agentscope.message import TextBlock
|
|
from agentscope.tool import ToolResponse
|
|
|
|
from pineagents.sandbox.local_sandbox import NoneSandbox
|
|
from pineagents.tool_calls import ToolCoordinator
|
|
|
|
@dataclass
|
|
class _ToolCall:
|
|
id: str = "call-sandbox-ext"
|
|
name: str = "execute_shell_command"
|
|
input: dict[str, Any] = field(default_factory=dict)
|
|
|
|
coordinator = ToolCoordinator(offload_on_deadline=False)
|
|
tool_call = _ToolCall()
|
|
started = asyncio.Event()
|
|
release = asyncio.Event()
|
|
stop_mock = AsyncMock()
|
|
|
|
async def next_handler(
|
|
tool_call: _ToolCall,
|
|
) -> AsyncGenerator[Any, None]:
|
|
config = SandboxConfig(
|
|
mode=SandboxMode.NONE,
|
|
workspace_dir=str(tmp_path),
|
|
timeout_seconds=60,
|
|
)
|
|
sandbox = NoneSandbox(config)
|
|
proc = MagicMock()
|
|
proc.returncode = None
|
|
proc.pid = 4243
|
|
|
|
async def hang_communicate():
|
|
started.set()
|
|
await release.wait()
|
|
return b"survived\n", b""
|
|
|
|
proc.communicate = hang_communicate
|
|
context_manager = MagicMock()
|
|
context_manager.__aenter__ = AsyncMock(return_value=sandbox)
|
|
context_manager.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
with (
|
|
patch(
|
|
"pineagents.sandbox.create_sandbox",
|
|
return_value=context_manager,
|
|
),
|
|
patch(
|
|
"asyncio.create_subprocess_exec",
|
|
new=AsyncMock(return_value=proc),
|
|
),
|
|
patch.object(sandbox, "stop", new=stop_mock),
|
|
):
|
|
result = await _execute_in_sandbox(
|
|
"sleep 1",
|
|
config,
|
|
0.08,
|
|
str(tmp_path),
|
|
{"PATH": "/bin"},
|
|
)
|
|
yield ToolResponse(
|
|
content=[TextBlock(type="text", text=result.stdout or "")],
|
|
id=tool_call.id,
|
|
)
|
|
|
|
async def extend_then_cancel() -> None:
|
|
await started.wait()
|
|
await asyncio.sleep(0.04)
|
|
ok = await coordinator.extend_kill_deadline(
|
|
"call-sandbox-ext",
|
|
seconds=1.0,
|
|
)
|
|
assert ok is True
|
|
# Past original 0.08s — must still be running (not returned yet).
|
|
await asyncio.sleep(0.08)
|
|
assert release.is_set() is False
|
|
assert stop_mock.await_count == 0
|
|
cancelled = await coordinator.cancel("call-sandbox-ext")
|
|
assert cancelled is True
|
|
await asyncio.sleep(0.05)
|
|
stop_mock.assert_awaited()
|
|
|
|
ctrl = asyncio.create_task(extend_then_cancel())
|
|
events = await asyncio.wait_for(
|
|
_collect_sandbox_events(
|
|
coordinator.execute(
|
|
tool_call=tool_call,
|
|
next_handler=next_handler,
|
|
session_id="session-sandbox-ext",
|
|
agent_id="agent-1",
|
|
root_session_id="root-1",
|
|
),
|
|
),
|
|
timeout=3,
|
|
)
|
|
await ctrl
|
|
# Cancel may surface as interrupted; key assertion is stop().
|
|
assert events
|
|
assert stop_mock.await_count >= 1
|
|
|
|
|
|
async def _collect_sandbox_events(iterator):
|
|
events = []
|
|
async for item in iterator:
|
|
events.append(item)
|
|
return events
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_none_sandbox_execute_cancel_calls_stop(tmp_path):
|
|
"""CancelledError in sandbox.execute must call stop()."""
|
|
import asyncio
|
|
|
|
from pineagents.sandbox.local_sandbox import NoneSandbox
|
|
|
|
config = SandboxConfig(
|
|
mode=SandboxMode.NONE,
|
|
workspace_dir=str(tmp_path),
|
|
timeout_seconds=60,
|
|
)
|
|
sandbox = NoneSandbox(config)
|
|
proc = MagicMock()
|
|
proc.returncode = None
|
|
proc.pid = 4242
|
|
|
|
async def hang_communicate():
|
|
await asyncio.sleep(60)
|
|
return b"", b""
|
|
|
|
proc.communicate = hang_communicate
|
|
stop_mock = AsyncMock()
|
|
|
|
with (
|
|
patch(
|
|
"asyncio.create_subprocess_exec",
|
|
new=AsyncMock(return_value=proc),
|
|
),
|
|
patch.object(sandbox, "stop", new=stop_mock),
|
|
):
|
|
task = asyncio.create_task(
|
|
sandbox.execute("sleep 1", cwd=str(tmp_path)),
|
|
)
|
|
await asyncio.sleep(0.05)
|
|
task.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
stop_mock.assert_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sandbox_setup_preserves_offload_remaining_and_arms_kill(
|
|
tmp_path,
|
|
):
|
|
"""Setup must not collapse offload into the command timeout.
|
|
|
|
When command kill (12s) is shorter than the pre-setup offload window
|
|
(30s), arming kill pulls offload back to ``12 * OFFLOAD_TIMEOUT_RATIO``
|
|
so kill stays strictly later — without rewriting offload to the full
|
|
command timeout.
|
|
"""
|
|
import asyncio
|
|
|
|
from pineagents.tool_calls import (
|
|
OFFLOAD_TIMEOUT_RATIO,
|
|
reset_call_context,
|
|
set_call_context,
|
|
)
|
|
from pineagents.tool_calls._context import ToolCallContext
|
|
|
|
loop = asyncio.get_running_loop()
|
|
now = loop.time()
|
|
offload_remaining = 30.0
|
|
offload_at = now + offload_remaining
|
|
command_timeout = 12.0
|
|
ctx = ToolCallContext(
|
|
tool_call_id="tc-sandbox-ddl",
|
|
tool_name="execute_shell_command",
|
|
session_id="s",
|
|
agent_id="a",
|
|
root_session_id="r",
|
|
started_at=now,
|
|
offload_deadline=offload_at,
|
|
cancel_event=asyncio.Event(),
|
|
)
|
|
token = set_call_context(ctx)
|
|
|
|
sandbox = AsyncMock()
|
|
sandbox.execute.return_value = ExecutionResult(0, "ok", "")
|
|
context_manager = MagicMock()
|
|
context_manager.__aenter__ = AsyncMock(return_value=sandbox)
|
|
context_manager.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
config = SandboxConfig(
|
|
mode=SandboxMode.NONE,
|
|
workspace_dir=str(tmp_path),
|
|
)
|
|
|
|
try:
|
|
with patch(
|
|
"pineagents.sandbox.create_sandbox",
|
|
return_value=context_manager,
|
|
):
|
|
result = await _execute_in_sandbox(
|
|
"echo ok",
|
|
config,
|
|
command_timeout,
|
|
str(tmp_path),
|
|
{"PATH": "/bin"},
|
|
)
|
|
assert result.exit_code == 0
|
|
assert ctx.kill_deadline is not None
|
|
kill_remaining = ctx.kill_deadline - loop.time()
|
|
assert kill_remaining == pytest.approx(command_timeout, abs=1.0)
|
|
# Short kill pulled offload back; must stay strictly before kill.
|
|
assert ctx.offload_deadline is not None
|
|
offload_left = ctx.offload_deadline - loop.time()
|
|
assert offload_left == pytest.approx(
|
|
command_timeout * OFFLOAD_TIMEOUT_RATIO,
|
|
abs=0.5,
|
|
)
|
|
assert ctx.offload_deadline < ctx.kill_deadline
|
|
assert not ctx.cancel_event.is_set()
|
|
sandbox.execute.assert_awaited_once()
|
|
finally:
|
|
reset_call_context(token)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sandbox_setup_extension_does_not_leave_kill_armed_early(
|
|
tmp_path,
|
|
):
|
|
"""kill_deadline must be unset during setup and only armed for execute."""
|
|
import asyncio
|
|
|
|
from pineagents.tool_calls import reset_call_context, set_call_context
|
|
from pineagents.tool_calls._context import ToolCallContext
|
|
|
|
loop = asyncio.get_running_loop()
|
|
now = loop.time()
|
|
offload_remaining = 5.0
|
|
offload_at = now + offload_remaining
|
|
ctx = ToolCallContext(
|
|
tool_call_id="tc-sandbox-setup",
|
|
tool_name="execute_shell_command",
|
|
session_id="s",
|
|
agent_id="a",
|
|
root_session_id="r",
|
|
started_at=now,
|
|
offload_deadline=offload_at,
|
|
cancel_event=asyncio.Event(),
|
|
)
|
|
token = set_call_context(ctx)
|
|
|
|
seen_during_setup = {}
|
|
|
|
class _SandboxCM:
|
|
async def __aenter__(self):
|
|
# During setup enter, kill must not already be command-timeout.
|
|
seen_during_setup["kill"] = ctx.kill_deadline
|
|
seen_during_setup["offload"] = ctx.offload_deadline
|
|
sandbox = AsyncMock()
|
|
sandbox.execute.return_value = ExecutionResult(0, "ok", "")
|
|
return sandbox
|
|
|
|
async def __aexit__(self, *args):
|
|
return None
|
|
|
|
config = SandboxConfig(
|
|
mode=SandboxMode.NONE,
|
|
workspace_dir=str(tmp_path),
|
|
)
|
|
|
|
try:
|
|
with patch(
|
|
"pineagents.sandbox.create_sandbox",
|
|
return_value=_SandboxCM(),
|
|
):
|
|
await _execute_in_sandbox(
|
|
"echo ok",
|
|
config,
|
|
9.0,
|
|
str(tmp_path),
|
|
{"PATH": "/bin"},
|
|
)
|
|
assert seen_during_setup["kill"] is None
|
|
assert seen_during_setup["offload"] == pytest.approx(
|
|
offload_at + 180.0,
|
|
abs=0.05,
|
|
)
|
|
remaining = ctx.offload_deadline - loop.time()
|
|
assert remaining == pytest.approx(offload_remaining, abs=0.2)
|
|
assert ctx.kill_deadline is not None
|
|
finally:
|
|
reset_call_context(token)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sandbox_slow_setup_does_not_consume_offload_budget(tmp_path):
|
|
"""A long first-time sandbox setup must not shrink offload remaining."""
|
|
import asyncio
|
|
|
|
from pineagents.tool_calls import reset_call_context, set_call_context
|
|
from pineagents.tool_calls._context import ToolCallContext
|
|
|
|
loop = asyncio.get_running_loop()
|
|
now = loop.time()
|
|
offload_remaining = 1.0
|
|
offload_at = now + offload_remaining
|
|
ctx = ToolCallContext(
|
|
tool_call_id="tc-sandbox-slow",
|
|
tool_name="execute_shell_command",
|
|
session_id="s",
|
|
agent_id="a",
|
|
root_session_id="r",
|
|
started_at=now,
|
|
offload_deadline=offload_at,
|
|
cancel_event=asyncio.Event(),
|
|
)
|
|
token = set_call_context(ctx)
|
|
|
|
class _SlowSandboxCM:
|
|
async def __aenter__(self):
|
|
await asyncio.sleep(0.35)
|
|
sandbox = AsyncMock()
|
|
sandbox.execute.return_value = ExecutionResult(0, "ok", "")
|
|
return sandbox
|
|
|
|
async def __aexit__(self, *args):
|
|
return None
|
|
|
|
config = SandboxConfig(
|
|
mode=SandboxMode.NONE,
|
|
workspace_dir=str(tmp_path),
|
|
)
|
|
|
|
try:
|
|
with patch(
|
|
"pineagents.sandbox.create_sandbox",
|
|
return_value=_SlowSandboxCM(),
|
|
):
|
|
await _execute_in_sandbox(
|
|
"echo ok",
|
|
config,
|
|
8.0,
|
|
str(tmp_path),
|
|
{"PATH": "/bin"},
|
|
)
|
|
# Without compensation remaining would be ~0.65; with it ~1.0.
|
|
remaining = ctx.offload_deadline - loop.time()
|
|
assert remaining == pytest.approx(offload_remaining, abs=0.08)
|
|
assert remaining > 0.9
|
|
finally:
|
|
reset_call_context(token)
|
|
|
|
|
|
def test_cancel_stderr_message_distinguishes_timeout_and_user():
|
|
import asyncio
|
|
|
|
from pineagents.tool_calls import reset_call_context, set_call_context
|
|
from pineagents.tool_calls._context import CancelReason, ToolCallContext
|
|
|
|
ctx = ToolCallContext(
|
|
tool_call_id="tc-msg",
|
|
tool_name="execute_shell_command",
|
|
session_id="s",
|
|
agent_id="a",
|
|
root_session_id="r",
|
|
started_at=0.0,
|
|
offload_deadline=None,
|
|
cancel_event=asyncio.Event(),
|
|
cancel_reason=CancelReason.TIMEOUT,
|
|
)
|
|
token = set_call_context(ctx)
|
|
try:
|
|
msg = _cancel_stderr_message(42.0)
|
|
assert "TimeoutError" in msg
|
|
assert "42.0" in msg
|
|
|
|
ctx.cancel_reason = CancelReason.USER
|
|
user_msg = _cancel_stderr_message(42.0)
|
|
assert "cancelled by the user" in user_msg
|
|
assert "Do not retry" in user_msg
|
|
finally:
|
|
reset_call_context(token)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.skipif(
|
|
sys.platform == "win32",
|
|
reason="Unix non-sandbox subprocess path under test",
|
|
)
|
|
async def test_unix_shell_cancellederror_uses_timeout_stderr():
|
|
"""kill_deadline CancelledError must surface TimeoutError text on Unix."""
|
|
import asyncio
|
|
|
|
from pineagents.agents.tools.shell import execute_shell_command
|
|
from pineagents.tool_calls import reset_call_context, set_call_context
|
|
from pineagents.tool_calls._context import CancelReason, ToolCallContext
|
|
|
|
ctx = ToolCallContext(
|
|
tool_call_id="tc-unix-timeout",
|
|
tool_name="execute_shell_command",
|
|
session_id="s",
|
|
agent_id="a",
|
|
root_session_id="r",
|
|
started_at=0.0,
|
|
offload_deadline=None,
|
|
cancel_event=asyncio.Event(),
|
|
cancel_reason=CancelReason.TIMEOUT,
|
|
)
|
|
token = set_call_context(ctx)
|
|
|
|
proc = MagicMock()
|
|
proc.returncode = -1
|
|
|
|
async def _fake_cleanup(proc_arg, stderr_suffix):
|
|
return "", stderr_suffix
|
|
|
|
try:
|
|
with (
|
|
patch(
|
|
"pineagents.agents.tools.shell.asyncio.create_subprocess_shell",
|
|
AsyncMock(return_value=proc),
|
|
),
|
|
patch(
|
|
"pineagents.tool_calls.cancellable_wait",
|
|
AsyncMock(side_effect=asyncio.CancelledError()),
|
|
),
|
|
patch(
|
|
"pineagents.agents.tools.shell._cleanup_proc",
|
|
side_effect=_fake_cleanup,
|
|
),
|
|
):
|
|
result = await execute_shell_command(
|
|
"sleep 99",
|
|
timeout=7.5,
|
|
sandbox_config=None,
|
|
)
|
|
text = result.content[0].text
|
|
assert "TimeoutError" in text
|
|
assert "7.5" in text
|
|
assert "cancelled" not in text.lower()
|
|
finally:
|
|
reset_call_context(token)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.skipif(
|
|
sys.platform == "win32",
|
|
reason="Unix non-sandbox subprocess path under test",
|
|
)
|
|
async def test_unix_shell_cancellederror_uses_user_cancel_stderr():
|
|
import asyncio
|
|
|
|
from pineagents.agents.tools.shell import execute_shell_command
|
|
from pineagents.tool_calls import reset_call_context, set_call_context
|
|
from pineagents.tool_calls._context import CancelReason, ToolCallContext
|
|
|
|
ctx = ToolCallContext(
|
|
tool_call_id="tc-unix-user",
|
|
tool_name="execute_shell_command",
|
|
session_id="s",
|
|
agent_id="a",
|
|
root_session_id="r",
|
|
started_at=0.0,
|
|
offload_deadline=None,
|
|
cancel_event=asyncio.Event(),
|
|
cancel_reason=CancelReason.USER,
|
|
)
|
|
token = set_call_context(ctx)
|
|
|
|
proc = MagicMock()
|
|
proc.returncode = -1
|
|
|
|
async def _fake_cleanup(proc_arg, stderr_suffix):
|
|
return "", stderr_suffix
|
|
|
|
try:
|
|
with (
|
|
patch(
|
|
"pineagents.agents.tools.shell.asyncio.create_subprocess_shell",
|
|
AsyncMock(return_value=proc),
|
|
),
|
|
patch(
|
|
"pineagents.tool_calls.cancellable_wait",
|
|
AsyncMock(side_effect=asyncio.CancelledError()),
|
|
),
|
|
patch(
|
|
"pineagents.agents.tools.shell._cleanup_proc",
|
|
side_effect=_fake_cleanup,
|
|
),
|
|
):
|
|
result = await execute_shell_command(
|
|
"sleep 99",
|
|
timeout=7.5,
|
|
sandbox_config=None,
|
|
)
|
|
text = result.content[0].text
|
|
assert "cancelled by the user" in text
|
|
assert "Do not retry" in text
|
|
assert "TimeoutError" not in text
|
|
finally:
|
|
reset_call_context(token)
|
|
|
|
|
|
def test_execute_subprocess_sync_honors_stop_event(tmp_path):
|
|
"""stop_event must kill the process tree before the full timeout."""
|
|
import threading
|
|
import time
|
|
|
|
stop_event = threading.Event()
|
|
|
|
def _arm_stop() -> None:
|
|
time.sleep(0.15)
|
|
stop_event.set()
|
|
|
|
killed_pids: list[int] = []
|
|
|
|
def _fake_kill(pid: int) -> None:
|
|
killed_pids.append(pid)
|
|
if sys.platform == "win32":
|
|
subprocess.call(
|
|
["taskkill", "/F", "/T", "/PID", str(pid)],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
timeout=10,
|
|
)
|
|
return
|
|
try:
|
|
os.kill(pid, signal.SIGKILL)
|
|
except OSError:
|
|
pass
|
|
|
|
# Avoid /bin/sh on Windows (FileNotFound → except path returns -1
|
|
# without calling the kill helper).
|
|
if sys.platform == "win32":
|
|
cmd = "ping -n 60 127.0.0.1 >NUL"
|
|
shell_executable = None
|
|
else:
|
|
cmd = "sleep 30"
|
|
shell_executable = "/bin/sh"
|
|
|
|
armer = threading.Thread(target=_arm_stop)
|
|
armer.start()
|
|
started = time.monotonic()
|
|
try:
|
|
with patch(
|
|
"pineagents.agents.tools.shell._kill_process_tree_win32",
|
|
side_effect=_fake_kill,
|
|
):
|
|
code, _stdout, _stderr = _execute_subprocess_sync(
|
|
cmd,
|
|
str(tmp_path),
|
|
timeout=60.0,
|
|
shell_executable=shell_executable,
|
|
stop_event=stop_event,
|
|
)
|
|
finally:
|
|
armer.join(timeout=2)
|
|
|
|
elapsed = time.monotonic() - started
|
|
assert code == -1
|
|
assert killed_pids
|
|
assert elapsed < 5.0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_windows_host_arms_kill_deadline():
|
|
import asyncio
|
|
|
|
from pineagents.tool_calls import reset_call_context, set_call_context
|
|
from pineagents.tool_calls._context import ToolCallContext
|
|
|
|
loop = asyncio.get_running_loop()
|
|
ctx = ToolCallContext(
|
|
tool_call_id="tc-win-kill",
|
|
tool_name="execute_shell_command",
|
|
session_id="s",
|
|
agent_id="a",
|
|
root_session_id="r",
|
|
started_at=loop.time(),
|
|
offload_deadline=None,
|
|
cancel_event=asyncio.Event(),
|
|
)
|
|
token = set_call_context(ctx)
|
|
|
|
def _fake_sync(*_args, **_kwargs):
|
|
return 0, "ok", ""
|
|
|
|
try:
|
|
with patch(
|
|
"pineagents.agents.tools.shell._execute_subprocess_sync",
|
|
side_effect=_fake_sync,
|
|
):
|
|
code, out, _err = await _execute_windows_host(
|
|
"echo ok",
|
|
"/tmp",
|
|
3.5,
|
|
{"PATH": "/bin"},
|
|
None,
|
|
)
|
|
assert code == 0
|
|
assert out == "ok"
|
|
assert ctx.kill_deadline is not None
|
|
remaining = ctx.kill_deadline - loop.time()
|
|
assert remaining == pytest.approx(3.5, abs=0.5)
|
|
finally:
|
|
reset_call_context(token)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_windows_host_cancel_bridges_stop_event():
|
|
import asyncio
|
|
import time
|
|
|
|
from pineagents.tool_calls import reset_call_context, set_call_context
|
|
from pineagents.tool_calls._context import CancelReason, ToolCallContext
|
|
|
|
loop = asyncio.get_running_loop()
|
|
ctx = ToolCallContext(
|
|
tool_call_id="tc-win-cancel",
|
|
tool_name="execute_shell_command",
|
|
session_id="s",
|
|
agent_id="a",
|
|
root_session_id="r",
|
|
started_at=loop.time(),
|
|
offload_deadline=None,
|
|
cancel_event=asyncio.Event(),
|
|
)
|
|
token = set_call_context(ctx)
|
|
seen: dict[str, object] = {}
|
|
|
|
def _fake_sync(
|
|
_cmd,
|
|
_cwd,
|
|
_timeout,
|
|
_env=None,
|
|
_shell_executable=None,
|
|
stop_event=None,
|
|
):
|
|
seen["has_stop"] = stop_event is not None
|
|
deadline = time.time() + 2.0
|
|
while time.time() < deadline:
|
|
if stop_event is not None and stop_event.is_set():
|
|
seen["stopped"] = True
|
|
return -1, "", ""
|
|
time.sleep(0.02)
|
|
return 0, "too-late", ""
|
|
|
|
try:
|
|
with patch(
|
|
"pineagents.agents.tools.shell._execute_subprocess_sync",
|
|
side_effect=_fake_sync,
|
|
):
|
|
task = asyncio.create_task(
|
|
_execute_windows_host(
|
|
"sleep 99",
|
|
"/tmp",
|
|
30.0,
|
|
{"PATH": "/bin"},
|
|
None,
|
|
),
|
|
)
|
|
await asyncio.sleep(0.05)
|
|
assert ctx.kill_deadline is not None
|
|
ctx.cancel_reason = CancelReason.USER
|
|
ctx.cancel_event.set()
|
|
code, _out, err = await asyncio.wait_for(task, timeout=2)
|
|
# Thread may observe stop_event slightly after CancelledError returns.
|
|
for _ in range(50):
|
|
if seen.get("stopped"):
|
|
break
|
|
await asyncio.sleep(0.02)
|
|
assert seen.get("has_stop") is True
|
|
assert seen.get("stopped") is True
|
|
assert code == -1
|
|
assert "cancelled by the user" in err
|
|
assert "Do not retry" in err
|
|
assert "TimeoutError" not in err
|
|
finally:
|
|
reset_call_context(token)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_windows_host_ctx_passes_no_sync_timeout():
|
|
"""Under ToolCallContext, sync must not get a frozen command timeout."""
|
|
import asyncio
|
|
import time
|
|
|
|
from pineagents.tool_calls import reset_call_context, set_call_context
|
|
from pineagents.tool_calls._context import ToolCallContext
|
|
|
|
loop = asyncio.get_running_loop()
|
|
ctx = ToolCallContext(
|
|
tool_call_id="tc-win-sync-to",
|
|
tool_name="execute_shell_command",
|
|
session_id="s",
|
|
agent_id="a",
|
|
root_session_id="r",
|
|
started_at=loop.time(),
|
|
offload_deadline=None,
|
|
cancel_event=asyncio.Event(),
|
|
)
|
|
token = set_call_context(ctx)
|
|
seen: dict[str, object] = {}
|
|
|
|
def _fake_sync(
|
|
_cmd,
|
|
_cwd,
|
|
timeout,
|
|
_env=None,
|
|
_shell_executable=None,
|
|
stop_event=None,
|
|
):
|
|
seen["timeout"] = timeout
|
|
start = time.monotonic()
|
|
# Survive past the original command timeout (0.08s).
|
|
while time.monotonic() - start < 0.2:
|
|
if stop_event is not None and stop_event.is_set():
|
|
return -1, "", f"sync-timeout-{timeout}"
|
|
time.sleep(0.02)
|
|
return 0, "survived", ""
|
|
|
|
try:
|
|
with patch(
|
|
"pineagents.agents.tools.shell._execute_subprocess_sync",
|
|
side_effect=_fake_sync,
|
|
):
|
|
code, out, _err = await asyncio.wait_for(
|
|
_execute_windows_host(
|
|
"sleep 99",
|
|
"/tmp",
|
|
0.08,
|
|
{"PATH": "/bin"},
|
|
None,
|
|
),
|
|
timeout=2,
|
|
)
|
|
assert seen.get("timeout") is None
|
|
assert code == 0
|
|
assert out == "survived"
|
|
finally:
|
|
reset_call_context(token)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_windows_host_extend_kill_and_no_deadline_ignore_sync_timeout():
|
|
"""extend_kill / no_deadline must not be overridden by sync timeout."""
|
|
import asyncio
|
|
import time
|
|
|
|
from pineagents.tool_calls import reset_call_context, set_call_context
|
|
from pineagents.tool_calls._context import CancelReason, ToolCallContext
|
|
|
|
loop = asyncio.get_running_loop()
|
|
ctx = ToolCallContext(
|
|
tool_call_id="tc-win-extend",
|
|
tool_name="execute_shell_command",
|
|
session_id="s",
|
|
agent_id="a",
|
|
root_session_id="r",
|
|
started_at=loop.time(),
|
|
offload_deadline=None,
|
|
cancel_event=asyncio.Event(),
|
|
)
|
|
token = set_call_context(ctx)
|
|
seen: dict[str, object] = {}
|
|
|
|
def _fake_sync(
|
|
_cmd,
|
|
_cwd,
|
|
timeout,
|
|
_env=None,
|
|
_shell_executable=None,
|
|
stop_event=None,
|
|
):
|
|
seen["timeout"] = timeout
|
|
start = time.monotonic()
|
|
while time.monotonic() - start < 1.5:
|
|
if stop_event is not None and stop_event.is_set():
|
|
seen["stopped"] = True
|
|
return -1, "", "stopped-by-event"
|
|
if timeout is not None and time.monotonic() - start >= timeout:
|
|
return -1, "", f"sync-timeout-{timeout}"
|
|
time.sleep(0.02)
|
|
return 0, "still-running", ""
|
|
|
|
try:
|
|
with patch(
|
|
"pineagents.agents.tools.shell._execute_subprocess_sync",
|
|
side_effect=_fake_sync,
|
|
):
|
|
task = asyncio.create_task(
|
|
_execute_windows_host(
|
|
"sleep 99",
|
|
"/tmp",
|
|
0.1,
|
|
{"PATH": "/bin"},
|
|
None,
|
|
),
|
|
)
|
|
await asyncio.sleep(0.05)
|
|
assert ctx.kill_deadline is not None
|
|
# Mimic extend_kill(+10s) then no_deadline clear.
|
|
ctx.kill_deadline = loop.time() + 10.0
|
|
ctx.deadline_changed_event.set()
|
|
await asyncio.sleep(0.2)
|
|
assert not task.done()
|
|
ctx.kill_deadline = None
|
|
ctx.deadline_changed_event.set()
|
|
await asyncio.sleep(0.2)
|
|
assert not task.done()
|
|
# Cancel still terminates promptly via stop_event.
|
|
ctx.cancel_reason = CancelReason.USER
|
|
ctx.cancel_event.set()
|
|
code, _out, err = await asyncio.wait_for(task, timeout=2)
|
|
|
|
assert seen.get("timeout") is None
|
|
assert "sync-timeout-" not in str(seen)
|
|
assert seen.get("stopped") is True
|
|
assert code == -1
|
|
assert "cancelled by the user" in err
|
|
assert "Do not retry" in err
|
|
finally:
|
|
reset_call_context(token)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_windows_host_without_ctx_keeps_sync_timeout():
|
|
"""Direct/SDK calls (no ctx) still use the command timeout in sync."""
|
|
seen: dict[str, object] = {}
|
|
|
|
def _fake_sync(
|
|
_cmd,
|
|
_cwd,
|
|
timeout,
|
|
_env=None,
|
|
_shell_executable=None,
|
|
stop_event=None,
|
|
):
|
|
seen["timeout"] = timeout
|
|
return 0, "ok", ""
|
|
|
|
with patch(
|
|
"pineagents.agents.tools.shell._execute_subprocess_sync",
|
|
side_effect=_fake_sync,
|
|
):
|
|
code, out, _err = await _execute_windows_host(
|
|
"echo ok",
|
|
"/tmp",
|
|
4.25,
|
|
{"PATH": "/bin"},
|
|
None,
|
|
)
|
|
assert code == 0
|
|
assert out == "ok"
|
|
assert seen.get("timeout") == 4.25
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_windows_host_task_cancel_still_sets_stop_event():
|
|
"""force/task cancel without relying on bridge must still stop sync."""
|
|
import asyncio
|
|
import time
|
|
|
|
from pineagents.tool_calls import reset_call_context, set_call_context
|
|
from pineagents.tool_calls._context import ToolCallContext
|
|
|
|
loop = asyncio.get_running_loop()
|
|
ctx = ToolCallContext(
|
|
tool_call_id="tc-win-force-stop",
|
|
tool_name="execute_shell_command",
|
|
session_id="s",
|
|
agent_id="a",
|
|
root_session_id="r",
|
|
started_at=loop.time(),
|
|
offload_deadline=None,
|
|
cancel_event=asyncio.Event(),
|
|
)
|
|
token = set_call_context(ctx)
|
|
seen: dict[str, object] = {}
|
|
|
|
def _fake_sync(
|
|
_cmd,
|
|
_cwd,
|
|
timeout,
|
|
_env=None,
|
|
_shell_executable=None,
|
|
stop_event=None,
|
|
):
|
|
seen["timeout"] = timeout
|
|
deadline = time.time() + 2.0
|
|
while time.time() < deadline:
|
|
if stop_event is not None and stop_event.is_set():
|
|
seen["stopped"] = True
|
|
return -1, "", ""
|
|
time.sleep(0.02)
|
|
return 0, "leaked", ""
|
|
|
|
try:
|
|
with patch(
|
|
"pineagents.agents.tools.shell._execute_subprocess_sync",
|
|
side_effect=_fake_sync,
|
|
):
|
|
task = asyncio.create_task(
|
|
_execute_windows_host(
|
|
"sleep 99",
|
|
"/tmp",
|
|
30.0,
|
|
{"PATH": "/bin"},
|
|
None,
|
|
),
|
|
)
|
|
await asyncio.sleep(0.05)
|
|
# Equivalence of force cancel: cancel the awaitable without
|
|
# first setting cancel_event (bridge alone would not fire).
|
|
assert not ctx.cancel_event.is_set()
|
|
task.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
|
|
for _ in range(50):
|
|
if seen.get("stopped"):
|
|
break
|
|
await asyncio.sleep(0.02)
|
|
assert seen.get("timeout") is None
|
|
assert seen.get("stopped") is True
|
|
finally:
|
|
reset_call_context(token)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_shell_command_win32_uses_windows_host():
|
|
"""Host shell on win32 must go through the dual-deadline helper."""
|
|
from pineagents.agents.tools.shell import execute_shell_command
|
|
|
|
with (
|
|
patch("pineagents.agents.tools.shell.sys.platform", "win32"),
|
|
patch(
|
|
"pineagents.agents.tools.shell._execute_windows_host",
|
|
AsyncMock(return_value=(0, "win-ok", "")),
|
|
) as mock_win,
|
|
patch(
|
|
"pineagents.agents.tools.shell.get_current_shell_command_timeout",
|
|
return_value=None,
|
|
),
|
|
patch(
|
|
"pineagents.agents.tools.shell.get_current_workspace_dir",
|
|
return_value=None,
|
|
),
|
|
patch(
|
|
"pineagents.agents.tools.shell.get_current_shell_command_executable",
|
|
return_value=None,
|
|
),
|
|
):
|
|
result = await execute_shell_command(
|
|
"echo hi",
|
|
timeout=9.0,
|
|
sandbox_config=None,
|
|
)
|
|
|
|
mock_win.assert_awaited_once()
|
|
assert "win-ok" in result.content[0].text
|