From 7d00c24ab1ec7c232f180714ed720ed5a6489052 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 14 Sep 2026 14:10:10 +0800 Subject: [PATCH] =?UTF-8?q?microVM=20=E6=B2=99=E7=AE=B1=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=EF=BC=9A=E5=86=85=E5=AD=98=E9=99=90=E9=A2=9D=E7=BB=88=E4=BA=8E?= =?UTF-8?q?=E7=9C=9F=E9=99=90=E5=BE=97=E4=BD=8F=EF=BC=8C=E4=BD=86=E7=BD=91?= =?UTF-8?q?=E7=BB=9C=E5=85=B3=E4=B8=8D=E5=B9=B2=E5=87=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 那档回归未受影响。 --- .env.example | 19 +- README.md | 74 ++++-- backend/app/core/config.py | 11 +- backend/app/engine/nodes/tools.py | 12 +- backend/app/sandbox/manager.py | 82 ++++-- backend/app/sandbox/microvm_sandbox.py | 331 +++++++++++++++++++++++++ frontend/src/canvas/nodeDefs.ts | 10 + frontend/src/pages/SettingsPage.tsx | 31 ++- 8 files changed, 522 insertions(+), 48 deletions(-) create mode 100644 backend/app/sandbox/microvm_sandbox.py diff --git a/.env.example b/.env.example index 03af079b..452b0d52 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index d0761d74..a7a8c270 100644 --- a/README.md +++ b/README.md @@ -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 调大,或把思考模式设为关闭。时间线里会有提示。 diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 744dacf8..a61d2e54 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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 diff --git a/backend/app/engine/nodes/tools.py b/backend/app/engine/nodes/tools.py index 151f7cef..b4d8c447 100644 --- a/backend/app/engine/nodes/tools.py +++ b/backend/app/engine/nodes/tools.py @@ -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, diff --git a/backend/app/sandbox/manager.py b/backend/app/sandbox/manager.py index 3fe9387f..7f5abf27 100644 --- a/backend/app/sandbox/manager.py +++ b/backend/app/sandbox/manager.py @@ -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, "") diff --git a/backend/app/sandbox/microvm_sandbox.py b/backend/app/sandbox/microvm_sandbox.py new file mode 100644 index 00000000..923c4085 --- /dev/null +++ b/backend/app/sandbox/microvm_sandbox.py @@ -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) diff --git a/frontend/src/canvas/nodeDefs.ts b/frontend/src/canvas/nodeDefs.ts index dcdff1aa..cf708567 100644 --- a/frontend/src/canvas/nodeDefs.ts +++ b/frontend/src/canvas/nodeDefs.ts @@ -196,6 +196,16 @@ export const NODE_DEFS: Record = { { 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: '总是确认(可改代码)' }], diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 107d6714..5560b5af 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -450,10 +450,11 @@ function SystemTab() { {sandbox.defaults && ( - - - - + + + + } /> )} @@ -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 ( + + {text} · 部分生效 + + ) + } return on ? ( {text}