Sandbox 接口,所以 Adapter 不需要知道当前用的是本地 Docker、Vercel micro-VM、E2B 还是第三方云服务。
Sandbox 接口
Adapter 常用操作包括:
两个
OrThrow 方法抛出的错误消息会附带经过清理、脱敏和截断的 stderr 尾部。stderr 为空时回退 stdout。排障时可以先看这条摘要,需要完整输出时再读取 SandboxCommandExitError.result。
选择 Sandbox
在实验里设置sandbox 字段
漏装时不会静默失败:NiceEval 在创建 sandbox 的那一刻报错并直接给出上面的安装命令,例如
Docker sandbox requires 'dockerode'. Install it with: pnpm add dockerode @types/dockerode。
Docker Compose:workspaceService 指定主 Sandbox
一道题需要不止一个容器时(比如应用容器加一个数据库),用 dockerComposeSandbox 声明整份 Compose 环境:
workspaceService 指定 Compose 文件里哪个 service 是主 Sandbox。Agent、t.sandbox 的命令与文件操作、workdir、变更 diff,全部只落在这一个容器上。Compose 文件里的其它 service(数据库、mock server 之类)是伴随资源,不经过 Sandbox 接口——不能对它们 runCommand、上传或读写文件,也看不到它们的 diff。要用到这些伴随 service,只能靠题目自己的网络访问,例如从 client 容器里用 Compose 内置 DNS 访问 db:5432。
让 Agent 自己运行 Docker
如果 Agent 需要执行docker build、docker run 或 docker compose,可以显式挂已有 Unix socket,
也可以选择 raw privileged 或 managed rootless Docker-in-Docker。三种模式的适用场景、Dockerfile 和
完整配置见让 Sandbox 使用 Docker。
按顺序准备 Sandbox
每个 Provider factory 都返回不可变的SandboxLayer。用 .before() 声明 Agent 开始前的动作,用 .after() 声明无条件收尾:
changeFrequency 是普通非负数。数值小的稳定动作排在前面,数值大的易变动作排在后面。内置常量只是便于阅读的数字:
dependsOn 先决定依赖,再按数值排序。因此,评估组里频率较低的动作可以排到 Experiment 的易变动作之前。
所有声明式 Sandbox step 在运行时都有统一的安全 activity。shell() 显示你填写的 command,不显示
/bin/sh -lc 包装;command() 和 exec 显示 executable 与 argv。writeText() / writeBytes() 只显示
目标路径、bytes 与 digest,不显示正文。upload / transfer 只显示 digest、目标路径与 bytes,不显示宿主 source path。
checkout locator 会去掉 userinfo、query 和 fragment;env 只列 keys。
很短的 progress 可能合并。需要稳定观察长脚本的细粒度进度时,把脚本拆成较小 action,并让每一段本身运行得
足够久或显式报告 progress;NiceEval 不追踪 shell 内部步骤。动态 callback 仍由 context.progress() 自己说明进度。
声明准备缓存覆盖范围
每个.before(action) 都要如实声明它改变的完整状态范围。省略 cache.state 等于
sandboxState.all:该 action 的全部副作用都必须能恢复。它不是让 Provider 任选一部分目录缓存的开关。
只有 action 的全部副作用都局限于已经静止的 inner /var/lib/docker 时,才声明
sandboxState.dockerData。完整的 Docker Profile Action 示例见
让 Sandbox 使用 Docker。
把 cache.state 写在 action family 的 definition 或单次 instance 的任一处即可,不能两处重复,
也不能由 instance 覆盖 definition。family ID、输入、展开后的 steps、声明的 state 和内容摘要会自动进入
fingerprint。cache.fingerprint 只能补充 NiceEval 看不到的版本值,不能替代自动 fingerprint。
Provider 只会发布自己能完整保存的覆盖范围:
第一个默认
all、opaque callback 或 Provider 不支持的 state 就是准备前缀的 barrier。该 action
以及它后面的所有 action 都会真实执行;后缀即使声明 dockerData,也不能跳过缺少祖先状态的步骤。
普通本地单容器 Docker 可以从最长匹配前缀启动。只有最后一个 .env action 变了时,固定的 Git checkout、
目录上传和工具安装不会重跑。每个 Attempt 都从缓存结果的私有副本启动,不会继承上一个 Agent 留下的改动。
定义可复用 Action
内置的shell()、writeText()、uploadDirectory() 和 gitCheckout() 都是 SandboxAction。项目也可以用相同机制定义自己的 family:
Sandbox。上面的覆盖范围和 fingerprint
规则同样适用于自定义 family。
写入、上传与 Git checkout
这些内置函数都是同一种SandboxAction,可以直接传给 .before():
uploadFile() 和 uploadDirectory() 接收 new URL("fixture/", import.meta.url),不接收随当前目录漂移的相对路径。实际 bytes、权限与目录 manifest 自动进入指纹。gitCheckout() 只接受不含凭据的 HTTPS URL 和完整 commit object ID。
需要在 Agent 返回后才公开的隐藏内容不要放进 .before()。先用 sandboxContent.file() 或 sandboxContent.directory() 取得不可变 handle,再在 test(t) 中调用 t.sandbox.upload(handle, targetPath)。handle 只暴露内容摘要,不暴露宿主路径。
动态回调与收尾
只有运行时才能决定的分支可以直接写.before(async (sandbox, context) => ...)。回调总会真实执行,并截断后续可共享的准备前缀。
资源成功取得后,立即用 context.onCleanup() 登记释放。闭包可以安全保存这次调用的句柄,不需要写两份彼此猜测状态的回调:
sandbox.sandboxId 是当前主物理 Sandbox 的 Provider-native ID。通常把它当作不透明关联值;只有这条 layer 已明确绑定具体 Provider 时,可信宿主代码才应把它交给对应 SDK 取得 Attempt 级辅助资源。辅助资源不属于 NiceEval 管理的 Case 拓扑,也不进入缓存;创建成功后必须立即登记幂等 cleanup。准备前缀可能在首个 callback 前替换主实例,但 callback 开始后,本 Attempt 的 cleanup 与 after 会看到同一 ID。
无条件、幂等的收尾可以写成 .after()。所有已登记的收尾按实际登记顺序的反向执行,前面的动作失败时也会继续完成已经登记的项目:
context.progress() 只更新当前 Attempt 的短期状态。context.diagnostic() 保存需要回顾的问题。环境无法继续时直接抛错;NiceEval 会把它记录为环境错误,而不是 Agent 做错题。
不带 Sandbox 的 defineAgent 没有这条准备流程。Sandbox Agent 可以在自己的 sandbox 字段贡献 command-only layer,但不能选择或替换 Provider template。
预制环境与运行时 checkpoint
NiceEval 用 typed spec 统一引用预制环境,但不提供一个假的通用构建命令:从官方基线继续构建以提速
稳定、体积大、每个 Attempt 都相同的内容——系统包、Agent CLI、编译好的二进制、大模型缓存——应在跑评估用例之前烘焙进 provider 的可发布构建输出,让每个 Attempt 从预制环境启动、跳过运行时安装。三个内置 provider 都能从官方基线继续派生,不必从空白环境装 Agent。但它们的构建工具、凭据和发布语义不同,NiceEval 只统一消费产物 ID(image / snapshotId / template),不伪造跨 provider 的构建 DSL。
Adapter 与 Sandbox 不互相猜配置:Adapter 负责检查所需 CLI,Sandbox spec 负责选择 provider 和构建输出。Claude Code 与 Codex 缺少 CLI 时会回退到运行时安装(构建输出在题面网络之外准备,经文件 API 送入),所以烘焙纯粹是提速。Codex 送进去的是该平台自带运行时的原生包,任务镜像不带 Node 也能装,Claude Code 仍要求镜像里有 npm。
默认 Bub 0.4.0 配方除 OTel 插件外,还固定 any-llm-sdk==1.17.0 和 openai==2.31.0。安装指纹同时覆盖 Bub requirement、这两个 client override、OTel 插件和规范化后的 Python 插件集,不能仅凭 command -v bub 跳过安装。旧 marker 的预制环境会重装一次;显式选择其它 Bub 版本时,传递依赖闭包仍由调用方负责。三个 Provider 的构建都只在环境依赖变化时跑一次,产物换一个版本化名字,不要放进每个 Attempt 都会执行的动态回调。
E2B:从官方与公共模板派生
E2B 已提供 Claude Code 的claude template 和 Codex 的 codex template。NiceEval 提供一个 E2B 专属的薄封装,让你从这两个官方起点继续链原生 E2B API。E2B 暂无 Bub 官方 template,因此默认 Bub 分支与 bubAgent() 使用同一份完整 client 闭包和 OTel 插件:
NiceEval 同时发布了三份任何 E2B Team 都能引用的公共模板。完整 namespace 与版本由 NiceEval 自己维护,
下游直接取完整引用:
2.1.207、Codex 0.144.1、Bub 0.4.0(安装指纹随 Bub requirement、client override、OTel 插件和 Python 插件 pin 一起变)。模板的版本号跟着它装的那个 Agent 走(形如 0.144.1-r1:Agent 版本 + NiceEval 配方修订号),三个 Agent 各自独立发版,NiceEval 库自己的版本不参与命名。所以业务仓库直接用常量,不复制这些字符串、也不跟踪版本。派生模板需要记录 base 身份时,同样用常量的值。
当前这批公共模板中,Claude Code 模板沿用 E2B 官方 /usr npm prefix,普通用户不能直接写全局模块。需要在运行时追加 pnpm、yarn 等全局 Node 工具时,显式把它们装到两个模板都已加入 PATH 的 /usr/local:
sudo npm install -g。模板里已有的 root 全局包可能产生文件冲突。NiceEval 后续会在
新模板发布时统一这条默认 prefix。在发布说明明确新 release 已满足前,继续保留显式
--prefix /usr/local。
scripts/build-e2b-template.ts
e2bCodingAgentTemplate("claude-code" | "codex" | "bub") 返回原生 TemplateBuilder,不是 NiceEval 私有构建 DSL。你可以继续使用 .aptInstall()、.runCmd()、.copy() 等 E2B 能力。构建自己的 alias 会把官方起点和项目依赖冻结在同一个可复现构建输出里。依赖变更时重建并换一个版本化 alias。
若 Bub Adapter 配了 pythonPlugins,构建模板时把同一组 package 传给 factory,插件集合才会进入兼容性指纹并真正命中预装环境:
Docker:使用 NiceEval 维护的镜像,或从官方 node 基础镜像派生
要直接运行 NiceEval 内置的claude-code、codex 或 bub Adapter,可以使用对应的公开镜像:
niceeval/claude-code、
niceeval/codex 或
niceeval/bub。每个镜像只包含自己的 Agent CLI,并为
linux/amd64 和 linux/arm64 发布 manifest。tag 与对应的 E2B 公共模板同号——版本位是镜像里那个
Agent 的版本。稳定 CI 用具名常量或 digest,不要依赖会移动的 latest:
library/* Official Image。它在 Agent 版本或
构建配方变化时发布新 tag,跟 NiceEval 库的发版节奏无关。
如果只需要一个 Agent,或还要加入项目专有依赖,写 Dockerfile 从 Docker 的官方基线
node:24-slim 派生:
Dockerfile
USER,容器就默认以 root 跑命令,/usr/local/bin 已经在 root 的 PATH 上,npm install -g 装的全局二进制天然可见。需要非 root 身份时(比如 Claude Code 在 root 下会拒绝 --dangerously-skip-permissions),在 Dockerfile 里加一行 USER node(node:24-slim 镜像自带这个用户)。也可以用 dockerSandbox({ source: { type: "image", image }, user: "node" }) 显式覆盖。
要装到别处的 Agent(如落在 ~/.local/bin)记得让它进 PATH。dockerSandbox 要求显式 image。稳定 CI 引用不可变 tag。
Vercel:从官方 runtime 拍快照
Vercel 没有 E2B 式的 template registry,也没有 Dockerfile。Sandbox 快照是从一台跑起来的 microVM 拍出来的。用 Vercel SDK 从官方 runtime(node24)起一台 Sandbox,装好 Agent CLI,调 .snapshot() 拿到 snap_...,再把它交给 vercelSandbox({ snapshotId }):
scripts/build-vercel-snapshot.ts
snap_7sIjfs71xfmVly0WEUTGhTBoMGeL,但它不是跨账号公共 ID。
运行时 checkpoint
createCheckpoint() / restoreCheckpoint() 是另一件事。它们把指定的 Linux 路径打包成 Buffer,可以在已创建的 Sandbox 之间恢复文件系统片段:
.before() 中调用 restoreCheckpoint()。恢复成功后用 context.onCleanup() 登记 createCheckpoint(),让回存在实例退出、Provider finalizer 之前执行。
sandboxReuse: true 只让同一个物理实例在本 Invocation 内继续存在。多个 Invocation 会读写同一 checkpoint 时,在 Experiment 顶层声明 sharedState: { key }。NiceEval 在 Experiment setup 或创建 Sandbox 前取得租约,等 checkpoint 回存、Provider finalizer 与 Experiment teardown 完成后释放。它只提供互斥,不负责 checkpoint 存储、事务恢复或跨机器协调。
瞬时错误重试
内置 Provider 创建 Sandbox 时,限流、fetch failed、连接重置、5xx、临时网络不可达这类瞬时失败会自动做指数退避重试。模板不存在、凭据缺失这类配置错误第一次就报错。重试用尽后该 Attempt 记为 errored。defineSandbox 自定义 Provider 的 create 是你自己的函数,NiceEval 不替它重试。
readText、readBytes、downloadFile、uploadFile、文件写入和目录上传会对 429、5xx、fetch failed、连接重置等瞬时传输错误自动做有限重试。文件不存在、权限错误、取消和 Sandbox terminated 不重试。
runCommand 与 runShell 不自动重试。命令可能已经产生副作用,只有你能确认它可安全重复时,才在 hook 或评估用例中显式重试。
Docker
Docker 适合本地开发和标准 CI。优点是简单、可控、无云端依赖。缺点是机器资源有限,冷启动和安装依赖可能较慢。Vercel Sandbox
Vercel Sandbox 适合需要云端隔离、更多资源或更稳定环境的任务。需要相应 token 或 OIDC 配置。自定义 Provider
用defineSandbox 接入其它服务。自定义 Provider 的作者必须负责文件系统、进程、网络与凭据隔离;NiceEval 不会为一个任意 Sandbox 实现自动补上这些安全边界。create 的 feedback 已绑定到 sandbox.create 阶段,可以报告分配实例、拉镜像或恢复 Sandbox 快照的状态:
Sandbox 接口即可。Provider SDK 的原始日志不要直接写宿主进程的 stdout / stderr。短期状态走 feedback.progress,需要保留的问题走 feedback.diagnostic,无法创建环境时抛错。这样 Human dashboard 不会被日志打散,CI 也能保持单一有序输出。
权限和执行身份
不同 provider 对执行身份、网络、文件系统和进程生命周期的约束不同。编写 fixture 时尽量避免依赖宿主机环境,把依赖写进package.json 或 fixture setup。
性能建议
- 把稳定重依赖做进 image/template/snapshot,或声明为低频
.before()action。 - 减少 fixture 依赖体积。
- 对动态内容使用小而明确的缓存或预检。
- 控制
maxConcurrency(experiment 字段或--max-concurrency),避免本地 Docker 资源耗尽。 - 把慢测试拆成必要的 gate 和可选的
.optional()检查。
在 Attempt 之间复用 Provider 启动
Provider 启动主导反馈时间时,在实验里声明sandboxReuse: true。多条 Attempt 可以依次共用一个 Sandbox:每个复用实例只创建一次,每条真实 Attempt 仍有 attempt .before() / .after() 计划,题间重置工作目录。声明式 Docker action 可另外复用匹配的准备前缀。