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。
生命周期
dockerSandbox({ source: { type: "image", image } })、vercelSandbox({ snapshotId }) 和 e2bSandbox({ template }) 都返回 SandboxLayer。返回值有 .prepare(command)、.setup(fn) 和 .teardown(fn) 方法。
这些方法处理只有运行时才知道的环境内容。例如按实验写配置、检查预制工具、安装 hook,或在多次 Attempt 之间载入和回存状态。
共享辅助函数需要显式标注回调类型时,从公开入口导入,不要从某个 provider 的 spec 反推:
.setup() 只做薄薄的一层动态配置和 fail-fast 检查。
- 签名:Hook 函数是
(sandbox, ctx),不返回值。这里的ctx是窄的 Sandbox Hook Context,只有 experiment 身份、取消信号和反馈方法,不带 Agent 会话或 telemetry。 - 不可变:每次
.setup()/.teardown()都返回一个新 spec,原对象不变,可以继续链。 - 多个 Hook:多个
.setup()按追加顺序执行。多个.teardown()按追加的逆序执行(LIFO)。.setup()链中途抛错时后续.setup()不再执行,.teardown()链仍完整走完——半初始化的 Sandbox 同样要扫尾。 - 执行时机:
setupHook 在 sandbox 创建后、git 基线之前最先跑——它写下的文件会进基线,不会被算进 agent 产出的 diff。teardownHook 在 Adapter 的teardown之后、sandbox 销毁之前最后跑——把状态回存到外部正好用这个时机。 - 触发规则:
teardown当且仅当同一 Sandbox 的setup时点已经走到才执行——setup抛错不豁免,收尾代码要对可能未赋值的句柄做防御。setup没跑到(Sandbox 没创建成功)则teardown同样跳过。 - 失败语义:
setupHook 抛错,这次 Attempt 记为errored(环境问题,不是 Agent 做错题)。teardownHook 可以报告 diagnostic,默认不改变已经得到的判定。某个收尾动作是结果成立的必要条件时应抛错,由 runner 明确记录为致命错误。 - 不带 Sandbox 的 Agent:
defineAgent构造的 Agent 没有 Sandbox,sandbox字段对它不生效,Hook 自然不会跑。
把 setup 的产物传给 teardown
setup 不返回值,teardown 要用 setup 建立的句柄(一个已打开的连接、一个临时文件路径)时不能存进普通模块变量——同一个 spec 被多个并发 Attempt 复用同一个模块,模块变量会被后来的 Attempt 互相覆写。以 sandbox 实例作键存取:每个 Attempt 有自己独立的 sandbox,是天然的 per-attempt 键:
ctx.experimentId 的外部 KV,普通代码即可,见下段。
Hook 里可以用 ctx.experimentId(路径推导的实验 id)当状态隔离的键,比如不同实验各自维护一份跨 attempt 的缓存。跨 attempt 状态的载入和回存是你自己在 Hook 里写的普通代码——NiceEval 不提供状态存储。要保证同一实验的 attempt 不并发读写同一份状态,在 experiment 上声明 maxConcurrency: 1。
从 Hook 中输出进度和问题
长时间安装、预热和状态恢复可以调用ctx.progress(...)。它只更新当前 Attempt 的短期状态,不会把每一步都写进结果。需要运行结束后仍能看到的问题使用 ctx.diagnostic(...):
progress 和 diagnostic 都不能指定全局阶段、颜色或输出流。Runner 知道当前回调属于 sandbox.setup,会把 Human 终端中的阶段显示为 Sandbox setup。diagnostic 写入 Attempt-owned 诊断通道,之后可用 niceeval show --run <runId> --page attempt-<attemptId> 回顾。它本身不会改变判定。Sandbox 无法继续时直接抛出异常。
三种 setup 各有职责。Adapter 的 setup 负责连接被测 Agent,例如安装 CLI 和写入鉴权配置。评估用例中 test(t) 开头的代码负责准备任务起始文件。Sandbox 的 .setup() 负责当前实验的额外环境准备。MCP server、Skill 和 model 等被测 Agent 配置仍然只从 Adapter 工厂参数传入。
预制环境与运行时 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 还会核对版本、OTel 插件和 Python 插件集合的安装指纹,不能仅凭 command -v bub 跳过安装,必须用预制环境。三个 provider 的构建都只在环境依赖变化时跑一次,产物换一个版本化名字,不要写入每个 Attempt 的 .setup()。
E2B:从官方与公共模板派生
E2B 已提供 Claude Code 的claude template 和 Codex 的 codex template。NiceEval 提供一个 E2B 专属的薄封装,让你从这两个官方起点继续链原生 E2B API。E2B 暂无 Bub 官方 template,因此 Bub 分支使用 NiceEval 固定版本的安装配方(Bub 版本 + 同代 OTel 插件):
NiceEval 同时发布了三份任何 E2B Team 都能引用的公共模板。完整 namespace 与版本由 NiceEval 自己维护,
下游直接取完整引用:
2.1.207、Codex 0.144.1、Bub 0.4.0(安装指纹随版本与 OTel 插件 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。沙箱快照是从一台跑起来的 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 之间恢复文件系统片段:
restoreCheckpoint() 放进 SandboxLayer.setup(),把 createCheckpoint() 放进配对的 teardown()。setup() 在实际 Sandbox 创建后恢复,因此新 run 也走同一边界。teardown() 在实例退休、Provider finalizer 前回存。
sandboxReuse: true 只让同一个物理实例在本 Invocation 内继续存在。多个 Invocation 读写同一 checkpoint 时,在 Experiment 顶层声明 sharedState: { key },让另一边在创建 Sandbox 前等待。
瞬时错误重试
内置 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 配置。本地目录
localSandbox() 让 Agent 直接在你机器上的一个 Git 仓库里跑,不需要 Docker,也不需要云凭据:
localSandbox({ dir: "/path/to/repo" })。
NiceEval 只观察、不还原:Agent 改了什么会真实落在你的工作树上。NiceEval 用一份独立的私有 Git 记录采集 diff 和评分,不碰你自己的 .git、暂存区和未提交改动,跑完也不会执行任何 git reset。要保留还是丢弃 Agent 的改动,由你决定。
使用前记住三件事:
- Agent 以你的身份、在你的机器上执行命令。只在你信任任务和 Prompt 的时候用它。要隔离就用 Docker 或云 Provider。
- 本地目录同一时刻只跑一个 Attempt(强制串行),
--max-concurrency抬不高它。 - 连续跑多个评估用例时,前一个的改动会留在工作树上,成为后一个的起点。要每题干净起点,用容器 Provider。
{ user: "..." } 和 --keep-sandbox 对本地目录不可用,会直接报错:NiceEval 不在你的机器上提权或换身份,也不需要「留存」一个本来就在你工作树里的现场。
自定义 Provider
用defineSandbox 接入其它服务。create 的 feedback 已绑定到 sandbox.create 阶段,可以报告分配实例、拉镜像或恢复沙箱快照的状态:
Sandbox 接口即可。Provider SDK 的原始日志不要直接写宿主进程的 stdout / stderr。短期状态走 feedback.progress,需要保留的问题走 feedback.diagnostic,无法创建环境时抛错。这样 Human dashboard 不会被日志打散,CI 也能保持单一有序输出。
权限和执行身份
不同 provider 对执行身份、网络、文件系统和进程生命周期的约束不同。编写 fixture 时尽量避免依赖宿主机环境,把依赖写进package.json 或 fixture setup。
性能建议
- 把稳定重依赖做进 image/template/snapshot,不在每个 Attempt 的
.setup()重装。 - 减少 fixture 依赖体积。
- 对动态内容使用小而明确的缓存或预检。
- 控制
maxConcurrency(experiment 字段或--max-concurrency),避免本地 Docker 资源耗尽。 - 把慢测试拆成必要的 gate 和可选的 soft 检查。
让公共准备只付一次
冷启动(起容器、装依赖、装 Agent CLI)主导反馈时间时,在实验里声明sandboxReuse: true:多条 Attempt 依次共用一个 Sandbox,创建与 Sandbox 级 setup 每个 Sandbox 只付一次,题与题之间只把工作目录重置回公共准备完成时的状态。