基于 Docker 的代码执行沙箱。Go 服务通过 Docker Engine API 管理容器。
数据按两级隔离:
scope:数据归属,比如用户 id。每个 scope 有一个长期空间,跨对话共享session:scope 下的一次对话,比如 chat_id,可选。每个 session 有自己私有的工作目录和容器
宿主机 容器 (scope=u1, session=chatA)
<root>/u1/workspace/ ──bind──> /opt/user 用户长期空间
<root>/u1/sessions/chatA/ ──bind──> /opt/workspace 当前对话目录(cwd)
- chatA 的容器里根本没有挂载 chatB 的目录,对话之间真正隔离;
/opt/user所有对话共享 - 不传 session 时,
/opt/workspace和/opt/user都是 scope 的长期空间 - 第一次调用时创建容器,之后的调用通过
exec复用;空闲超过SANDBOX_IDLE_TTL(默认 10 分钟)自动销毁容器,数据保留 - 运行时镜像包含 Python 3.13、Node.js 22、ffmpeg 和常用的 Python 包
建议在给模型的工具说明里写明:当前对话的工作目录是 /opt/workspace,生成的文件都放在这里;/opt/user 是用户的长期空间,只有用户明确要求时才使用。scope 和 session 由业务方根据当前会话填写,不要让模型传。
Linux 服务器上一条命令完成安装:没有 Docker 时自动安装,生成 API key,拉取 GHCR 上发布的镜像并启动。
curl -fsSL https://raw.githubusercontent.com/runify-dev/agent-sandbox/main/install.sh | sudo bash- 安装到
/opt/agent-sandbox,数据在/opt/agent-sandbox/data,配置和 API key 在/opt/agent-sandbox/.env - 服务由 Docker Compose 管理(
restart: unless-stopped),Docker 本身由 systemd 管理并开机自启,不需要额外的 systemd 服务 - 默认只监听
127.0.0.1:8090。需要给其他机器上的 MaxKB 调用时,在.env里设置SANDBOX_BIND=0.0.0.0并配好防火墙 - 指定版本:
curl -fsSL .../install.sh | sudo VERSION=0.1.0 bash - 升级:重新执行安装命令,会保留已有的
.env - 国内服务器:Docker 没装时可以用
curl -fsSL .../install.sh | sudo MIRROR=aliyun bash从阿里云源安装 Docker。脚本只需要下载install.sh这一个文件;如果raw.githubusercontent.com不稳定,也可以git clone仓库后直接执行sudo ./install.sh。GHCR 拉取镜像慢时,可以把镜像同步到自己的仓库并设置AGENT_SANDBOX_IMAGE_PREFIX - 离线安装:在能联网的机器上
docker save两个镜像,拷到服务器上docker load,然后用AGENT_SANDBOX_SKIP_PULL=1执行install.sh
cd agent-sandbox
docker build -t agent-sandbox-runtime:latest ./runtime
export SANDBOX_API_KEY=$(openssl rand -hex 24)
docker compose up -d --build sandbox-servermacOS 的 Docker Desktop 默认不共享 /opt,可以在 .env 里设置 SANDBOX_DATA_ROOT=$HOME/agent-sandbox-data。
启动后运行冒烟测试(会读取 .env 里的 key):
./smoke_test.sh .不用 Docker 直接运行(需要 Go 1.26+,并有 docker.sock 访问权限):
cd agent-sandbox
SANDBOX_API_KEY=xxxxxxxxxxxxxxxx go run ..github/workflows/ci.yml:push 到 main 或提 PR 时运行 gofmt / vet / 单元测试,然后构建两个镜像、启动服务并跑完整的smoke_test.sh.github/workflows/release.yml:推送v*标签(如git tag v0.1.0 && git push origin v0.1.0)时构建 amd64 + arm64 镜像,发布到ghcr.io/runify-dev/agent-sandbox-server和ghcr.io/runify-dev/agent-sandbox-runtime。runtime 镜像的 arm64 部分通过 QEMU 构建,耗时较长
所有 /v1/* 接口都需要携带 X-API-Key: <key> 或 Authorization: Bearer <key>。
{
"scope": "u1",
"session": "chatA",
"language": "python",
"code": "import os; print(os.listdir('.'))",
"timeout": 30,
"env": {"FOO": "bar"}
}scope:必填;session:可选language:python|node|bash(ffmpeg 通过 bash 调用)timeout:秒,不传使用默认值,最大不超过SANDBOX_MAX_TIMEOUT- 代码通过 stdin 传给解释器,工作目录是
/opt/workspace
返回:
{"stdout": "...", "stderr": "", "exit_code": 0, "timed_out": false, "truncated": false, "duration_ms": 42}| 状态码 | 含义 |
|---|---|
| 400 | 参数错误(scope / session 只允许 [A-Za-z0-9_-],最长 64) |
| 401 | key 错误 |
| 429 | 活跃容器数达到 SANDBOX_MAX_SESSIONS |
| 方法 | 路径 | 说明 |
|---|---|---|
DELETE |
/v1/scopes/{scope}/sessions/{session} |
销毁这个对话的容器,数据保留 |
DELETE |
/v1/scopes/{scope}/sessions/{session}?purge=true |
同时删除这个对话的目录,对话被删除时调用 |
DELETE |
/v1/scopes/{scope} |
销毁这个 scope 下所有对话的容器,数据保留 |
DELETE |
/v1/scopes/{scope}?purge=true |
同时删除 scope 的全部数据,用户被删除时调用 |
有正在执行的任务时返回 409。
{path} 是相对路径。带 ?session=<session> 时操作这个对话的目录(容器里的 /opt/workspace),不带时操作 scope 的长期空间(容器里的 /opt/user)。所有操作都通过 os.Root 限制在对应目录内,../ 和指向目录外的符号链接都会被拒绝。
| 方法 | 路径 | 说明 |
|---|---|---|
PUT |
/v1/files/{scope}/{path} |
上传。请求体就是文件的原始字节,流式写入,自动创建上级目录,已存在则覆盖。上限 SANDBOX_MAX_UPLOAD_MB |
GET |
/v1/files/{scope}/{path} |
下载。文件返回内容(支持 Range,可以断点续传、拖动视频进度);目录返回 JSON 文件列表 |
GET |
/v1/files/{scope}/ |
列出根目录 |
DELETE |
/v1/files/{scope}/{path} |
删除文件或整个目录 |
目录列表返回:
{"path": "videos", "entries": [{"name": "a.mp4", "is_dir": false, "size": 1048576, "mod_time": "2026-10-16T10:00:00Z"}]}健康检查,不需要 key。
上传视频 → 用 ffmpeg 处理 → 下载结果:
curl -s -X PUT "localhost:8090/v1/files/u1/input.mp4?session=chatA" -H "X-API-Key: $SANDBOX_API_KEY" --data-binary @input.mp4
curl -s localhost:8090/v1/exec -H "X-API-Key: $SANDBOX_API_KEY" -d '{
"scope": "u1",
"session": "chatA",
"language": "bash",
"code": "ffmpeg -y -i input.mp4 -vf scale=640:-2 output.mp4 2>&1 | tail -n 5"
}'
curl -s "localhost:8090/v1/files/u1/output.mp4?session=chatA" -H "X-API-Key: $SANDBOX_API_KEY" -o output.mp4在 MaxKB 后端(Python)中调用:
from urllib.parse import quote
import requests
def run_in_sandbox(scope: str, session: str | None, language: str, code: str, timeout: int = 60) -> dict:
resp = requests.post(
"http://127.0.0.1:8090/v1/exec",
headers={"X-API-Key": SANDBOX_API_KEY},
json={"scope": scope, "session": session or "", "language": language, "code": code, "timeout": timeout},
timeout=timeout + 30,
)
resp.raise_for_status()
return resp.json()
def upload_to_sandbox(scope: str, session: str | None, path: str, fileobj) -> dict:
resp = requests.put(
f"http://127.0.0.1:8090/v1/files/{scope}/{quote(path)}",
params={"session": session} if session else None,
headers={"X-API-Key": SANDBOX_API_KEY},
data=fileobj, # 文件对象会被流式发送,不会整个读进内存
)
resp.raise_for_status()
return resp.json()
def download_from_sandbox(scope: str, session: str | None, path: str, dest: str) -> None:
with requests.get(
f"http://127.0.0.1:8090/v1/files/{scope}/{quote(path)}",
params={"session": session} if session else None,
headers={"X-API-Key": SANDBOX_API_KEY},
stream=True,
) as resp:
resp.raise_for_status()
with open(dest, "wb") as f:
for chunk in resp.iter_content(chunk_size=1 << 20):
f.write(chunk)| 环境变量 | 默认值 | 说明 |
|---|---|---|
SANDBOX_API_KEY |
必填 | 至少 16 位 |
SANDBOX_LISTEN |
:8090 |
监听地址 |
SANDBOX_IMAGE |
agent-sandbox-runtime:latest |
运行时镜像 |
SANDBOX_DATA_ROOT |
/opt |
数据根目录:<root>/<scope>/workspace 和 <root>/<scope>/sessions/<session> |
SANDBOX_MOUNT_TARGET |
/opt/workspace |
当前对话目录在容器内的挂载点 |
SANDBOX_USER_MOUNT_TARGET |
/opt/user |
scope 长期空间在容器内的挂载点 |
SANDBOX_RUNTIME |
空(runc) | 设为 runsc 使用 gVisor |
SANDBOX_NETWORK |
none(compose 中为 proxy) |
见下方「网络与白名单」 |
SANDBOX_MEMORY_MB |
1024 |
每个容器的内存上限 |
SANDBOX_CPUS |
1.0 |
每个容器的 CPU 上限 |
SANDBOX_PIDS_LIMIT |
256 |
每个容器的进程数上限 |
SANDBOX_TMP_MB |
512 |
/tmp 的 tmpfs 大小(计入内存) |
SANDBOX_UID / SANDBOX_GID |
1000 |
容器内运行用户,工作区会被 chown 给它 |
SANDBOX_IDLE_TTL |
10m |
空闲回收时间 |
SANDBOX_MAX_SESSIONS |
100 |
同时存在的容器上限(每个 scope + session 一个) |
SANDBOX_DEFAULT_TIMEOUT |
60 |
默认执行超时(秒) |
SANDBOX_MAX_TIMEOUT |
600 |
最大执行超时(秒),视频处理可以适当调大 |
SANDBOX_MAX_OUTPUT_KB |
1024 |
stdout / stderr 各自最多返回多少 KB |
SANDBOX_MAX_UPLOAD_MB |
2048 |
单个上传文件的大小上限 |
SANDBOX_EGRESS_ALLOWLIST |
pypi.org,files.pythonhosted.org,registry.npmjs.org |
proxy 模式下允许访问的域名,逗号分隔 |
SANDBOX_EGRESS_PORTS |
80,443 |
proxy 模式下允许访问的端口 |
SANDBOX_EGRESS_PRIVATE_CIDRS |
空 | 额外允许访问的内网地址段,如内部镜像源 10.1.2.3/32 |
SANDBOX_PIP_INDEX_URL |
空 | 注入容器的 PIP_INDEX_URL |
SANDBOX_NPM_REGISTRY |
空 | 注入容器的 npm registry |
SANDBOX_PROXY_NETWORK |
agent-sandbox-egress |
沙箱接入的 internal 网络(compose 自动创建) |
SANDBOX_NETWORK |
效果 |
|---|---|
none |
完全断网 |
proxy |
沙箱只接入一个 internal 网络(没有出网路由),唯一出口是服务内置的白名单代理 |
| 其他值 | 作为 Docker 网络名直接使用,比如 bridge 不做任何限制,不建议 |
proxy 模式下:
- 容器里已经设置好
HTTP_PROXY/HTTPS_PROXY,pip、npm、curl、requests等会自动走代理 - 只放行
SANDBOX_EGRESS_ALLOWLIST里的域名和SANDBOX_EGRESS_PORTS里的端口,其他请求返回 403:sandbox egress denied: host xxx is not in allowlist - 白名单写法:
pypi.org精确匹配;*.example.com匹配所有子域名(不含example.com本身);单独一个*放行所有域名 - 即使配置了
*,内网地址、云厂商元数据地址和SANDBOX_EGRESS_PORTS以外的端口依然被拒绝,*的含义是「整个公网」 - 即使白名单域名解析到内网地址,也会被拒绝(防 DNS rebinding),云厂商元数据地址
169.254.169.254同样被拒绝。确实要访问内部镜像源时,把它的地址段加到SANDBOX_EGRESS_PRIVATE_CIDRS - 沙箱不能调用沙箱服务自己的管理接口
- 需要
docker compose启动服务(服务要同时接入 internal 网络和外网)
修改白名单后重启服务生效:docker compose up -d。
使用国内镜像源的例子(.env):
SANDBOX_EGRESS_ALLOWLIST=mirrors.aliyun.com,registry.npmmirror.com
SANDBOX_PIP_INDEX_URL=https://mirrors.aliyun.com/pypi/simple/
SANDBOX_NPM_REGISTRY=https://registry.npmmirror.com白名单里的域名仍然 403 时,看服务日志里的 egress denied:
docker compose logs sandbox-server | grep "egress denied"如果原因是 destination 198.18.x.x is a private or reserved address,说明宿主机开着 Clash / Surge 等 fake-ip 模式的代理,域名被解析到了 198.18.0.0/15。本地开发时可以在 .env 里加 SANDBOX_EGRESS_PRIVATE_CIDRS=198.18.0.0/15 放行,服务器上不要这样配置。
pip install 会自动装到用户长期空间的 /opt/user/.local(根文件系统只读),这个 scope 下所有对话都能用。镜像里已预装常用的包,见 runtime/requirements.txt。
- 容器配置:只读根文件系统、
cap-drop ALL、no-new-privileges、非 root 运行、断网或只能通过白名单代理出网、限制内存 / CPU / 进程数 - 执行命令外层包了
timeout -s KILL,超时会杀掉整个进程组;如果仍然卡住,服务会直接销毁并重建容器 - 服务本身挂载了 docker.sock,相当于拥有宿主机 root 权限:只监听内网,key 不要泄露
- proxy 模式下所有沙箱容器在同一个 internal 网络里,彼此之间网络可达(但都只是普通用户进程,没有对外服务)
- 执行不可信代码时强烈建议使用 gVisor(
SANDBOX_RUNTIME=runsc) - 磁盘配额没有限制:bind mount 不受 Docker 存储限制,需要的话在宿主机上用 XFS project quota 或者给
/opt单独分区 - 建议把
SANDBOX_DATA_ROOT设为专用目录(比如/opt/sandbox),不要和/opt下其他软件混在一起 - macOS 上的 Docker Desktop 需要在 File Sharing 里共享
SANDBOX_DATA_ROOT