Files
AgentLab/backend/app/sandbox/bubblewrap_sandbox.py
T
ThearchyHelios 6af4e396b9 生产部署:prod.sh 直接运行,package-docker.sh 打 Docker 离线包(镜像 + 一键部署脚本 + 配置 + 说明)
- 服务端可以托管前端构建产物(AGENTLAB_WEB_DIST):页面路由回退到 index.html,/api 下不存在的路径照旧是 JSON 404,
  挡住路径穿越;index.html 不缓存,assets/ 带哈希的文件长缓存;没设时行为完全不变
- scripts/prod.sh:不用 Docker 的生产运行。按需构建前端,单进程、不热重载,默认只听本机,--host 开放到局域网并提示没有登录
- Dockerfile(多阶段构建,Linux 镜像):前端在构建机架构上构建,运行阶段用 python:3.12-slim,依赖装进独立的 venv,
  以非 root 用户(uid 10001)运行,数据在 /data,带健康检查;.dockerignore 排除 .env、data/、各种库文件,镜像里实测没有密钥和数据库
- scripts/package-docker.sh:构建指定平台的镜像(默认按本机架构,可选 linux/amd64),打成
  release/agentlab-<版本>-linux-<架构>.tar,里面有 image.tar、deploy.sh、agentlab.env、README.md、SHA256SUMS
- deploy/deploy.sh:目标机上只依赖 Docker,离线可用。install / start / stop / restart / status / logs / upgrade / uninstall;
  检查架构和配置,自动导入镜像,替换容器时保留旧容器以便回退,数据永远保留,日志轮转
- 修正 bubblewrap 的可用性判断:以前只看有没有装 bwrap,容器里和 Ubuntu 24.04 默认配置下装了却建不了命名空间,
  每次执行代码都失败;现在真起一次试,不能用就退回下一个后端,并写明原因
- README 增加「生产部署」一节,部署细节见 deploy/README.md
2026-09-30 09:48:16 +08:00

233 lines
9.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import asyncio
import logging
import os
import resource
import shutil
import subprocess
import sys
import time
from functools import lru_cache
from pathlib import Path
from app.core.config import settings, sanitize_session
from app.core.errors import describe_exception, raw
from app.sandbox import interpreter
from app.sandbox.base import ENTRY_PREFIX, ExecResult, Sandbox, SandboxLimits, entry_name, truncate
logger = logging.getLogger(__name__)
# 入口脚本后缀 + 执行命令前缀(沙箱内路径在运行时拼)。文件名不能写死:
# 同会话并发执行都写 main.py 会互相覆盖。
_LANG = {
"python": (".py", None), # 运行时用 interpreter.python_argv()
"bash": (".sh", ["/bin/bash"]),
"sh": (".sh", ["/bin/sh"]),
"node": (".js", ["node"]),
"javascript": (".js", ["node"]),
}
# 只读挂进沙箱的系统目录,存在才挂
_RO_BINDS = ["/usr", "/lib", "/lib64", "/bin", "/sbin", "/etc/alternatives", "/etc/ssl"]
def _on_linux() -> bool:
return sys.platform.startswith("linux")
@lru_cache(maxsize=4)
def _probe(bwrap: str) -> str:
"""真起一次 bwrap,返回失败原因;空串表示能用。
装了 bwrap 不等于建得了隔离环境:Docker 默认的 seccomp 配置不许非特权进程建
user namespace,Ubuntu 24.04 的 AppArmor 默认也限制它。以前 available() 只看
二进制在不在,于是容器里 auto 选中 bubblewrap,每次执行都报 "No permissions to
create a new namespace"——而本地子进程明明能用。
探测带上执行时同样要用的命名空间、/proc 和 /dev,任何一样建不起来都算不可用。
结果缓存:内核和容器权限在进程生命周期里不会变。
"""
argv = [
bwrap, "--unshare-all", "--die-with-parent", "--new-session",
"--ro-bind", "/", "/", "--proc", "/proc", "--dev", "/dev",
"--", shutil.which("true") or "/bin/true",
]
try:
proc = subprocess.run(argv, capture_output=True, text=True, timeout=10)
except (OSError, subprocess.SubprocessError) as e:
return raw(e)
if proc.returncode == 0:
return ""
lines = [line.strip() for line in (proc.stderr or "").splitlines() if line.strip()]
return lines[-1] if lines else f"退出码 {proc.returncode}"
class BubblewrapSandbox(Sandbox):
"""Linux 上的对等方案:bubblewrap(Flatpak 的沙箱内核)。
一个几百 KB 的二进制,直接用 namespace + seccomp 建隔离环境,
不需要 daemon、不需要 root、不需要镜像。相比 Seatbelt 它还多隔离了
PID、IPC 和挂载视图,隔离强度更接近容器。
"""
name = "bubblewrap"
def __init__(self) -> None:
self._root = settings.workspace_dir / "bwrap"
self._bwrap = shutil.which("bwrap")
@staticmethod
def available() -> bool:
if not _on_linux():
return False
bwrap = shutil.which("bwrap")
return bwrap is not None and not _probe(bwrap)
@staticmethod
def probe_failure() -> str:
"""装了 bwrap 却建不起隔离环境时的原因;没装或者能用都返回空串。"""
bwrap = shutil.which("bwrap") if _on_linux() else None
return _probe(bwrap) if bwrap else ""
@classmethod
def unavailable_reason(cls) -> str:
failure = cls.probe_failure()
if failure:
return f"已安装 bwrap,但无法创建隔离环境(容器默认权限或系统策略不允许):{failure}"
return "未找到 bwrap,可执行 apt install bubblewrap 安装"
async def health(self) -> dict[str, object]:
ok = self.available()
return {
"backend": self.name,
"available": ok,
"isolated": ok,
"isolation": "Linux namespace(mount/pid/ipc/uts/net)+ 只读根",
"limits_note": "内存与 CPU 由 rlimit 限制;如需 cgroups 级配额请配合 systemd-run",
"enforced": {"timeout": True, "cpu": True, "memory": True, "network": True, "fsize": True},
"root": str(self._root),
"interpreter": interpreter.describe(),
**({} if ok else {"error": self.unavailable_reason()}),
}
def _session_dir(self, session_id: str) -> Path:
# 净化规则统一在 config.sanitize_session 里,四个后端和文件工具共用一份:
# 这段逻辑以前各抄各的,文件工具那份漏抄了,就成了越界读取的口子。
path = self._root / sanitize_session(session_id)
path.mkdir(parents=True, exist_ok=True)
return path
@staticmethod
def _preexec(limits: SandboxLimits): # pragma: no cover - 子进程里执行
def _apply() -> None:
resource.setrlimit(resource.RLIMIT_CPU, (limits.timeout, limits.timeout + 2))
# Linux 上 RLIMIT_AS 是真正生效的(macOS 不是)
nbytes = limits.memory_mb * 1024 * 1024
resource.setrlimit(resource.RLIMIT_AS, (nbytes, nbytes))
resource.setrlimit(resource.RLIMIT_FSIZE, (256 * 1024 * 1024,) * 2)
resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
os.setsid()
return _apply
def _argv(self, workdir: Path, limits: SandboxLimits, cmd: list[str]) -> list[str]:
argv = [
self._bwrap or "bwrap",
"--unshare-all", # mount/pid/ipc/uts/cgroup/net 全部隔离
*(["--share-net"] if limits.network else []),
"--die-with-parent", # 父进程没了,沙箱跟着走
"--new-session", # 摘掉控制终端,防 TIOCSTI 注入
"--proc", "/proc",
"--dev", "/dev",
"--tmpfs", "/tmp",
"--bind", str(workdir), "/work",
"--chdir", "/work",
"--setenv", "HOME", "/work",
"--setenv", "TMPDIR", "/tmp",
"--setenv", "PATH", "/usr/bin:/bin",
"--setenv", "PYTHONDONTWRITEBYTECODE", "1",
]
for path in _RO_BINDS:
if Path(path).exists():
argv += ["--ro-bind", path, path]
return argv + ["--", *cmd]
async def run(
self,
code: str,
*,
language: str = "python",
limits: SandboxLimits | None = None,
session_id: str | None = None,
env: dict[str, str] | None = None,
files: dict[str, str] | None = None,
) -> ExecResult:
limits = limits or SandboxLimits()
language = (language or "python").lower()
if language not in _LANG:
return ExecResult(ok=False, backend=self.name, error=f"不支持的语言:{language}")
if not self.available():
return ExecResult(ok=False, backend=self.name, error=self.unavailable_reason())
ext, cmd_prefix = _LANG[language]
# python 的命令前缀运行时才定:要挑一个看不见后端依赖的解释器
cmd_prefix = cmd_prefix if cmd_prefix is not None else interpreter.python_argv()
entry = entry_name(ext)
cmd = [*cmd_prefix, f"/work/{entry}"]
workdir = self._session_dir(session_id or f"tmp-{int(time.time() * 1000)}")
(workdir / entry).write_text(code)
for rel, content in (files or {}).items():
target = (workdir / rel).resolve()
if not str(target).startswith(str(workdir.resolve())):
return ExecResult(ok=False, backend=self.name, error=f"非法文件路径:{rel}")
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content)
argv = self._argv(workdir, limits, cmd)
for key, value in (env or {}).items():
argv[1:1] = ["--setenv", key, value]
started = time.perf_counter()
try:
proc = await asyncio.create_subprocess_exec(
*argv,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
preexec_fn=self._preexec(limits),
)
out, err = await asyncio.wait_for(proc.communicate(), timeout=limits.timeout)
except asyncio.TimeoutError:
try:
os.killpg(os.getpgid(proc.pid), 9)
except (ProcessLookupError, PermissionError):
proc.kill()
await proc.wait()
return ExecResult(
ok=False, backend=self.name, timed_out=True, exit_code=124,
duration_ms=int((time.perf_counter() - started) * 1000),
error=f"执行超过 {limits.timeout} 秒,已终止",
)
except Exception as e: # noqa: BLE001
# 起进程和收输出在同一个 try 里。首行只说原因,类名和 errno 这些留给日志
logger.warning("%s 沙箱进程出错:%s", self.name, raw(e))
return ExecResult(ok=False, backend=self.name, error=f"沙箱进程出错:{describe_exception(e)}")
stdout, t1 = truncate(out.decode(errors="replace"), limits.max_output)
stderr, t2 = truncate(err.decode(errors="replace"), limits.max_output)
code_ = proc.returncode or 0
return ExecResult(
ok=code_ == 0,
exit_code=code_,
stdout=stdout,
stderr=stderr,
truncated=t1 or t2,
timed_out=code_ in (-24, 152),
backend=self.name,
duration_ms=int((time.perf_counter() - started) * 1000),
files_written=[p.name for p in workdir.iterdir() if p.is_file() and not p.name.startswith(ENTRY_PREFIX)][:50],
)
async def cleanup(self, session_id: str) -> None:
await asyncio.to_thread(shutil.rmtree, self._session_dir(session_id), True)