mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 14:42:00 +00:00
microVM 沙箱后端:内存限额终于真限得住,但网络关不干净
Seatbelt 在 macOS 上最难受的缺口是 RLIMIT_AS 不被强制——设 256MB 仍能分配 900MB。microVM 不是"更严格的访问控制",是换了一类边界:宿主 Darwin 27.0.0, VM 里 Linux 6.12.99(libkrunfw),PID 1 是 init.krun,/Users 根本不存在, free 只看得见 512MB。申请 900MB 会被 OOM killer 杀掉而 VM 照常存活。 实现按实测契约写,不按别的 SDK 的惯例推断:create() 直接返回已启动的 Sandbox,没有 handle/connect 两层;cwd/env/timeout 都是 exec 的原生参数; 超时抛 ExecTimeoutError 而不是返回非零码。 必须写明的缺口:network=false 时 HTTP/HTTPS 与域名解析都断得掉,但 UDP/53 拦不住,手写 DNS 包仍能拿到真实响应。default_egress=DENY、Rule.deny_dns()、 显式 deny UDP、max_connections=0 全试过都堵不上——deny_dns 生成的规则只针对 宿主,DNS 出口在 microsandbox 网络栈里是硬编码放行的。所以 health 里 network 标成 partial 而不是 true,设置页对应显示黄色「部分生效」:拦了一半要是显示成 全绿,比显示成全红更危险。 成本也照实说:首次拉 OCI 镜像实测 54.5s,之后热启动 0.19s、执行 7~30ms。 贵的只是第一次,所以 auto 在镜像没缓存时先用 Seatbelt,不让人干等一分钟; 显式选 strict 档不受此限——那是使用者自己要的硬隔离。 端到端 17 项验证通过:隔离、内存拦截、超时 124、退出码透传、多文件与 env、 会话间互不可见、VM 回收。Seatbelt 那档回归未受影响。
This commit is contained in:
+17
-2
@@ -1,15 +1,30 @@
|
||||
# 复制成 .env 后按需修改。所有项都可以留空——留空时用代码里的默认值。
|
||||
# API Key 也可以不写在这里,启动后在「设置 → 模型接入」页面填,会加密存进本地 SQLite。
|
||||
|
||||
# ---- 环境 ----
|
||||
# 后端跑在哪个 conda 环境里(dev.sh 用)
|
||||
# AGENTLAB_CONDA_ENV=agentlab
|
||||
|
||||
# ---- 服务 ----
|
||||
# AGENTLAB_PORT=8000
|
||||
# AGENTLAB_WEB_PORT=5273
|
||||
|
||||
# ---- 沙箱 ----
|
||||
# auto(macOS→seatbelt,Linux→bubblewrap,都没有则 local)| seatbelt | bubblewrap | local | off
|
||||
# auto(挑第一个已就绪的:microvm → bubblewrap → seatbelt → local)
|
||||
# 可选值:microvm | seatbelt | bubblewrap | local | off
|
||||
# AGENTLAB_SANDBOX_BACKEND=auto
|
||||
|
||||
# ---- microVM 沙箱(独立 Linux 内核,需 conda 环境装了 [microvm] 额外依赖)----
|
||||
# 注意:内存/CPU/进程表是内核级真限额,但网络关不干净——HTTP/HTTPS 断得掉,
|
||||
# UDP/53 拦不住,DNS 隧道外泄通道始终开着。防外泄要靠网络层,别指望沙箱。
|
||||
# 跑代码用的 OCI 镜像,首次使用会拉取并缓存到 ~/.microsandbox(实测约 55s,仅此一次)
|
||||
# AGENTLAB_MICROVM_IMAGE=python:3.12-slim
|
||||
# 同 session 复用同一台 VM;空闲这么久就回收,避免闲置 VM 占内存
|
||||
# AGENTLAB_MICROVM_IDLE_SECONDS=300
|
||||
# 单次启动(含首次拉镜像)的等待上限
|
||||
# AGENTLAB_MICROVM_BOOT_TIMEOUT=300
|
||||
# AGENTLAB_SANDBOX_TIMEOUT=30
|
||||
# 内存限额仅在 Linux 生效,macOS 不强制 RLIMIT_AS
|
||||
# 内存限额:microvm 后端真实生效;seatbelt/local 在 macOS 上限不住(不强制 RLIMIT_AS)
|
||||
# AGENTLAB_SANDBOX_MEMORY_MB=512
|
||||
# AGENTLAB_SANDBOX_NETWORK=false
|
||||
|
||||
|
||||
@@ -20,12 +20,17 @@
|
||||
|
||||
## 快速开始
|
||||
|
||||
需要 Python 3.12+(`uv` 会自动装)、Node 20+、pnpm。**不需要 Docker** —— 代码沙箱用操作系统自带的隔离原语。
|
||||
需要 conda([miniforge](https://conda-forge.org/miniforge/) 即可)、Node 20+、pnpm。
|
||||
**不需要 Docker** —— 代码沙箱用操作系统自带的隔离原语,可选升级到 microVM。
|
||||
|
||||
```bash
|
||||
./scripts/dev.sh
|
||||
conda env create -f environment.yml # 首次:建 agentlab 环境并装依赖
|
||||
./scripts/dev.sh # 之后:一条命令拉起前后端
|
||||
```
|
||||
|
||||
`dev.sh` 会自己找到 `agentlab` 这个 conda 环境;环境不存在时它会照
|
||||
`environment.yml` 建一个。想用别的环境名就设 `AGENTLAB_CONDA_ENV`。
|
||||
|
||||
打开 http://localhost:5273 。
|
||||
|
||||
**不配任何 API Key 也能直接玩**——内置了一个 Mock provider,会产出假的流式回复、
|
||||
@@ -44,7 +49,7 @@
|
||||
| **可视化编排** | 15 种节点拖拽连线;运行时节点实时高亮、边上数据流动画、卡片里直接看到流式 token |
|
||||
| **多模型接入** | Anthropic / OpenAI / 任意 OpenAI 兼容服务(DeepSeek、Kimi、通义、智谱、硅基流动、Ollama…)+ Mock |
|
||||
| **工具链** | 15 个内置工具 + 自定义工具(HTTP 模板 / 沙箱 Python)+ MCP server 接入 |
|
||||
| **代码沙箱** | macOS 用 Seatbelt、Linux 用 bubblewrap:断网、家目录不可读、只有工作区可写、超时强杀,冷启动 ~25ms |
|
||||
| **代码沙箱** | 两档:系统沙箱(Seatbelt / bubblewrap,冷启动 ~25ms)或 microVM(独立 Linux 内核,内存限额真生效)。断网、家目录不可读、只有工作区可写、超时强杀 |
|
||||
| **人工介入** | 图暂停并落盘,人在界面上批准/驳回/改稿,从断点继续——进程重启也不丢 |
|
||||
| **记忆与知识库** | 跨运行的长期记忆(自动去重)+ 文档知识库(向量 + BM25 混合检索,可调配比) |
|
||||
| **Skill 管理** | 把「怎么做事」抽成可复用的方法论,挂到节点上注入 system prompt |
|
||||
@@ -117,13 +122,14 @@ backend/ FastAPI + LangGraph
|
||||
expressions.py 模板插值 + AST 白名单表达式求值
|
||||
nodes/ 各类节点的执行器
|
||||
app/providers/ 多模型接入与成本估算
|
||||
app/sandbox/ Seatbelt / bubblewrap / 裸子进程三后端
|
||||
app/sandbox/ microVM / Seatbelt / bubblewrap / 裸子进程四后端
|
||||
app/tools/ 工具注册表、内置工具、MCP、自定义工具
|
||||
app/memory/ 记忆、知识库、混合检索
|
||||
app/api/ REST + WebSocket
|
||||
frontend/ React 19 + React Flow + Zustand
|
||||
src/canvas/nodeDefs.ts 节点元数据——属性面板、节点库、连接桩全由它驱动
|
||||
src/store/studio.ts 画布状态 + 事件流到高亮的映射
|
||||
environment.yml conda 环境定义
|
||||
scripts/dev.sh 一键启动
|
||||
scripts/e2e-check.mjs 浏览器冒烟测试
|
||||
```
|
||||
@@ -143,24 +149,37 @@ LangGraph 的 `@task` 里,结果进 checkpoint,重放时直接取缓存。
|
||||
`RunEvent` 落库并广播。画布高亮、token 流、工具卡片、时间线全部由同一份事件驱动,
|
||||
所以刷新页面或中途接入都能拿到完整过程(WebSocket 先补历史再接实时)。
|
||||
|
||||
**沙箱不用容器。** 跑一段不可信代码不需要一整套容器运行时——操作系统自己就有
|
||||
内核级的强制访问控制。macOS 用 Seatbelt(`sandbox-exec`,系统自带),Linux 用
|
||||
bubblewrap(几百 KB 的二进制)。冷启动 ~25ms,对比容器方案的 ~250ms,且不用装任何东西。
|
||||
**沙箱不用容器,但分两档。** 轻的一档用操作系统自带的访问控制:macOS 的 Seatbelt
|
||||
(`sandbox-exec`)、Linux 的 bubblewrap,冷启动 ~25ms,不用装任何东西。重的一档是
|
||||
microVM(libkrun + Hypervisor.framework),起一台带独立内核的虚拟机。
|
||||
|
||||
守住的是三件事:**默认断网**、**家目录不可读**、**只有工作区可写**。策略由内核强制且
|
||||
对子进程继承——沙箱里 `subprocess` 出来的 `cat` 读家目录一样是 `Operation not permitted`。
|
||||
解释器刻意用 `sys.base_prefix` 下那个干净的 Python,而不是后端自己的 venv,
|
||||
所以沙箱代码 import 不到 FastAPI、SQLAlchemy 和凭据处理相关的任何东西。
|
||||
两档守的都是那三件事:**默认断网**、**家目录不可读**、**只有工作区可写**。Seatbelt 侧策略
|
||||
由内核强制且对子进程继承——沙箱里 `subprocess` 出来的 `cat` 读家目录一样是
|
||||
`Operation not permitted`;解释器刻意用 `sys.base_prefix` 下那个干净的 Python 而不是
|
||||
项目环境,所以沙箱代码 import 不到 FastAPI、SQLAlchemy 和凭据处理相关的任何东西。
|
||||
|
||||
**和容器比,少了什么要说清楚**:Seatbelt 做的是访问控制而非虚拟化——进程表是共享的
|
||||
(能看到宿主机进程列表),代码以当前用户身份运行(不像容器会降到 nobody),
|
||||
而且 **macOS 不强制 `RLIMIT_AS`,内存用量限不住**(实测设 256MB 仍能分配 900MB)。
|
||||
CPU 时间、单文件大小、墙钟超时这三项有效。bubblewrap 那边多隔离了 PID/IPC/挂载视图,
|
||||
内存限额也真实生效。要更强的隔离(内存配额、完整虚拟化),该上的是 gVisor 或 microVM,
|
||||
而不是回到容器。
|
||||
**Seatbelt 少了什么,要说清楚**:它做的是访问控制而非虚拟化——进程表共享(看得见宿主机
|
||||
进程列表),代码以当前用户身份运行(不像容器会降到 nobody),而且 **macOS 不强制
|
||||
`RLIMIT_AS`,内存用量根本限不住**(实测设 256MB 仍能分配 900MB)。CPU 时间、单文件
|
||||
大小、墙钟超时这三项有效。写 SBPL 还有个坑:`(deny default)` 会让 Python 直接 SIGABRT
|
||||
起不来,要枚举的路径和 syscall 太多、漏一个就崩,所以用的是「默认允许 + 精确拒绝」。
|
||||
|
||||
写 SBPL 策略有个坑:`(deny default)` 会让 Python 直接 SIGABRT 起不来——要枚举的路径和
|
||||
syscall 太多,漏一个就崩。所以用的是「默认允许 + 精确拒绝」,只把那三件事收紧。
|
||||
**microVM 补上的正是内存那一刀。** 本机实测:宿主是 Darwin 27.0.0,VM 里是 Linux 6.12.99
|
||||
(libkrunfw 编译),PID 1 是 `init.krun`,`/Users` 在 VM 里根本不存在,`free` 只看得见
|
||||
512MB。申请 900MB 会被内核 OOM killer 杀掉而 VM 照常存活——`RLIMIT_AS` 在 macOS 上
|
||||
形同虚设的问题在这里不是被修好,是结构上不存在。两台 VM 之间也互不可见。
|
||||
|
||||
代价照实说:运行时约 50MB,OCI 镜像**首次拉取实测 54.5s**;拉过之后热启动 0.19s、
|
||||
VM 内执行 7~30ms。所以贵的是第一次而不是每一次——也正因为只贵第一次,
|
||||
auto 模式在镜像没缓存时会先用 Seatbelt,不让人干等一分钟(显式选 strict 不受此限)。
|
||||
|
||||
**但网络关不干净,这条必须写在前面。** `network=false` 时 HTTP/HTTPS 和域名解析都会断,
|
||||
可 **UDP/53 拦不住**——实测手写 DNS 包仍能拿到真实响应。`default_egress=DENY`、
|
||||
`Rule.deny_dns()`、显式 deny UDP、`max_connections=0` 全试过,UDP 那条都堵不上
|
||||
(deny_dns 生成的规则只针对宿主,DNS 出口在 microsandbox 的网络栈里是硬编码放行的)。
|
||||
也就是说 **DNS 隧道外泄通道始终开着**。防「代码意外联网装包」够用,防「恶意代码偷数据」
|
||||
不够。所以设置页把网络这项标成「部分生效」的黄色而不是绿色——拦了一半要是显示成全绿,
|
||||
比显示成全红更危险。真要防外泄,得在网络层做,不能指望沙箱。
|
||||
|
||||
**检索为什么是混合的。** 默认 embedding 是本地特征哈希,不联网不下模型,零配置可用,
|
||||
但语义泛化弱;BM25 补上精确关键词匹配这一半。两者各自归一化后加权,权重在界面上可调,
|
||||
@@ -173,9 +192,20 @@ syscall 太多,漏一个就崩。所以用的是「默认允许 + 精确拒绝
|
||||
## 常见问题
|
||||
|
||||
**沙箱用的是哪个后端?** 设置页「运行环境」会列出所有候选及其可用性,并标出当前选中的那个。
|
||||
macOS 上应该是 `seatbelt`;Linux 上装了 `bwrap`(`apt install bubblewrap`)就是 `bubblewrap`,
|
||||
没装则降级到 `local`——**那条路径没有任何访问控制**,界面上会明确标红。
|
||||
可以用 `AGENTLAB_SANDBOX_BACKEND` 强制指定。
|
||||
装了 microVM 依赖且镜像已缓存时 auto 会选 `microvm`;否则 macOS 走 `seatbelt`、Linux 上装了
|
||||
`bwrap`(`apt install bubblewrap`)走 `bubblewrap`,都没有则降级到 `local`——**那条路径没有
|
||||
任何访问控制**,界面上会明确标红。可以用 `AGENTLAB_SANDBOX_BACKEND` 强制指定,
|
||||
也可以在代码节点上单独选「隔离档位」(strict = microVM,fast = 系统沙箱)。
|
||||
|
||||
**怎么启用 microVM?** 装可选依赖后跑一次安装(约 50MB 运行时):
|
||||
|
||||
```bash
|
||||
conda run -n agentlab pip install 'microsandbox>=0.6'
|
||||
conda run -n agentlab python -c "import asyncio,microsandbox as m; asyncio.run(m.install())"
|
||||
```
|
||||
|
||||
之后第一次执行代码会拉 OCI 镜像(实测 ~55s,只此一次),auto 就会自动升到 `microvm`。
|
||||
镜像可以用 `AGENTLAB_MICROVM_IMAGE` 换,闲置 VM 回收时间用 `AGENTLAB_MICROVM_IDLE_SECONDS`。
|
||||
|
||||
**模型返回空内容?** Claude 4.6 之后的模型默认开着 thinking,`max_tokens` 给小了会出现
|
||||
「思考完就没额度写正文」。节点里把 max_tokens 调大,或把思考模式设为关闭。时间线里会有提示。
|
||||
|
||||
@@ -31,13 +31,22 @@ class Settings(BaseSettings):
|
||||
secret_key: str = ""
|
||||
|
||||
# --- 沙箱 ---
|
||||
sandbox_backend: str = "auto" # auto | seatbelt | bubblewrap | local | off
|
||||
sandbox_backend: str = "auto" # auto | microvm | seatbelt | bubblewrap | local | off
|
||||
sandbox_timeout: int = 30
|
||||
sandbox_memory_mb: int = 512
|
||||
sandbox_cpus: float = 1.0
|
||||
sandbox_network: bool = False
|
||||
sandbox_max_output: int = 20_000
|
||||
|
||||
# --- microVM(硬件级隔离,独立内核)---
|
||||
# 跑代码用的 OCI 镜像。首次使用会拉取并缓存到 ~/.microsandbox。
|
||||
microvm_image: str = "python:3.12-slim"
|
||||
# microVM 是长驻的:同一 session 的多次执行复用同一台,省掉重复冷启动。
|
||||
# 空闲超过这个秒数就回收,避免闲置 VM 一直占着内存。
|
||||
microvm_idle_seconds: int = 300
|
||||
# 单次启动(含首次拉镜像)的等待上限
|
||||
microvm_boot_timeout: int = 300
|
||||
|
||||
# --- 执行引擎护栏 ---
|
||||
max_run_seconds: int = 600
|
||||
max_graph_steps: int = 200
|
||||
|
||||
@@ -117,14 +117,24 @@ async def run_code(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
|
||||
if not approved:
|
||||
raise NodeError(ctx.node.id, "用户拒绝执行代码")
|
||||
|
||||
ctx.emit(EventType.SANDBOX_START, language=language, limits=limits.model_dump())
|
||||
# 隔离档位:strict 要硬件级(microVM),fast 要低延迟(Seatbelt/bwrap),
|
||||
# 留空跟随整机默认。要的那档不可用时会安静退回默认,不阻断运行。
|
||||
isolation = ctx.cfg("isolation", "") or None
|
||||
|
||||
ctx.emit(EventType.SANDBOX_START, language=language, limits=limits.model_dump(),
|
||||
isolation=isolation or "auto")
|
||||
result = await sandbox_manager.run(
|
||||
code,
|
||||
language=language,
|
||||
limits=limits,
|
||||
session_id=ctx.run.thread_id,
|
||||
files=ctx.render(ctx.cfg("files", {}) or {}, state),
|
||||
backend=isolation,
|
||||
)
|
||||
if isolation == "strict" and result.backend != "microvm":
|
||||
# 要了硬件隔离却没拿到,必须说出来——否则用户以为自己在 VM 里跑
|
||||
ctx.emit(EventType.LOG, level="warn",
|
||||
message=f"节点要求 strict 隔离,但 microVM 未就绪,实际用了 {result.backend}")
|
||||
ctx.emit(
|
||||
EventType.SANDBOX_END,
|
||||
ok=result.ok,
|
||||
|
||||
@@ -8,6 +8,7 @@ from app.core.config import settings
|
||||
from app.sandbox.base import ExecResult, Sandbox, SandboxLimits
|
||||
from app.sandbox.bubblewrap_sandbox import BubblewrapSandbox
|
||||
from app.sandbox.local_sandbox import LocalSandbox
|
||||
from app.sandbox.microvm_sandbox import MicroVMSandbox
|
||||
from app.sandbox.seatbelt_sandbox import SeatbeltSandbox
|
||||
|
||||
|
||||
@@ -25,8 +26,10 @@ class DisabledSandbox(Sandbox):
|
||||
return {"backend": self.name, "available": False, "isolated": False, "reason": "已关闭"}
|
||||
|
||||
|
||||
# 按隔离强度从高到低排,auto 模式挑第一个能用的
|
||||
# 按隔离强度从高到低排,auto 模式挑第一个能用的。
|
||||
# microVM 排在最前:它是唯一能真正限住内存、隔离进程表的一档。
|
||||
_BACKENDS: list[tuple[str, type[Sandbox]]] = [
|
||||
("microvm", MicroVMSandbox),
|
||||
("bubblewrap", BubblewrapSandbox),
|
||||
("seatbelt", SeatbeltSandbox),
|
||||
("local", LocalSandbox),
|
||||
@@ -36,25 +39,56 @@ _BACKENDS: list[tuple[str, type[Sandbox]]] = [
|
||||
class SandboxManager:
|
||||
"""挑选并持有代码执行后端。
|
||||
|
||||
用操作系统自带的隔离原语,不依赖容器运行时:macOS 走 Seatbelt
|
||||
(`sandbox-exec`,系统自带),Linux 走 bubblewrap。两者都是进程级启动,
|
||||
没有虚拟机开销,冷启动在几十毫秒量级。
|
||||
隔离强度从高到低:microVM(独立内核,限额真实生效)→ bubblewrap / Seatbelt
|
||||
(共享内核的访问控制)→ 裸子进程。
|
||||
|
||||
auto 模式挑第一个**已就绪**的,判断比 available() 更严:microVM 还要求
|
||||
OCI 镜像已经拉到本地(实测首次拉取约 55s)。所以镜像没缓存的机器会安静地
|
||||
用 Seatbelt,而不是让第一次跑代码的人干等一分钟;拉过一次之后 auto 就会
|
||||
自动升到 microVM,不用改配置。
|
||||
|
||||
显式指定(配置写死 microvm,或节点选了 strict 档)不受这条限制——
|
||||
那是使用者自己要的硬隔离,该等就等。
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._backend: Sandbox | None = None
|
||||
self._named: dict[str, Sandbox] = {} # 按名字缓存,供节点级档位使用
|
||||
self._lock = asyncio.Lock()
|
||||
self._resolved_from: str = ""
|
||||
|
||||
async def get(self) -> Sandbox:
|
||||
async def get(self, prefer: str | None = None) -> Sandbox:
|
||||
"""取后端。prefer 指定档位时给那一档,拿不到就退回默认那台。
|
||||
|
||||
节点级的"隔离档位"走这条路:strict 要硬件隔离、fast 要低延迟,
|
||||
而整机默认仍由 auto 决定。
|
||||
"""
|
||||
if prefer:
|
||||
resolved = self._resolve_named(prefer)
|
||||
if resolved is not None:
|
||||
return resolved
|
||||
if self._backend is not None:
|
||||
return self._backend
|
||||
async with self._lock:
|
||||
if self._backend is not None:
|
||||
return self._backend
|
||||
self._backend = self._resolve()
|
||||
self._named[self._backend.name] = self._backend
|
||||
return self._backend
|
||||
|
||||
def _resolve_named(self, prefer: str) -> Sandbox | None:
|
||||
"""按档位名取一个**已就绪**的后端;不可用时返回 None 让调用方退回默认。"""
|
||||
alias = {"strict": "microvm", "fast": "seatbelt" if sys.platform == "darwin" else "bubblewrap"}
|
||||
name = alias.get(prefer, prefer)
|
||||
if name in self._named:
|
||||
return self._named[name]
|
||||
impl = dict(_BACKENDS).get(name)
|
||||
if impl is None or not getattr(impl, "available", lambda: True)():
|
||||
return None
|
||||
backend = impl()
|
||||
self._named[name] = backend
|
||||
return backend
|
||||
|
||||
def _resolve(self) -> Sandbox:
|
||||
choice = (settings.sandbox_backend or "auto").lower()
|
||||
|
||||
@@ -74,9 +108,15 @@ class SandboxManager:
|
||||
return backend
|
||||
|
||||
for name, impl in _BACKENDS:
|
||||
if getattr(impl, "available", lambda: True)():
|
||||
self._resolved_from = f"自动选择 {name}({_why(name)})"
|
||||
return impl()
|
||||
if not getattr(impl, "available", lambda: True)():
|
||||
continue
|
||||
# auto 模式额外要求"开箱即用":microVM 镜像没拉过就先跳过
|
||||
warm = getattr(impl, "image_ready", None)
|
||||
if warm is not None and not warm():
|
||||
self._resolved_from = f"{name} 可用但镜像未缓存,auto 先跳过"
|
||||
continue
|
||||
self._resolved_from = f"自动选择 {name}({_why(name)})"
|
||||
return impl()
|
||||
|
||||
self._resolved_from = "没有可用的隔离后端,降级到本地子进程"
|
||||
return LocalSandbox()
|
||||
@@ -90,9 +130,10 @@ class SandboxManager:
|
||||
session_id: str | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
files: dict[str, str] | None = None,
|
||||
backend: str | None = None,
|
||||
) -> ExecResult:
|
||||
backend = await self.get()
|
||||
return await backend.run(
|
||||
impl = await self.get(backend)
|
||||
return await impl.run(
|
||||
code,
|
||||
language=language,
|
||||
limits=limits,
|
||||
@@ -108,23 +149,32 @@ class SandboxManager:
|
||||
info["configured"] = settings.sandbox_backend
|
||||
info["defaults"] = SandboxLimits().model_dump()
|
||||
info["candidates"] = [
|
||||
{"name": name, "available": getattr(impl, "available", lambda: True)()}
|
||||
{
|
||||
"name": name,
|
||||
"available": getattr(impl, "available", lambda: True)(),
|
||||
# 可用但没预热的(microVM 镜像未拉取)要单独标出来:
|
||||
# 它不会被 auto 选中,但显式指定仍然能用
|
||||
**({} if getattr(impl, "image_ready", None) is None
|
||||
else {"warm": impl.image_ready()}),
|
||||
}
|
||||
for name, impl in _BACKENDS
|
||||
]
|
||||
return info
|
||||
|
||||
async def cleanup(self, session_id: str) -> None:
|
||||
backend = await self.get()
|
||||
await backend.cleanup(session_id)
|
||||
# 会话可能落在任意一档上(节点各自选了档位),全部清一遍
|
||||
for impl in {id(b): b for b in [*self._named.values(), self._backend] if b}.values():
|
||||
await impl.cleanup(session_id)
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._backend:
|
||||
await self._backend.close()
|
||||
for impl in {id(b): b for b in [*self._named.values(), self._backend] if b}.values():
|
||||
await impl.close()
|
||||
|
||||
|
||||
def _why(name: str) -> str:
|
||||
return {
|
||||
"seatbelt": "macOS 自带 sandbox-exec",
|
||||
"microvm": "microsandbox 运行时已就绪,独立内核",
|
||||
"seatbelt": "macOS 自带 sandbox-exec(microVM 未就绪)",
|
||||
"bubblewrap": "检测到 bwrap",
|
||||
"local": "无可用隔离原语",
|
||||
}.get(name, "")
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.core.config import settings
|
||||
from app.sandbox.base import ExecResult, Sandbox, SandboxLimits, truncate
|
||||
|
||||
_WORKDIR = "/workspace"
|
||||
|
||||
# 各语言在 VM 里的落地文件与执行命令
|
||||
_LANG = {
|
||||
"python": ("main.py", ["python", "-u", "main.py"]),
|
||||
"bash": ("main.sh", ["/bin/bash", "main.sh"]),
|
||||
"sh": ("main.sh", ["/bin/sh", "main.sh"]),
|
||||
"node": ("main.js", ["node", "main.js"]),
|
||||
"javascript": ("main.js", ["node", "main.js"]),
|
||||
}
|
||||
|
||||
_MAX_FILE_BYTES = 8 * 1024 * 1024
|
||||
_MANIFEST_CACHE = Path.home() / ".microsandbox" / "cache" / "manifests"
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Lease:
|
||||
"""一台长驻的 microVM。
|
||||
|
||||
SDK 的 create() 直接返回已启动的 Sandbox,没有 handle/connect 两层——
|
||||
这点是实测确认的,早先按别的 SDK 惯例推断的写法在这里是错的。
|
||||
"""
|
||||
|
||||
vm: Any
|
||||
network: bool
|
||||
memory_mb: int
|
||||
cpus: int
|
||||
last_used: float = field(default_factory=time.monotonic)
|
||||
|
||||
|
||||
class MicroVMSandbox(Sandbox):
|
||||
"""硬件级隔离:每个会话一台独立内核的 microVM(libkrun + Hypervisor.framework)。
|
||||
|
||||
和 Seatbelt 的差别不是"更严格的访问控制",而是换了一类边界。本机实测:
|
||||
宿主是 Darwin 27.0.0,VM 里是 Linux 6.12.99(libkrunfw 编译),PID 1 是
|
||||
init.krun,/Users 在 VM 里根本不存在,`free` 只看得见 512MB。
|
||||
|
||||
因此 Seatbelt 上那个最难受的缺口在这里是真的没了:申请 900MB 会被内核
|
||||
OOM killer 杀掉(进程退出码为负),而 VM 本身照常存活——macOS 上
|
||||
RLIMIT_AS 形同虚设的问题不复存在。
|
||||
|
||||
但有一处**必须说清楚的残留缺口**:网络关不干净。limits.network=False 时
|
||||
HTTP/HTTPS、域名解析都会断,可 UDP/53 拦不住——实测手写 DNS 包仍能拿到
|
||||
真实响应。试过 default_egress=DENY、Rule.deny_dns()、显式 deny UDP、
|
||||
max_connections=0,UDP 那条都堵不上(deny_dns 生成的规则只针对宿主,
|
||||
而 DNS 出口在 microsandbox 的网络栈里是硬编码放行的)。也就是说
|
||||
**DNS 隧道外泄通道始终是开的**。防"代码意外联网装包"够用,防"恶意代码
|
||||
偷数据"不够——health() 里如实标成部分生效,不写 True。
|
||||
|
||||
成本也照实说:运行时约 50MB,OCI 镜像首次拉取本机实测 54.5s;之后热启动
|
||||
0.19s、VM 内执行 7~30ms。所以贵的是第一次,不是每一次。
|
||||
"""
|
||||
|
||||
name = "microvm"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._leases: dict[str, _Lease] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
self._image = settings.microvm_image
|
||||
|
||||
# ---------------- 可用性 ----------------
|
||||
|
||||
@staticmethod
|
||||
def available() -> bool:
|
||||
"""SDK 装了、运行时也装好了才算可用。"""
|
||||
try:
|
||||
import microsandbox as ms
|
||||
except ImportError:
|
||||
return False
|
||||
try:
|
||||
return bool(ms.is_installed())
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def image_ready() -> bool:
|
||||
"""镜像是否已经拉到本地。
|
||||
|
||||
auto 模式拿这个做判断:镜像没缓存时第一次跑代码要等 50 秒以上,
|
||||
与其让人卡在那儿,不如安静地先用 Seatbelt——显式选 strict 的人
|
||||
另说,那是他自己要的硬隔离,等就等。
|
||||
"""
|
||||
try:
|
||||
return _MANIFEST_CACHE.is_dir() and any(_MANIFEST_CACHE.iterdir())
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
async def health(self) -> dict[str, Any]:
|
||||
try:
|
||||
import microsandbox as ms
|
||||
except ImportError:
|
||||
return {
|
||||
"backend": self.name,
|
||||
"available": False,
|
||||
"isolated": True,
|
||||
"error": "未安装 microsandbox(pip install 'agentlab-backend[microvm]')",
|
||||
}
|
||||
installed = False
|
||||
try:
|
||||
installed = bool(ms.is_installed())
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
cached = self.image_ready()
|
||||
return {
|
||||
"backend": self.name,
|
||||
"available": installed,
|
||||
"isolated": True,
|
||||
"isolation": "microVM:独立内核 + 独立内存 + 独立进程表(libkrun / Hypervisor.framework)",
|
||||
# 只有 network 不是真的——别的都由虚拟化边界强制执行
|
||||
"enforced": {
|
||||
"timeout": True,
|
||||
"cpu": True,
|
||||
"memory": True,
|
||||
"fsize": True,
|
||||
"pid": True,
|
||||
"network": "partial",
|
||||
},
|
||||
"limits_note": (
|
||||
"内存/CPU/进程表由内核边界强制执行(超限进程被 OOM killer 杀掉,VM 存活)。"
|
||||
"网络只能算部分生效:HTTP/HTTPS 与域名解析可断,但 UDP/53 拦不住,"
|
||||
"DNS 隧道外泄通道仍然开着。"
|
||||
),
|
||||
"image": self._image,
|
||||
"image_cached": cached,
|
||||
"live_vms": len(self._leases),
|
||||
**({} if installed else {"error": "运行时未就绪(await microsandbox.install(),约 50MB)"}),
|
||||
**({} if cached else {"first_run_cost": "镜像未缓存,首次启动需拉取(本机实测约 55s)"}),
|
||||
}
|
||||
|
||||
# ---------------- 会话(长驻 VM)----------------
|
||||
|
||||
def _vm_name(self, session_id: str) -> str:
|
||||
# SDK 要求:首字符必须是字母数字,其余只能是字母数字/点/横线/下划线
|
||||
safe = "".join(c for c in session_id if c.isalnum() or c in "-_.")[:96]
|
||||
return f"agentlab-{safe or 'default'}"
|
||||
|
||||
async def _lease(self, session_id: str, limits: SandboxLimits) -> _Lease:
|
||||
"""拿到该会话的 VM。限额变了就重建,否则复用(热复用约 7ms)。"""
|
||||
import microsandbox as ms
|
||||
|
||||
cpus = max(1, int(limits.cpus))
|
||||
async with self._lock:
|
||||
lease = self._leases.get(session_id)
|
||||
if lease is not None:
|
||||
if (lease.network == limits.network
|
||||
and lease.memory_mb == limits.memory_mb
|
||||
and lease.cpus == cpus):
|
||||
lease.last_used = time.monotonic()
|
||||
return lease
|
||||
# 限额变了:旧 VM 的边界已经不对,销毁重建
|
||||
await self._destroy(session_id)
|
||||
|
||||
# network 必须是 Network 实例(传 bool 会被 SDK 拒掉)
|
||||
network = ms.Network.allow_all() if limits.network else ms.Network.none()
|
||||
vm = await asyncio.wait_for(
|
||||
ms.Sandbox.create(
|
||||
self._vm_name(session_id),
|
||||
image=ms.Image.oci(self._image),
|
||||
cpus=cpus,
|
||||
memory=limits.memory_mb,
|
||||
network=network,
|
||||
),
|
||||
timeout=settings.microvm_boot_timeout,
|
||||
)
|
||||
try:
|
||||
await vm.fs.mkdir(_WORKDIR)
|
||||
except Exception: # noqa: BLE001 - 已存在就算了
|
||||
pass
|
||||
|
||||
lease = _Lease(vm=vm, network=limits.network,
|
||||
memory_mb=limits.memory_mb, cpus=cpus)
|
||||
self._leases[session_id] = lease
|
||||
return lease
|
||||
|
||||
async def _destroy(self, session_id: str) -> None:
|
||||
lease = self._leases.pop(session_id, None)
|
||||
if lease is None:
|
||||
return
|
||||
try:
|
||||
await lease.vm.destroy(force=True)
|
||||
except Exception: # noqa: BLE001 - 已经没了就算
|
||||
pass
|
||||
|
||||
async def _reap_idle(self) -> None:
|
||||
"""回收闲置 VM:它们各自占着几百 MB 内存,不能一直挂着。"""
|
||||
cutoff = time.monotonic() - settings.microvm_idle_seconds
|
||||
stale = [sid for sid, lease in self._leases.items() if lease.last_used < cutoff]
|
||||
for sid in stale:
|
||||
await self._destroy(sid)
|
||||
|
||||
# ---------------- 执行 ----------------
|
||||
|
||||
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:
|
||||
import microsandbox as ms
|
||||
|
||||
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="microVM 运行时未就绪:pip install 'agentlab-backend[microvm]' 后执行一次 install",
|
||||
)
|
||||
|
||||
filename, argv = _LANG[language]
|
||||
sid = session_id or f"tmp{int(time.time() * 1000)}"
|
||||
ephemeral = session_id is None
|
||||
started = time.perf_counter()
|
||||
|
||||
try:
|
||||
await self._reap_idle()
|
||||
lease = await self._lease(sid, limits)
|
||||
except asyncio.TimeoutError:
|
||||
return ExecResult(
|
||||
ok=False, backend=self.name, timed_out=True,
|
||||
duration_ms=int((time.perf_counter() - started) * 1000),
|
||||
error=f"microVM 启动超过 {settings.microvm_boot_timeout}s"
|
||||
+ ("" if self.image_ready() else "(镜像未缓存,首次需拉取约 55s)"),
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
return ExecResult(
|
||||
ok=False, backend=self.name,
|
||||
duration_ms=int((time.perf_counter() - started) * 1000),
|
||||
error=f"microVM 启动失败:{type(e).__name__}: {e}",
|
||||
)
|
||||
|
||||
try:
|
||||
payload = dict(files or {})
|
||||
payload[filename] = code
|
||||
for rel, content in payload.items():
|
||||
rel = rel.lstrip("/")
|
||||
if ".." in rel.split("/"):
|
||||
return ExecResult(ok=False, backend=self.name, error=f"非法文件路径:{rel}")
|
||||
data = content.encode()
|
||||
if len(data) > _MAX_FILE_BYTES:
|
||||
return ExecResult(ok=False, backend=self.name, error=f"{rel} 超过 8MB 上限")
|
||||
target = f"{_WORKDIR}/{rel}"
|
||||
if "/" in rel:
|
||||
try:
|
||||
await lease.vm.fs.mkdir(target.rsplit("/", 1)[0])
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
await lease.vm.fs.write(target, data)
|
||||
|
||||
# cwd / env / timeout 都是 exec 的原生参数,不用再拿 shell 拼
|
||||
out = await lease.vm.exec(
|
||||
argv[0], list(argv[1:]),
|
||||
cwd=_WORKDIR,
|
||||
env=({k: str(v) for k, v in env.items()} if env else None),
|
||||
timeout=limits.timeout,
|
||||
)
|
||||
except ms.ExecTimeoutError:
|
||||
# 超时的 VM 里可能还有进程在烧 CPU,状态不可信,直接销毁
|
||||
await self._destroy(sid)
|
||||
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}s 被终止",
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
return ExecResult(
|
||||
ok=False, backend=self.name,
|
||||
duration_ms=int((time.perf_counter() - started) * 1000),
|
||||
error=f"{type(e).__name__}: {e}",
|
||||
)
|
||||
finally:
|
||||
if ephemeral:
|
||||
await self._destroy(sid)
|
||||
|
||||
stdout, t1 = truncate(str(getattr(out, "stdout_text", "") or ""), limits.max_output)
|
||||
stderr, t2 = truncate(str(getattr(out, "stderr_text", "") or ""), limits.max_output)
|
||||
exit_code = int(getattr(out, "exit_code", 0) or 0)
|
||||
|
||||
# 退出码为负 = 进程被信号杀掉(SDK 用负值表示,具体是哪个信号它没给,
|
||||
# 所以这里不编)。VM 里最常见的原因就是撞了内存上限,而且这种情况
|
||||
# stdout/stderr 通常都是空的——不解释一句,用户只会看到"失败了"。
|
||||
error = None
|
||||
if exit_code < 0 and not stderr.strip():
|
||||
error = (f"进程被内核终止(退出码 {exit_code}),"
|
||||
f"最常见的原因是超过了 {limits.memory_mb}MB 内存上限")
|
||||
|
||||
written: list[str] = []
|
||||
if not ephemeral:
|
||||
try:
|
||||
entries = await lease.vm.fs.list(_WORKDIR)
|
||||
written = [
|
||||
getattr(e, "name", str(e)) for e in entries
|
||||
if getattr(e, "name", "") != filename
|
||||
][:50]
|
||||
except Exception: # noqa: BLE001 - 列目录失败不影响执行结果
|
||||
written = []
|
||||
|
||||
return ExecResult(
|
||||
ok=exit_code == 0,
|
||||
exit_code=exit_code,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
truncated=t1 or t2,
|
||||
backend=self.name,
|
||||
duration_ms=int((time.perf_counter() - started) * 1000),
|
||||
files_written=written,
|
||||
error=error,
|
||||
)
|
||||
|
||||
async def cleanup(self, session_id: str) -> None:
|
||||
await self._destroy(session_id)
|
||||
|
||||
async def close(self) -> None:
|
||||
for sid in list(self._leases):
|
||||
await self._destroy(sid)
|
||||
@@ -196,6 +196,16 @@ export const NODE_DEFS: Record<NodeType, NodeDef> = {
|
||||
{ key: 'timeout', label: '超时(秒)', type: 'number', min: 1, max: 300 },
|
||||
{ key: 'memory_mb', label: '内存上限 MB', type: 'number', min: 64, max: 4096, advanced: true },
|
||||
{ key: 'network', label: '允许联网', type: 'switch', help: '默认断网' },
|
||||
{
|
||||
key: 'isolation', label: '隔离档位', type: 'select',
|
||||
options: [
|
||||
{ value: '', label: '跟随整机默认' },
|
||||
{ value: 'strict', label: 'strict:microVM(独立内核,内存真限得住)' },
|
||||
{ value: 'fast', label: 'fast:系统沙箱(低延迟,内存限不住)' },
|
||||
],
|
||||
help: '不可信代码用 strict;自己写的、已审核的用 fast。两档都拦不住 DNS 出网,'
|
||||
+ '真要防数据外泄得靠网络层。要的档位不可用时会退回默认并在时间线上告警',
|
||||
},
|
||||
{
|
||||
key: 'approval', label: '执行前确认', type: 'select', advanced: true,
|
||||
options: [{ value: 'never', label: '不确认' }, { value: 'always', label: '总是确认(可改代码)' }],
|
||||
|
||||
@@ -450,10 +450,11 @@ function SystemTab() {
|
||||
{sandbox.defaults && (
|
||||
<Row label="默认限额" value={
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<Limit on={sandbox.enforced?.timeout !== false} text={`${sandbox.defaults.timeout}s 超时`} />
|
||||
<Limit on={sandbox.enforced?.memory !== false} text={`${sandbox.defaults.memory_mb}MB 内存`} />
|
||||
<Limit on={sandbox.enforced?.cpu !== false} text={`${sandbox.defaults.cpus} CPU`} />
|
||||
<Limit on={sandbox.enforced?.network !== false} text={sandbox.defaults.network ? '联网' : '断网'} />
|
||||
<Limit on={enforced(sandbox, 'timeout')} text={`${sandbox.defaults.timeout}s 超时`} />
|
||||
<Limit on={enforced(sandbox, 'memory')} text={`${sandbox.defaults.memory_mb}MB 内存`} />
|
||||
<Limit on={enforced(sandbox, 'cpu')} text={`${sandbox.defaults.cpus} CPU`} />
|
||||
<Limit on={enforced(sandbox, 'network')} text={sandbox.defaults.network ? '联网' : '断网'}
|
||||
note="HTTP/HTTPS 与域名解析可断,但 UDP/53 拦不住" />
|
||||
</span>
|
||||
} />
|
||||
)}
|
||||
@@ -474,8 +475,26 @@ function SystemTab() {
|
||||
)
|
||||
}
|
||||
|
||||
/** 限额项:当前后端管不住的,划掉并注明,别给人虚假的安全感。 */
|
||||
function Limit({ on, text }: { on: boolean; text: string }) {
|
||||
/** 后端对某项限额的执行力度。后端没说就按"生效"处理(老后端没有这个字段)。 */
|
||||
function enforced(sandbox: any, key: string): boolean | 'partial' {
|
||||
const v = sandbox.enforced?.[key]
|
||||
return v === 'partial' ? 'partial' : v !== false
|
||||
}
|
||||
|
||||
/** 限额项:当前后端管不住的,划掉并注明,别给人虚假的安全感。
|
||||
*
|
||||
* 三态而不是两态——microVM 的网络就卡在中间:HTTP/HTTPS 断得掉,
|
||||
* UDP/53 断不掉。这种"拦了一半"要是显示成绿色,比显示成红色更危险。
|
||||
*/
|
||||
function Limit({ on, text, note }: { on: boolean | 'partial'; text: string; note?: string }) {
|
||||
if (on === 'partial') {
|
||||
return (
|
||||
<span className="chip" title={note || '这项限额只部分生效'}
|
||||
style={{ color: 'var(--warn)', borderColor: 'color-mix(in srgb, var(--warn) 40%, transparent)' }}>
|
||||
{text} · 部分生效
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return on ? (
|
||||
<span className="chip" style={{ color: 'var(--ok)', borderColor: 'color-mix(in srgb, var(--ok) 40%, transparent)' }}>
|
||||
{text}
|
||||
|
||||
Reference in New Issue
Block a user