mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:51:57 +00:00
ba87a93ef7
以前被执行的代码看得到后端进程的全部环境变量:模型的 API Key,现在又多一条带密码的 AGENTLAB_DATABASE_URL。local、seatbelt 一直只给最小的一套。调用方给的变量以前插在最前面、会被缺省的盖掉,现在放在最后、能盖过缺省的,同另外两种沙箱。测试(本机没有 bwrap,测参数和进程环境的组装):--clearenv 在所有 --setenv 前面,AGENTLAB_* / *_API_KEY 的名字和值都不出现,bwrap 进程的环境只有 PATH
161 lines
6.8 KiB
Python
161 lines
6.8 KiB
Python
"""装了 bwrap 不等于建得了隔离环境。
|
||
|
||
Docker 默认的 seccomp 配置不许非特权进程建 user namespace,Ubuntu 24.04 的 AppArmor 默认也限制它。
|
||
以前 available() 只看 bwrap 在不在:容器里 auto 选中 bubblewrap,每次执行都报
|
||
「No permissions to create a new namespace」,而本地子进程明明能用。现在真起一次 bwrap 试,
|
||
起不来就算不可用,auto 往下退。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import subprocess
|
||
from typing import Any
|
||
|
||
import pytest
|
||
|
||
from app.sandbox import bubblewrap_sandbox as bw
|
||
from app.sandbox import manager as mgr
|
||
from app.sandbox.local_sandbox import LocalSandbox
|
||
|
||
DENIED = (
|
||
"bwrap: No permissions to create a new namespace, likely because the kernel does not "
|
||
"allow non-privileged user namespaces.\n"
|
||
)
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _fresh_probe(monkeypatch: pytest.MonkeyPatch):
|
||
bw._probe.cache_clear()
|
||
monkeypatch.setattr(bw, "_on_linux", lambda: True)
|
||
yield
|
||
bw._probe.cache_clear()
|
||
|
||
|
||
def _fake_run(returncode: int, stderr: str = "", calls: list[Any] | None = None):
|
||
def run(argv, **kwargs): # noqa: ANN001, ANN003, ANN202
|
||
if calls is not None:
|
||
calls.append(argv)
|
||
return subprocess.CompletedProcess(argv, returncode, stdout="", stderr=stderr)
|
||
|
||
return run
|
||
|
||
|
||
def test_binary_present_but_namespaces_denied(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
calls: list[Any] = []
|
||
monkeypatch.setattr(bw.shutil, "which", lambda name: f"/usr/bin/{name}")
|
||
monkeypatch.setattr(bw.subprocess, "run", _fake_run(1, DENIED, calls))
|
||
|
||
assert bw.BubblewrapSandbox.available() is False
|
||
assert "No permissions" in bw.BubblewrapSandbox.probe_failure()
|
||
# 探测真的在起 bwrap,并且带上执行时同样要用的命名空间和 /proc
|
||
argv = calls[0]
|
||
assert argv[0] == "/usr/bin/bwrap"
|
||
assert "--unshare-all" in argv and "--proc" in argv
|
||
# 结果缓存:内核和容器权限在进程生命周期里不会变
|
||
bw.BubblewrapSandbox.available()
|
||
assert len(calls) == 1
|
||
|
||
|
||
async def test_health_explains_why(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
monkeypatch.setattr(bw.shutil, "which", lambda name: f"/usr/bin/{name}")
|
||
monkeypatch.setattr(bw.subprocess, "run", _fake_run(1, DENIED))
|
||
info = await bw.BubblewrapSandbox().health()
|
||
assert info["available"] is False
|
||
assert "No permissions" in info["error"]
|
||
|
||
|
||
def test_binary_present_and_working(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
monkeypatch.setattr(bw.shutil, "which", lambda name: f"/usr/bin/{name}")
|
||
monkeypatch.setattr(bw.subprocess, "run", _fake_run(0))
|
||
assert bw.BubblewrapSandbox.available() is True
|
||
assert bw.BubblewrapSandbox.probe_failure() == ""
|
||
|
||
|
||
def test_probe_that_cannot_start_counts_as_unavailable(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
def boom(argv, **kwargs): # noqa: ANN001, ANN003, ANN202
|
||
raise PermissionError(13, "Permission denied")
|
||
|
||
monkeypatch.setattr(bw.shutil, "which", lambda name: f"/usr/bin/{name}")
|
||
monkeypatch.setattr(bw.subprocess, "run", boom)
|
||
assert bw.BubblewrapSandbox.available() is False
|
||
|
||
|
||
def test_no_binary_skips_probe(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
calls: list[Any] = []
|
||
monkeypatch.setattr(bw.shutil, "which", lambda name: None)
|
||
monkeypatch.setattr(bw.subprocess, "run", _fake_run(0, calls=calls))
|
||
assert bw.BubblewrapSandbox.available() is False
|
||
assert bw.BubblewrapSandbox.probe_failure() == ""
|
||
assert calls == []
|
||
|
||
|
||
def test_auto_falls_back_to_local_and_says_why(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
monkeypatch.setattr(bw.shutil, "which", lambda name: f"/usr/bin/{name}")
|
||
monkeypatch.setattr(bw.subprocess, "run", _fake_run(1, DENIED))
|
||
monkeypatch.setattr(mgr.MicroVMSandbox, "available", staticmethod(lambda: False))
|
||
monkeypatch.setattr(mgr.SeatbeltSandbox, "available", staticmethod(lambda: False))
|
||
monkeypatch.setattr(mgr.settings, "sandbox_backend", "auto")
|
||
|
||
manager = mgr.SandboxManager()
|
||
backend = manager._resolve()
|
||
assert isinstance(backend, LocalSandbox)
|
||
assert "bwrap" in manager._resolved_from
|
||
assert "本地子进程" in manager._resolved_from
|
||
# 节点上选 fast 档也不能点到一个起不来的 bubblewrap
|
||
monkeypatch.setattr(mgr.sys, "platform", "linux")
|
||
assert manager._resolve_named("fast") is None
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 环境变量:沙箱里的代码看不到后端进程的(模型的 API Key、带密码的 AGENTLAB_DATABASE_URL)
|
||
# --------------------------------------------------------------------------
|
||
|
||
_SECRETS = {"AGENTLAB_DATABASE_URL": "postgresql+asyncpg://u:not-a-real-password@h/db",
|
||
"ANTHROPIC_API_KEY": "sk-ant-not-real", "OPENAI_API_KEY": "sk-not-real"}
|
||
|
||
|
||
def _setenvs(argv: list[str]) -> dict[str, str]:
|
||
return {argv[i + 1]: argv[i + 2] for i, a in enumerate(argv) if a == "--setenv"}
|
||
|
||
|
||
def test_the_sandbox_starts_from_a_cleared_environment(monkeypatch, tmp_path):
|
||
from app.sandbox.base import SandboxLimits
|
||
|
||
for key, value in _SECRETS.items():
|
||
monkeypatch.setenv(key, value)
|
||
argv = bw.BubblewrapSandbox()._argv(tmp_path, SandboxLimits(), ["/usr/bin/python3", "/work/x.py"],
|
||
{"RUN_FLAG": "1", "HOME": "/work/custom"})
|
||
# --clearenv 在所有 --setenv 前面:bwrap 按先后处理,放后面就把设好的也清掉了
|
||
assert argv[1] == "--clearenv" and argv.index("--clearenv") < argv.index("--setenv")
|
||
env = _setenvs(argv)
|
||
assert env["RUN_FLAG"] == "1" and env["HOME"] == "/work/custom", "调用方给的照旧生效,能盖过缺省的"
|
||
assert env["PATH"] == "/usr/bin:/bin"
|
||
assert not any(k.startswith("AGENTLAB_") or k.endswith("_API_KEY") for k in env)
|
||
assert not any(v in " ".join(argv) for v in _SECRETS.values())
|
||
|
||
|
||
async def test_bwrap_itself_gets_only_a_minimal_environment(monkeypatch, tmp_path):
|
||
for key, value in _SECRETS.items():
|
||
monkeypatch.setenv(key, value)
|
||
seen: dict[str, Any] = {}
|
||
|
||
class Done:
|
||
returncode = 0
|
||
pid = 1
|
||
|
||
async def communicate(self):
|
||
return b"", b""
|
||
|
||
async def fake_exec(*argv, **kwargs):
|
||
seen["argv"], seen["env"] = list(argv), kwargs.get("env")
|
||
return Done()
|
||
|
||
box = bw.BubblewrapSandbox()
|
||
monkeypatch.setattr(bw.BubblewrapSandbox, "available", staticmethod(lambda: True))
|
||
monkeypatch.setattr(box, "_session_dir", lambda _sid: tmp_path)
|
||
monkeypatch.setattr(bw.asyncio, "create_subprocess_exec", fake_exec)
|
||
result = await box.run("print(1)", env={"RUN_FLAG": "1"})
|
||
assert result.ok, result.error
|
||
assert seen["env"] is not None and set(seen["env"]) == {"PATH"}, "bwrap 进程本身不继承后端的环境变量"
|
||
assert not any(v in " ".join(seen["argv"]) for v in _SECRETS.values())
|
||
assert _setenvs(seen["argv"])["RUN_FLAG"] == "1"
|