8376f56ab4
Adds native sandbox execution runtime, dynamic review middleware, and workspace file handling, with supporting stream events, prompt, and scope registry changes plus architecture docs.
140 lines
4.5 KiB
Python
140 lines
4.5 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
import shlex
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from EvoScientist.native_sandbox import (
|
|
NativeSandboxExecutor,
|
|
NativeWorkspaceBackend,
|
|
ensure_native_sandbox_ready,
|
|
)
|
|
|
|
pytestmark = pytest.mark.skipif(
|
|
os.getenv("EVOSCIENTIST_RUN_NATIVE_SANDBOX_TESTS") != "1",
|
|
reason="set EVOSCIENTIST_RUN_NATIVE_SANDBOX_TESTS=1 on a supported host",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def executor(tmp_path: Path) -> NativeSandboxExecutor:
|
|
ensure_native_sandbox_ready()
|
|
files = tmp_path / "files"
|
|
runtime = tmp_path / "runtime"
|
|
files.mkdir()
|
|
runtime.mkdir()
|
|
return NativeSandboxExecutor(files, runtime, timeout=5)
|
|
|
|
|
|
def test_real_sandbox_scrubs_secrets_and_keeps_command_tmp_private(
|
|
executor: NativeSandboxExecutor, monkeypatch: pytest.MonkeyPatch
|
|
):
|
|
monkeypatch.setenv("OPENAI_API_KEY", "must-not-leak")
|
|
result = executor.execute(
|
|
'env | sort; printf result > result.txt; printf temp > "$TMPDIR/value"'
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
assert "OPENAI_API_KEY" not in result.output
|
|
assert "PROXY_PASSWORD" not in result.output
|
|
assert "HTTP_PROXY" not in result.output
|
|
assert (executor.files_dir / "result.txt").read_text(encoding="utf-8") == "result"
|
|
assert not list((executor.runtime_dir / "tmp").glob("*/tmp/value"))
|
|
|
|
|
|
def test_real_sandbox_drains_but_caps_output(
|
|
executor: NativeSandboxExecutor, monkeypatch: pytest.MonkeyPatch
|
|
):
|
|
monkeypatch.setenv("EVOSCIENTIST_SANDBOX_MAX_OUTPUT_BYTES", "64")
|
|
code = "import sys; sys.stdout.write('x' * 10000)"
|
|
|
|
result = executor.execute(f"python3 -c {shlex.quote(code)}")
|
|
|
|
assert result.exit_code == 0
|
|
assert result.truncated is True
|
|
assert len(result.output.encode("utf-8")) <= 64
|
|
|
|
|
|
def test_real_sandbox_enforces_timeout_and_file_limit(
|
|
executor: NativeSandboxExecutor, monkeypatch: pytest.MonkeyPatch
|
|
):
|
|
started = time.monotonic()
|
|
timed_out = executor.execute("sleep 5", timeout=1)
|
|
assert timed_out.exit_code == 124
|
|
assert time.monotonic() - started < 3
|
|
|
|
monkeypatch.setenv("EVOSCIENTIST_SANDBOX_FILE_SIZE_BYTES", "1024")
|
|
code = "f=open('large.bin','wb'); f.write(b'x' * 4096); f.flush()"
|
|
limited = executor.execute(f"python3 -c {shlex.quote(code)}")
|
|
assert limited.exit_code != 0
|
|
assert (executor.files_dir / "large.bin").stat().st_size <= 1024
|
|
|
|
|
|
def test_real_sandbox_kills_background_descendants(executor: NativeSandboxExecutor):
|
|
result = executor.execute("sleep 30 >/dev/null 2>&1 & echo $! > child.pid")
|
|
assert result.exit_code == 0
|
|
child_pid = int((executor.files_dir / "child.pid").read_text(encoding="ascii"))
|
|
time.sleep(0.1)
|
|
with pytest.raises(ProcessLookupError):
|
|
os.kill(child_pid, 0)
|
|
|
|
|
|
def test_file_tools_and_pandoc_share_one_workspace(tmp_path: Path):
|
|
ensure_native_sandbox_ready()
|
|
files = tmp_path / "files"
|
|
runtime = tmp_path / "runtime"
|
|
files.mkdir()
|
|
runtime.mkdir()
|
|
backend = NativeWorkspaceBackend(files, runtime, timeout=20)
|
|
assert (
|
|
backend.write("/workspace/source.md", "# 火电分析\n\n同一会话文件。\n").error
|
|
is None
|
|
)
|
|
|
|
result = backend.execute(
|
|
"mkdir -p uploads results && "
|
|
'pandoc source.md -o "uploads/湖南 火电.docx" && '
|
|
'pandoc "uploads/湖南 火电.docx" -o results/report.html'
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert (
|
|
backend.read("/workspace/uploads/湖南 火电.docx").file_data["encoding"]
|
|
== "base64"
|
|
)
|
|
report = backend.read("/workspace/results/report.html")
|
|
assert report.error is None
|
|
assert "火电分析" in report.file_data["content"]
|
|
assert backend.download_files(["/workspace/results/report.html"])[0].content
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_cancellation_terminates_process_group(tmp_path: Path):
|
|
ensure_native_sandbox_ready()
|
|
files = tmp_path / "files"
|
|
runtime = tmp_path / "runtime"
|
|
files.mkdir()
|
|
runtime.mkdir()
|
|
backend = NativeWorkspaceBackend(files, runtime, timeout=30)
|
|
|
|
task = asyncio.create_task(
|
|
backend.aexecute("echo $$ > shell.pid; sleep 30", timeout=30)
|
|
)
|
|
for _ in range(50):
|
|
if (files / "shell.pid").exists():
|
|
break
|
|
await asyncio.sleep(0.02)
|
|
assert (files / "shell.pid").exists()
|
|
shell_pid = int((files / "shell.pid").read_text(encoding="ascii"))
|
|
|
|
task.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
await asyncio.sleep(0.1)
|
|
with pytest.raises(ProcessLookupError):
|
|
os.kill(shell_pid, 0)
|