Files
agent-desktop/tests/unit/agents/tools/test_shell.py
T

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