mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-11-05 07:39:47 +00:00
* Add sandboxed Python execution and home AutoBash plugin * Harden read-only shell commands and filesystem sandboxes * Group file sandbox backends in one package * Fix macOS file sandbox isolation and runtime access * Refine file sandbox limits and executor checks * Harden AutoBash policy and macOS sandbox supervision * fix(home): require ReMe release with file sandbox * refactor(home): simplify sandbox calls and redundant tests * fix(execution): bound output and clean up cancelled sandbox startup * fix(tests): mock Seatbelt platform requirements on Linux * fix(home): block Git help helpers and harden sandbox cleanup * Fix sandbox runtime links and subprocess cleanup * ci: cover Home plugin tests * test: wait for pipeline descendants to exit after cancellation * fix: align Home dependencies and preserve cleanup cancellation Require ReMe 0.4.2.1 and verify isolated Home wheel installation in package CI. Share cancellation-safe cleanup with thread calls, retain AnyIO cancellation semantics, and record Bubblewrap mount targets during argument construction.
141 lines
5.9 KiB
Python
141 lines
5.9 KiB
Python
"""Execution output stays bounded across pipe and launchd backends."""
|
|
|
|
# pylint: disable=protected-access
|
|
|
|
import asyncio
|
|
import os
|
|
import plistlib
|
|
import stat
|
|
import sys
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
|
|
from reme.components.application_context import ApplicationContext
|
|
from reme.steps.common.python_execute import PythonExecuteStep
|
|
from reme.utils.file_sandbox.path_seatbelt_backend import PathSeatbeltBackend
|
|
from reme.utils.local_backend import LocalProcessBackend, OUTPUT_LIMIT_BYTES
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("stream", ["stdout", "stderr", "both"])
|
|
@pytest.mark.parametrize("extra", [0, 1])
|
|
async def test_pipe_output_boundary(tmp_path, stream, extra):
|
|
"""Each stream has its own budget; crossing either budget fails execution."""
|
|
backend = LocalProcessBackend()
|
|
size = OUTPUT_LIMIT_BYTES + extra
|
|
code = "import os;" + ";".join(
|
|
f"os.write({fd}, b'x' * {size})" for fd in (1, 2) if stream in ("both", "stdout" if fd == 1 else "stderr")
|
|
)
|
|
result = await backend.exec_shell([sys.executable, "-c", code], cwd=str(tmp_path), timeout=5)
|
|
assert len(result.stdout) <= OUTPUT_LIMIT_BYTES
|
|
assert len(result.stderr) <= OUTPUT_LIMIT_BYTES
|
|
if extra:
|
|
assert not result.ok()
|
|
assert b"Output limit exceeded:" in result.stderr
|
|
else:
|
|
assert result.ok(), result.stderr
|
|
assert result.stdout == (b"x" * size if stream in ("stdout", "both") else b"")
|
|
assert result.stderr == (b"x" * size if stream in ("stderr", "both") else b"")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("fd", [1, 2])
|
|
async def test_continuous_output_stops_process(tmp_path, fd):
|
|
"""Overflow terminates a live writer before it can perform a later write."""
|
|
marker = tmp_path / "late.txt"
|
|
code = (
|
|
"import os,time;from pathlib import Path;"
|
|
f"os.write({fd}, b'x' * {OUTPUT_LIMIT_BYTES + 1});"
|
|
f"time.sleep(1);Path({str(marker)!r}).write_text('late')"
|
|
)
|
|
backend = LocalProcessBackend()
|
|
process = await backend._start_process([sys.executable, "-c", code], str(tmp_path))
|
|
result = await asyncio.wait_for(backend._communicate(process), timeout=3)
|
|
assert not result.ok()
|
|
assert b"Output limit exceeded:" in result.stderr
|
|
assert process.returncode is not None
|
|
assert not marker.exists()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_python_step_reports_output_overflow(tmp_path):
|
|
"""Python callers receive bounded partial output and failure metadata."""
|
|
step = PythonExecuteStep(app_context=ApplicationContext(workspace_dir=str(tmp_path)))
|
|
response = await step(code=f"import os;os.write(1, b'x' * {OUTPUT_LIMIT_BYTES + 1})")
|
|
assert response.success is False
|
|
assert len(response.answer) == OUTPUT_LIMIT_BYTES
|
|
assert "Output limit exceeded: stdout" in response.metadata["stderr"]
|
|
assert response.metadata["returncode"] != 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.skipif(os.name != "posix", reason="launchd uses POSIX FIFOs")
|
|
@pytest.mark.parametrize("stream", ["stdout", "stderr", "both"])
|
|
@pytest.mark.parametrize("extra", [0, 1])
|
|
async def test_launchd_output_boundary(tmp_path, monkeypatch, stream, extra):
|
|
"""Real FIFO writers obey each stream's budget and are reaped on overflow."""
|
|
backend = PathSeatbeltBackend(cwd=tmp_path, read_only_paths=[], writable_paths=[])
|
|
monkeypatch.setattr("reme.utils.file_sandbox.path_seatbelt_backend.sys.platform", "darwin")
|
|
monkeypatch.setattr("reme.utils.file_sandbox.path_seatbelt_backend.shutil.which", lambda _: "sandbox-exec")
|
|
monkeypatch.setattr(backend, "_has_gui_launchd", AsyncMock(return_value=True))
|
|
size = OUTPUT_LIMIT_BYTES + extra
|
|
code = "import os,time;" + ";".join(
|
|
f"os.write({fd}, b'x' * {size})" for fd in (1, 2) if stream in ("both", "stdout" if fd == 1 else "stderr")
|
|
)
|
|
if extra:
|
|
code += ";time.sleep(60)"
|
|
job = {}
|
|
process = None
|
|
control = None
|
|
calls = []
|
|
|
|
async def launchctl(*args):
|
|
nonlocal job, process, control
|
|
calls.append(args[0])
|
|
if args[0] == "bootstrap":
|
|
control = Path(args[-1]).parent
|
|
with open(args[-1], "rb") as source:
|
|
job = plistlib.load(source)
|
|
for key in ("StandardOutPath", "StandardErrorPath"):
|
|
assert stat.S_ISFIFO(os.stat(job[key]).st_mode)
|
|
elif args[0] == "kickstart":
|
|
with open(job["StandardOutPath"], "wb") as stdout, open(job["StandardErrorPath"], "wb") as stderr:
|
|
process = await asyncio.create_subprocess_exec(
|
|
sys.executable,
|
|
"-c",
|
|
code,
|
|
stdout=stdout,
|
|
stderr=stderr,
|
|
start_new_session=True,
|
|
)
|
|
return str(process.pid)
|
|
elif args[0] == "bootout":
|
|
if process.returncode is None:
|
|
process.kill()
|
|
await process.wait()
|
|
return ""
|
|
|
|
async def wait_status(*_args):
|
|
return await process.wait()
|
|
|
|
monkeypatch.setattr(backend, "_launchctl", launchctl)
|
|
monkeypatch.setattr(backend, "_wait_status", wait_status)
|
|
try:
|
|
result = await asyncio.wait_for(backend.exec_shell(["test-command"], timeout=5), timeout=7)
|
|
assert len(result.stdout) <= OUTPUT_LIMIT_BYTES
|
|
assert len(result.stderr) <= OUTPUT_LIMIT_BYTES
|
|
assert result.ok() is (extra == 0)
|
|
if extra:
|
|
assert b"Output limit exceeded:" in result.stderr
|
|
else:
|
|
assert result.stdout == (b"x" * size if stream in ("stdout", "both") else b"")
|
|
assert result.stderr == (b"x" * size if stream in ("stderr", "both") else b"")
|
|
assert calls == ["bootstrap", "kickstart", "bootout"]
|
|
assert process.returncode is not None
|
|
assert not control.exists()
|
|
finally:
|
|
if process is not None and process.returncode is None:
|
|
process.kill()
|
|
await process.wait()
|