方便 Mac 本机运行 e2b 的沙箱方案 e2b-local

e2b-local 是一个跑在本机的 E2B 兼容网关。应用侧还是接 E2B SDK,只需要把 API 地址指到本地;Sandbox.create()commands.run()、filesystem、PTY 这些调用方式都不变。变化发生在后面:原本会创建到云端的 sandbox,现在会落到本机 Docker 容器,或者 OrbStack 的 Linux VM 里。

它更像是把 E2B 控制面接到本地 runtime 的一层适配器。上面尽量保持 SDK 的调用方式,下面负责把 template、volume、envd 和 sandbox 生命周期翻译成 Docker / OrbStack 的操作:Docker 模式把本机 image 当 template;OrbStack 模式把已有 machine 当 template,再 clone 出一个新的 sandbox VM。

我做它的起点不是想复刻 E2B,也不是觉得云端 sandbox 不好用。真正卡住我的,是开发 template 时那条很长的反馈链路。

一个 template 还没稳定的时候,经常只是改了一行系统依赖、换了一个启动命令、调了一下 ready check,或者临时加一个 debug 工具。按正常云端流程,每一次改动都要重新 build、上传、发布,再创建 sandbox 验证。单次看起来还好,但连续调几轮以后,人的注意力会被等待切碎。

另一个更现实的问题是本地网络。很多时候 sandbox 里要访问的服务还在我的开发机上:本地 API server、mock server、临时数据库、还没部署的内部服务。远端 sandbox 当然可以通过各种办法绕回来,但这件事本身就已经偏离了"我只是想验证一下 template 能不能跑"的初衷。

所以这个项目想解决的是开发阶段的那一截路:SDK 仍然用 E2B 的方式调用,但真正的 sandbox 跑在本机 Docker 容器或 OrbStack Linux VM 里。它不是云端 E2B 的替代品,更像是把 template 调试这件事拉回开发机,让修改、启动、观察日志、再修改形成一个短很多的闭环。

先把边界想清楚

刚开始想做本地版 sandbox 时,很容易把问题想成"本地启动一个容器,然后把 SDK 接过去"。但真正看一遍 E2B 的调用链,会发现这里面其实有两条很不一样的路径。

一条是控制面:创建 sandbox、删除 sandbox、列 template、管理 volume、看 logs 和 metrics。这些请求适合由一个本地 gateway 接住,然后翻译成本地 runtime 操作。

另一条是数据面:执行命令、读写文件、开 PTY、处理 streaming。这些能力真正落在 sandbox 内部的 envd 上。也就是说,gateway 不应该假装自己能处理所有事情;它更像调度器,负责把一个 sandbox 创建出来,然后把这个 sandbox 自己的 envdURL 返回给 SDK。后续 command、filesystem、PTY 调用,SDK 直接访问 envd。

这个拆分是整个项目里最重要的判断之一。它决定了 e2b-local 不需要重写 envd 协议,也不需要把所有数据流量都绕回 gateway。

HTTP 层使用 Gin,E2B API 的 request/response DTO 从 OpenAPI schema 通过 oapi-codegen 生成到 internal/e2bapi。这里我不太想手写一套"看起来差不多"的结构体,因为 SDK 兼容是这个项目最核心的边界。字段名、枚举、返回结构只要偏一点,最后都会变成调用方很难理解的小问题。

SDK 侧的配置保持很轻:

bash 复制代码
export E2B_API_URL="http://127.0.0.1:3000"
export E2B_API_KEY="local"
unset E2B_SANDBOX_URL

E2B_API_KEY 在这里主要是为了满足 SDK 的调用习惯,本地 gateway 不依赖真实的云端 key。

Docker 是最短的第一步

如果只看 template 调试,Docker 是最自然的起点。E2B 的自定义 template 本来就和 Docker image 有很深的关系;云上最终跑的是 Firecracker microVM,但用户构建 template 时,入口通常还是 Dockerfile 和 linux/amd64 镜像。

所以 Docker backend 的思路很直接:把本机已有的 Docker images 当成 templates。开发者在本机 build/tag 一个 image,e2b-local 从这个 image 创建 sandbox。验证通过以后,同一套 Dockerfile 或 image 构建逻辑再接到云上的 E2B template 流程里。

例如本地先构建一个 amd64 image:

bash 复制代码
docker buildx build \
  --platform linux/amd64 \
  -t e2b-local/code-interpreter:latest \
  --load .

然后 SDK 里创建 code-interpreter,本地 gateway 会把它解析到对应的 Docker image。

这里我刻意没有调用 docker rundocker ps 这些命令,而是直接走 Docker Engine API。原因和后面 OrbStack 的选择是一脉相承的:gateway 是一个常驻服务,它更适合持有结构化 client,而不是不断 fork CLI 进程再解析 stdout/stderr。

Docker host 的解析顺序也尽量贴近开发环境:先尊重用户显式设置的 DOCKER_HOST;如果当前用户有 OrbStack 的 Docker socket,就使用 ~/.orbstack/run/docker.sock;否则回到常见的 unix:///var/run/docker.sock。后面的 image inspect、container create/start/stop/remove、logs、stats、volume 操作都走同一个 Docker SDK client。

template 映射也尽量不引入额外 registry。本机有 tag 的 image 就是候选 template,dangling image 会被忽略,带 e2b.local.snapshot=true 的 snapshot image 也不会混进列表。默认 template ID 来自 image reference 的最后一段:

text 复制代码
e2b-local/code-interpreter:latest  -> code-interpreter
python:3.11                        -> python
ghcr.io/acme/my-template:v1        -> my-template

如果 image 是通过 e2b-local 的 template build API 构建出来的,gateway 会写入 e2b.local.template_id、template names、build ID、start command、ready command 等 labels。之后再列 templates 时,这些 labels 会覆盖默认推导。这样简单 image 可以零配置使用,复杂 template 又能保留更明确的 metadata。

Docker 容器启动时还有一个关键点:e2b-local 不修改用户镜像,也不重新 build 一层 image。它会根据 image 架构选择仓库里的 envd 二进制:

text 复制代码
linux/amd64 -> envd-bin/envd-linux-amd64
linux/arm64 -> envd-bin/envd-linux-arm64

然后在创建容器时把它只读挂载进去:

text 复制代码
host:      envd-bin/envd-linux-amd64
container: /usr/local/bin/envd
readonly:  true

容器 entrypoint 被覆盖成 /usr/local/bin/envd。envd 默认在容器内监听 49983/tcp,宿主机端口由 Docker 自动分配,并且只绑定到 127.0.0.1。gateway 在容器启动后 inspect 端口映射,拿到类似 http://127.0.0.1:随机端口 的地址,把它作为 envdURL 返回给 SDK。

如果 template label 里记录了 start command,gateway 会把它作为 envd 的 -cmd 参数传进去;如果有 ready command,就等 envd health check 通过后再执行 ready command。失败时,刚创建的容器会被清理掉,错误里会尽量带上容器日志。这个细节很重要,因为本地开发最怕的是"创建失败了,但不知道里面发生了什么"。

OrbStack 是另一种形态的本地 sandbox

Docker 很适合轻量环境,但不是所有 template 都天然适合容器。有些东西更像一台完整 Linux 机器:需要 systemd,需要更接近真实主机的进程模型,需要在一个长期存在的 base machine 上慢慢调整系统状态。

这就是 OrbStack backend 出现的原因。

在 macOS 上,OrbStack 的 VM 启动很快,文件系统体验也好,同时它本身就提供 Docker 兼容能力和 Linux machine 能力。对 e2b-local 来说,已有的 OrbStack machine 可以很自然地被看成 template:创建 sandbox 时 clone 这台 template machine,启动 clone 出来的 sandbox VM,把 envd 放进去,安装 systemd service,然后等待 envd 起来。

最开始我确实先想过直接调用 orb 命令。这个方案能很快跑通原型,而且命令也很直观:

text 复制代码
orb clone <template-vm> <sandbox-vm>
orb start <sandbox-vm>
orb stop <sandbox-vm>
orb delete --force <sandbox-vm>
orb info --format json <vm>
orb list --format json
orb config set machine.<vm>.isolated true
orb config add machine.<vm>.mounts <host-path>:<vm-path>

但往下做一点,就会发现这不是我想长期留下来的接口。

CLI 当然适合人用,却不一定适合一个 gateway 当作内部控制协议。每次生命周期操作都 fork 一个 orb 进程,启动成本是一方面,更麻烦的是超时、取消、stderr 解析和错误分类。即使 orb info --format json 能给出结构化结果,错误路径也经常还是人类可读的文本。最后这些文本再被包装成 SDK 错误,对调用方其实不够友好。

还有一个更深的原因:OrbStack VM 初始化不只是 clone/start。e2b-local 还要把 envd 写进 VM 的 /usr/local/bin,写 systemd unit,写 sandbox metadata,创建 volume symlink,再执行 systemctl daemon-reload && systemctl restart envd。如果全靠 orb runorb push,实现会被绑在一层更大的 CLI 语义上,而不是清楚地分成"控制 VM 生命周期"和"进入 VM 做文件/systemd 操作"。

到这里,思路就变了:不要把 orb 当成唯一接口,而是看看它自己到底怎么和 OrbStack daemon 通信。CLI 最终也要和本机 daemon 说话,如果我们能直接接入背后的 Unix domain socket,就可以少绕一层。

我是怎么确认 OrbStack UDS 协议的

这一步不是拍脑袋猜协议,而是两边交叉验证:一边逆向 Go 客户端,一边抓真实 socket 流量。

OrbStack 的 orb/orbctl 在 macOS 上本质是 Go 程序。对 Go 二进制,go version -mstrings 很有用。它们能暴露 module path、依赖库,以及一些没被完全抹掉的方法名。分析下来可以看到一些非常关键的线索:

text 复制代码
github.com/creachadair/jrpc2
ContainerStart
ContainerStop
ContainerDelete
ContainerClone
ContainerSetConfig
ListContainers
MachineConfig
MachineMount
sconrpc.sock
sconssh.sock

这些名字基本把方向指清楚了:控制面大概率是 JSON-RPC,VM 生命周期对应的是一组 Container* RPC method;另有一条 sconssh.sock 用来进入 machine。

ListContainers 举个例子,会更容易理解这里所谓"直接和 OrbStack daemon 通信"到底是什么意思。它不是一个新的 HTTP 服务,也不是监听在 TCP 端口上的 API,而是跑在 Unix domain socket 上的一次 JSON-RPC 调用。用 curl 就能直接试:

bash 复制代码
curl --unix-socket "$HOME/.orbstack/run/sconrpc.sock" \
  -H 'Content-Type: application/json' \
  -X POST http://sconrpc \
  --data '{"jsonrpc":"2.0","id":1,"method":"ListContainers"}'

这里的 http://sconrpc 不是一个真实的网络地址。curl --unix-socket 仍然需要一个 URL 来组 HTTP request,host 部分只是占位,真正的连接走的是 $HOME/.orbstack/run/sconrpc.sock

返回结果大概是这样的,实际机器上的 id、IP 和磁盘大小会不同:

json 复制代码
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": [
    {
      "record": {
        "id": "01GQQVF6C60000000000DOCKER",
        "name": "docker",
        "image": {
          "distro": "docker",
          "version": "latest",
          "arch": "arm64",
          "variant": "default"
        },
        "config": {
          "isolated": false,
          "forward_ssh_agent": true,
          "isolate_network": false,
          "default_username": "root",
          "http_port": 0,
          "https_port": 0
        },
        "builtin": true,
        "state": "running"
      },
      "disk_size": 2634473472,
      "ip4": "192.168.139.2",
      "ip6": "fd07:b51a:cc66::2"
    },
    {
      "record": {
        "id": "01KTK0Z32XA8Y4R8MVY2F4TZKN",
        "name": "ubuntu-2404",
        "image": {
          "distro": "ubuntu",
          "version": "noble",
          "arch": "arm64",
          "variant": "cloud"
        },
        "config": {
          "isolated": false,
          "forward_ssh_agent": true,
          "isolate_network": false,
          "default_username": "arthur",
          "http_port": 0,
          "https_port": 0
        },
        "builtin": false,
        "state": "running"
      },
      "disk_size": 1155293184,
      "ip4": "192.168.139.198",
      "ip6": "fd07:b51a:cc66:0:18cb:1bff:fe4a:2ea0"
    }
  ]
}

这个响应一眼就能看出它比 orb list 的人类输出更适合程序使用。record.name 是 machine 名称,record.image 能告诉我们它来自哪个发行版和架构,record.state 能判断 running/stopped,record.config.isolated 正好对应后面要设置的隔离配置。builtin: truedocker machine 也能被识别出来,避免把 OrbStack 内置环境误当成用户 template。

e2b-local 来说,这个 method 基本就覆盖了 "list/info" 这类入口能力:列出当前机器,过滤出可用 template,判断 sandbox VM 是否已经存在,读取它当前的状态和网络地址。后面的 clone/start/stop/delete/config 也只是沿着同一条 JSON-RPC 通道继续调用不同 method。也正是从这个例子开始,orb list --format json 就没有必要再留在 gateway 里了。

光看字符串还不够,因为你仍然不知道真实 payload 长什么样、socket 路径有没有版本差异、CLI 到底在什么场景调用哪些 method。所以我又用 socat 做了一次 Unix socket 中间人。方法是把真正的 socket 文件临时改名,然后在原路径上放一个新的监听 socket;这个监听 socket 一边转发到真实 socket,一边把双向流量 dump 出来。

先找 socket。现在常见路径在 ~/.orbstack/run 下:

bash 复制代码
ls -la ~/.orbstack/run
ls -la ~/.orbstack/run/vmcontrol.sock
ls -la ~/.orbstack/run/sconrpc.sock

如果版本不同,也可以扩大搜索:

bash 复制代码
find ~/.orbstack /Applications/OrbStack.app \
  \( -name "vmcontrol.sock" -o -name "sconrpc.sock" \) 2>/dev/null

安装 socat

bash 复制代码
brew install socat

然后以 vmcontrol.sock 为例做中间人:

bash 复制代码
SOCK="$HOME/.orbstack/run/vmcontrol.sock"

mv "$SOCK" "$SOCK.real"

socat -v \
  "UNIX-LISTEN:$SOCK,fork" \
  "UNIX-CONNECT:$SOCK.real" \
  2>&1 | tee /tmp/vmcontrol-dump.log

接着运行几次 orb listorb info,或者在 OrbStack UI 里操作 machine。对于 JSON-RPC 这类明文协议,dump 里通常能直接看到 method、params 和 id。抓完以后记得恢复:

bash 复制代码
rm -f "$SOCK"
mv "$SOCK.real" "$SOCK"

有些旧版本或特殊安装路径可能会把 socket 放在 app bundle 里,这时同样的办法也能用,只是需要 sudo

bash 复制代码
sudo mv /Applications/OrbStack.app/Contents/MacOS/vmcontrol.sock \
        /Applications/OrbStack.app/Contents/MacOS/vmcontrol.sock.real

sudo socat -v \
  UNIX-LISTEN:/Applications/OrbStack.app/Contents/MacOS/vmcontrol.sock,fork \
  UNIX-CONNECT:/Applications/OrbStack.app/Contents/MacOS/vmcontrol.sock.real \
  2>&1 | tee /tmp/vmcontrol-dump.log

这个过程的目的不是"占有"OrbStack 的所有内部协议,而是确认当前项目真正需要的边界:list/info/clone/start/stop/delete/config 走 sconrpc.sock 上的 JSON-RPC;进入 VM 写文件、装 systemd service 走 sconssh.sock 上的 SSH。至于 orb run --machine <vm> /bin/sh -lc <script>,最后发现并不是必需能力,因为 VM 内操作可以直接通过 SSH 完成。

于是代码里落下来的形态很薄:internal/orbctl 负责通过 Unix socket 发 JSON-RPC HTTP request,封装 ListContainersContainerCloneContainerStartContainerStopContainerDeleteContainerSetConfig;OrbStack backend 需要写 root 文件或执行 systemd 命令时,用 Go SSH client 连 ~/.orbstack/run/sconssh.sock,不再 fork 系统 ssh,也不再 fork orb

这样以后,Docker 和 OrbStack 两个 backend 在风格上终于统一了:Docker 不 shell out 到 docker,OrbStack 也不 shell out 到 orb。gateway 直接和本地 daemon/socket 通信,拿结构化结果,自己控制超时和错误。

volume metadata 这个小问题

OrbStack volume 的实现里还有一个很小但挺典型的取舍:本地目录应该怎么命名,metadata 又应该放在哪里。

Docker backend 可以直接用 Docker native named volume。但 OrbStack VM 的 volume 更适合映射成本机目录,默认放在:

text 复制代码
~/.e2b-local/volumes

创建 sandbox 时,backend 通过 OrbStack selective mount 把对应目录挂进 VM,再在 VM 里 symlink 到 SDK 请求的路径。如果开启 orbstack.isolated: true,sandbox VM 不会看到完整 macOS 文件系统,只能看到显式挂载的 volume。

一开始最简单的目录名是 volume ID,比如:

text 复制代码
~/.e2b-local/volumes/vol_01HX...

这样查找很方便,但人打开目录时完全不知道每个 volume 是干什么的。换成用户给的 volume name 又舒服很多:

text 复制代码
~/.e2b-local/volumes/data
~/.e2b-local/volumes/cache

问题是 name 不是稳定主键,可能重名,也可能以后想改显示名。E2B API 里真正稳定的是 volume ID。

最后的设计是把这两个概念拆开:目录名尽量保持可读,比如 datacachedata-2;稳定身份写进目录自己的 extended attribute。现在使用的 xattr key 是:

text 复制代码
com.e2b.local.volume-meta

内容是一个很小的 JSON:

json 复制代码
{"VolumeID":"vol-123","Name":"data"}

我选择 xattr,而不是在 volume 目录里放 .e2b-meta.json,主要是因为这个目录会被挂进 VM,里面应该尽量只出现用户数据。额外的元数据文件很容易被用户看到、误改或者删除;xattr 更像这个目录在宿主机侧的外部属性,不会混进 sandbox 的业务文件列表。

迁移上也留了口子。早期版本可能已经有旧 xattr key com.e2b.volume-meta,或者旧的 .e2b-meta.json。读取 metadata 时,代码会按新 xattr、旧 xattr、旧文件的顺序尝试;如果读到旧格式,就写回新的 com.e2b.local.volume-meta,并清理旧 key 或旧文件。旧 payload 里的历史 Token 字段也会被丢掉,当前 metadata 只保留 VolumeIDName

这不是 OrbStack 本身要求的格式,而是 e2b-local 在 macOS + OrbStack 场景下管理本地 volume 的方式:API 身份稳定,文件夹名字对人可读,控制面 metadata 不混进 sandbox 数据面。

envd 不能依赖我的机器

还有一个发布前必须处理的问题是 envd 二进制的位置。

早期调试时,很容易把 envd 路径写成自己机器上的绝对路径。这种写法能让原型先跑起来,但项目一旦给别人用就会立刻坏掉,因为那条路径只存在于我的电脑上。

现在仓库里直接带了 Linux 版 envd:

text 复制代码
envd-bin/envd-linux-amd64
envd-bin/envd-linux-arm64

这些 envd 不是 e2b-local 重新实现的协议服务,而是从 E2B 源码构建出来的二进制。这样 SDK 数据面的 commands、filesystem、PTY、streaming 行为可以尽量贴近真实 E2B sandbox。

Docker backend 会把对应架构的 envd bind-mount 到容器内 /usr/local/bin/envd;OrbStack backend 会把 envd 复制进 VM,再安装成 systemd service。配置里仍然可以写相对路径,但解析会按配置文件所在目录来做,避免把某台开发机上的私有路径带进项目。

SDK 调用方最好感觉不到这些选择

做完这些底层工作以后,SDK 调用方看到的东西应该尽量简单。

TypeScript 里仍然可以这样写:

ts 复制代码
import { Sandbox } from 'e2b'

const sandbox = await Sandbox.create('code-interpreter')
const result = await sandbox.commands.run('echo "hello from e2b-local"')
console.log(result.stdout)
await sandbox.kill()

Go 里也可以通过 superduck-ai/e2b-go-sdk 调:

go 复制代码
sandbox, err := e2b.Create(ctx, "code-interpreter", nil)
if err != nil {
	panic(err)
}
defer sandbox.Kill(ctx, nil)

result, err := sandbox.Commands.Run(ctx, `echo "hello from e2b-local"`, nil)
if err != nil {
	panic(err)
}
fmt.Println(result.(*e2b.CommandResult).Stdout)

调用方真正需要知道的差异很少:Docker runtime 的 template 来自本机 Docker image;OrbStack runtime 的 template 来自已有 OrbStack machine;Docker volume 是 native named volume;OrbStack volume 是 orbstack.volume_host_path 下的本地目录。除此之外,SDK 仍然按 E2B 的方式创建 sandbox、执行命令、读写文件和销毁环境。

现在它适合放在哪个位置

它适合本地开发和 template 调试,尤其适合这些场景:快速验证 Docker image 能不能作为 E2B template 工作;调启动命令、环境变量、ready check 和系统依赖;让 sandbox 访问本机开发服务;在 macOS 上用 OrbStack VM 模拟更完整的 Linux 主机环境。

它不打算替代云端 E2B 的多租户生产隔离,也不想把本地机器包装成一个生产 sandbox 平台。它更像开发阶段的一层适配器:把反馈链路缩短,把问题提前暴露,把 SDK 调用方式保持住。

回头看,最关键的几个决定其实都来自同一个出发点:gateway 应该直接接本地 runtime 的结构化接口,而不是把 CLI 当成长期依赖。Docker 走 Engine API,OrbStack 走 UDS 上的 JSON-RPC 和 SSH,envd 继续负责数据面。这样实现更轻,错误更可控,也更接近一个本地常驻服务应该有的形态。

这也是为什么我最后没有继续沿用 orb 命令,而是花时间去分析 OrbStack 的 socket 通信。不是为了炫技,也不是为了追求"更底层",只是因为对 e2b-local 这种 gateway 来说,少 fork 一层进程、少解析一层人类输出,很多后续问题都会简单不少。

我还没实现的部分

现在这套实现优先覆盖的是本地开发最常用的链路:创建 sandbox、启动 envd、跑 commands/filesystem/PTY、管理 template 和 volume,以及用 Docker 或 OrbStack 快速验证环境。换句话说,我先把"能不能把一个 template 在本地快速跑起来"这件事做顺,而不是一开始就把 E2B 云端的所有 API 语义都补齐。

还没完整实现的部分,最典型的是 snapshot 这一类能力。云端 E2B 里的 snapshot 不只是"把当前环境存一下"这么简单,它还会牵涉到 template 生命周期、命名空间、权限、长期存储、从 snapshot 再创建 sandbox、以及和构建系统之间的关系。e2b-local 目前更关注本地调试闭环,所以即使有些 runtime 可以做非常薄的一层本地快照映射,我也不想把它包装成已经完整兼容云端 snapshot 语义。

类似的还有一些偏平台侧的能力:更完整的 metrics/logs 语义、network policy、access token / API key 管理、团队和权限模型、配额、审计、跨机器调度、长期资源治理等。这些能力在云端平台里很重要,但对本地 template 开发来说不是第一优先级。e2b-local 现在更适合作为开发工具,而不是生产控制面。

小结

e2b-local 做的事情并不复杂:让 E2B SDK 的使用方式尽量不变,把 template 开发时最慢的那段反馈链路搬回本机。Docker 负责轻量、直接的 image 调试;OrbStack VM 负责更接近完整 Linux 主机的场景;而绕开 docker / orb 这类 CLI,直接接本地 runtime 的结构化接口,是让这个 gateway 更轻、更稳定的关键。

欢迎 star:github.com/superduck-a...

相关推荐
用户51914958484524 分钟前
Windows 渗透测试载荷加载器 POC 工具集
人工智能·aigc
袋鱼不重27 分钟前
我的神奇同事,AI 用多了居然写了个 Open In Codex
前端·后端·ai编程
大树8828 分钟前
金刚石散热越强,管路越先见顶
大数据·运维·服务器·人工智能·ai
用户83562907805130 分钟前
使用 Python 操作 Word 内容控件
后端·python
像我这样帅的人丶你还31 分钟前
啥? 前端也要会干Java?🛵🛵🛵
后端
Hommy8833 分钟前
【剪映小助手】添加贴纸接口(Add Sticker)
后端·github·剪映小助手·视频剪辑自动化·剪映api
通信小呆呆38 分钟前
当算法有了“五感”:多模态数据融合如何向人体感官协同学习?
人工智能·学习·算法·机器学习·机器人
施小赞41 分钟前
普通 RAG vs GraphRAG 核心对比
人工智能·ai
EAIReport42 分钟前
RuoYi-AI 企业级AI开发平台实战详解
人工智能
HelloWorld__来都来了1 小时前
【每日学术速报】2026-06-15
人工智能·具身智能