mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:01:58 +00:00
6af4e396b9
- 服务端可以托管前端构建产物(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
233 lines
9.6 KiB
Python
233 lines
9.6 KiB
Python
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)
|