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:
ThearchyHelios
2026-09-14 14:10:10 +08:00
parent 4e901be0ec
commit 7d00c24ab1
8 changed files with 522 additions and 48 deletions
+17 -2
View File
@@ -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
+52 -22
View File
@@ -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 调大,或把思考模式设为关闭。时间线里会有提示。
+10 -1
View File
@@ -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
+11 -1
View File
@@ -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,
+66 -16
View File
@@ -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, "")
+331
View File
@@ -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)
+10
View File
@@ -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: '总是确认(可改代码)' }],
+25 -6
View File
@@ -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}