在 Cloudflare Workers 上自托管 Claude Agent 沙箱:基于 Durable Object 与 SessionToolRunner 的纯 Worker 实现指南
本文档完整解析
claude-cookbooks仓库中 managed_agents/self_hosted_sandboxes/cf-worker/README.md 所描述的纯 Worker 变体:如何不启动任何真实容器,仅凭 Cloudflare Worker + Durable Object,就用 TypeScript SDK 的client.beta.sessions.events.toolRunner()为一个自托管环境逐 session 拉起"沙箱执行器",并通过内存中的假文件系统完成read/write/edit/glob/grep工具调用。读完本文,你可以完整理解 webhook → 队列排空 → 每会话 runner 的链路设计、Durable Object 如何自行持有租约心跳与强制停止、为何单个环境密钥即可贯穿控制面与会话面,并掌握本地部署到 Cloudflare 的完整步骤及其与 Container / Vercel / Modal 变体的取舍。
一、这个变体在整套方案中的定位
仓库 managed_agents/self_hosted_sandboxes/README.md 把"Claude 托管 Agent 的自托管执行沙箱"定义为一个在多个计算平台上实现的统一契约,每个变体都做同样的三件事:
- 接收并校验
session.status_run_startedwebhook(用client.beta.webhooks.unwrap()); - 排空环境工作队列(environment work queue),使任何单次 webhook 投递都能补捞此前错过的所有积压任务;
- 对每个工作项拉起一个"每会话沙箱",运行 SDK/CLI 的工具 runner(
bash/read/write/edit/glob/grep),维持租约心跳,并把tool_result回投给会话。
各变体的计算载体与 runner 对比如下(详见 variants 对比表):
| 变体 | 计算载体 | Runner 形态 |
|---|---|---|
| docker/ | 自管主机上的普通 Docker | 每会话容器内跑 ant beta:worker run |
| cf/ | Cloudflare Containers | 每会话 Cloudflare Container 内跑 ant beta:worker run |
| cf-worker/(本文) | Cloudflare Workers(无容器) | Durable Object 内跑 TS SessionToolRunner + 隔离内假文件系统 |
| modal/ | Modal | Modal Sandbox 中运行 Python sandbox_runner.py |
| daytona/ | Daytona | 上传到 Daytona Sandbox 的同一份 sandbox_runner.py |
| vercel/ | Vercel Functions + Sandbox | Vercel Sandbox 内的 Node runner.mjs |
本文的 cf-worker 变体走的是同一套 webhook → 队列排空 → 每会话 runner 的形状,但把执行载体换成 Durable Object:它运行 TS 库形态的会话侧调度循环,配一个"隔离内的假文件系统"来替代真实容器。它的价值在于作为 SessionToolRunner 的 TypeScript 库用法参考——展示如何把自定义工具传入 toolRunner()、以及如何由 DO 自行负责工作项的生命周期(心跳 + 强制停止)。而 cf/ 变体的定位与它互补:若需要真实 shell、或需要 EnvironmentWorker(它组合了整个工作项生命周期,但会引入 Worker isolate 跑不动的 Node 专用模块 agent-toolset/node),应选用 Container 或 Vercel/Modal 演示(见 cf/README.md)。
二、整体数据流:一次 webhook 投递如何拉起一个会话 runner
cf-worker 只有两个逻辑部件,都在 src/index.ts 中可见:
POST /(webhook)
│ client.beta.webhooks.unwrap() 校验签名
▼
仅接受 session.status_run_started
▼
drainWork(): 轮询 /work/poll → ack → (若该 session 尚无活实例) RUNNER DO.start({...})
▼
SandboxRunner Durable Object(src/runner.ts)
├─ heartbeatLoop(): 对 work 项持续心跳,维护租约
└─ client.beta.sessions.events.toolRunner(sessionId, {tools: fakeTools(this.fs), signal})
每来一次工具调用 → 在内存 Map 上执行 → 回投 tool_result
▼
任务结束 → 强制 stop work 项(force: true)
2.1 webhook 只是"唤醒信号"
在 index.ts 的 fetch 处理器中,每个投递都会:
- 用
anthropic.beta.webhooks.unwrap(body, { headers })验证签名,失败直接返回401 signature verification failed(#L132-L137); - 若非
session.status_run_started,直接返回{ status: "ignored", event_type }(#L139-L141); - 命中则调用
drainWork(env, anthropic)。
关键设计在文件头注释中已点明:webhook 只是唤醒信号——每次投递会把所有待处理工作项全部排空,因此"单次到达的 webhook 就能恢复任何更早错过的投递"。这消除了对 webhook 可靠性的强依赖,也符合上层 契约第 2 步 的约定。
2.2 队列排空:poll → ack → spawn,而不是长轮询
worker 控制面的 WorkPoller 会对空队列长轮询且永不返回,但 Worker 的 fetch handler 必须及时返回响应。因此 drainWork() 采用"轮询直到空"的同步排空策略,并设置了 MAX_DRAIN = 25 的循环上限(#L22)以防无界空转。
每次循环内的处理要点:
anthropic.beta.environments.work.poll(env.ANTHROPIC_ENVIRONMENT_ID, { reclaim_older_than_ms: 2000 })领取一个工作项(#L62-L64)。注释说明 SDK 会自动带上anthropic-beta: managed-agents-2026-04-01,无需额外手动声明 beta 头。- 对
404("EnvironmentInstance not found for session …")做跳过而非终止处理:当队列取出 Redis 条目而其 session 已消失时,服务端已消费该过期条目,继续continue即可越过它;其余错误则break,等下一个 webhook 再来恢复(#L65-L76)。 - 日志采用显式白名单:只记录
id、environment_id、data.type、data.id、时间戳与心跳时间等字段,刻意排除actor(可能是 PII)和metadata(用户提供内容),见#L80-L89。 - 只处理
work.data.type === "session"的工作项(#L90)。 ack之后,以 session id 为名取 DO:env.RUNNER.get(env.RUNNER.idFromName(sessionId))(#L100)——一个 session 恰好对应一个 Durable Object 实例,天然实现"每会话沙箱"。- 先查
stub.isLive():只有该 DO 当前没有活任务时才调用stub.start(...),避免重复拉起(#L101-L109);已在跑的则记入created: false返回。
错误回显统一走 errDetail():它把 SDK 错误里的 status 与 requestID 保留下来(便于与服务端 trace 关联),而消息正文经 redact() 处理后才会落到日志——sk-ant-*、whsec_*、Bearer <token> 三种凭证形态都会被抹掉。
三、核心 Runner:Durable Object 内的 SessionToolRunner
真正执行"会话侧调度"的是 SandboxRunner 这个 Durable Object。它持有两个实例状态:一个假文件系统 private fs: FakeFS = new Map()(#L37)和一个用于中止整条链路的 AbortController(#L38)。
3.1 分派只做会话侧,租约生命周期由 DO 自持
代码注释明确区分了两层职责(#L4-L10):
SessionToolRunner只管分派循环:reconcile + 事件流 + 工具执行 + 结果回投;- 工作项生命周期(心跳 + 强制停止)由 DO 持有,因为 DO 才是工作项的租约人(lessee);
- 仓库中没有用
EnvironmentWorker,因为它会引入 Node 专用模块agent-toolset/node,Workers isolate 无法运行——这正是这个变体要"手写"心跳循环的根本原因。
这解释了为什么 start() 里要把任务 detach 出去跑:start() 由 webhook 调用,this.ctx.waitUntil(...) 让心跳与 runner 循环在 webhook 返回后继续执行(runner.ts#L44-L61)。同时 start() 开头有 isLive() 幂等保护,配合 index.ts 中的重复检查构成双层防重入。
3.2 调度主循环:toolRunner()
runWorkItem() 的骨架是:
for await (const call of client.beta.sessions.events.toolRunner(opts.sessionId, {
tools: fakeTools(this.fs),
signal: ctrl.signal, // 缺省:end_turn 空闲 60s 后退出;传 0 则跑到 session 结束
})) {
console.log(`[runner] dispatched tool=${call.name} tool_use_id=${call.toolUseId} is_error=${call.isError} posted=${call.posted}`);
}
每条日志都带 tool_use_id、is_error、posted,方便用 wrangler tail 观察"工具是否被成功分派与回投"。
3.3 心跳循环:动态跟随服务端 TTL
heartbeatLoop() 默认每 30 秒(HEARTBEAT_DEFAULT_MS = 30_000,#L22)向 client.beta.environments.work.heartbeat() 报告一次:
- 携带
expected_last_heartbeat: last做并发安全的条件更新(#L104-L109); - 若服务端返回
ttl_seconds > 0,把间隔收敛到[1s, ttl/2] ∩ 30s区间(#L110)——即按服务端租约时长自适应频率; - 若响应显示
state为stopping/stopped,或lease_extended为假,说明控制面要回收该工作项,立即ctrl.abort()退出(#L111-L115); - 错误分两类处理(isFatal4xx):永久性 4xx(
4xx且非 408/429)直接放弃,临时性错误只告警并继续下次心跳(#L116-L124)。408/429 被视为可重试,这符合常见的限流/超时语义。
3.4 结束清理:强制停止工作项
调度循环无论正常退出还是异常退出,都进入 finally(#L87-L96):先 ctrl.abort() 停掉心跳,await heartbeat 收尾,再调 work.stop(opts.workId, { force: true }) 释放租约;409(已被停止等竞态)被吞掉,其余错误才记日志。这与 ant beta:worker run 在退出时的行为一致——runner 是 work 项的租约人,退出前必须显式 force-stop。
四、"假沙箱"工具集:Durable Object 内存里的 Map 文件系统
tools 模块在 src/tools.ts 中。所谓"隔离内假文件系统"就是一个 Map<string, string>(类型别名 FakeFS,#L12),key 是路径、value 是文件内容,常驻在 Durable Object 里。它展示的核心 API 是:如何把自定义工具传给 client.beta.sessions.events.toolRunner——入参形态与 client.beta.messages.toolRunner 接受的完全一致。
五个工具的 schema 与语义如下(均由 fakeTools() 返回):
| 工具 | 入参 | 行为 |
|---|---|---|
read |
file_path |
fs.get(file_path),缺失返回 error: <path>: no such file |
write |
file_path, content |
fs.set(...),返回写入字节数 |
edit |
file_path, old_string, new_string |
替换首个匹配;文件缺失或 old_string 不在时返回明确错误 |
glob |
pattern |
把 glob 转正则后在 key 上过滤,命中按字典序 \n 拼接 |
grep |
pattern, path? |
逐行正则搜索,输出 path:line:text;path 可选用于限定范围 |
bash |
command |
不可用桩:直接返回错误并提示改用上面五个工具 |
其中 globToRegex() 把 ** 翻译成 .*、* 翻译成 [^/]*、? 翻译成 [^/],让 glob 的"目录通配"语义在路径匹配上成立;grep 复用同一转换来限定扫描范围。
值得注意的边界行为都写进了返回字符串而非抛异常:例如 edit 在 old_string 找不到时返回可读错误、bash 明确告诉模型"此环境不可用 bash"。这种"用工具结果说话"的做法让 Agent 能自然地切换策略(改走文件类工具),而不是撞上不可恢复的运行错误。
fakeTools 最后返回 [bash, read, write, edit, glob, grep](#L87),与真实 toolset 同形。相比之下,容器类变体(如 docker/ 与 cf/)里这些工具由 CLI/SDK 在真实 shell 上实现;而在纯 Worker isolate 里没有进程模型,所以 bash 只能是被禁用的占位。
五、单一凭证设计:环境密钥贯穿控制面与会话面
cf-worker 变体与整套方案共享一条安全设计(cf/README.md 也有同样表述):组织级 API key(org API key)从不进入任何 runner。
- webhook 侧:
drainWork用的 Anthropic client 以ANTHROPIC_ENVIRONMENT_KEY为authToken,负责poll/ack(work.stop也走同一密钥),因此这些调用不需要任何额外的 Authorization 头(见 index.ts#L94-L98 注释); - runner 侧:DO 内
new Anthropic({ authToken: opts.environmentKey })用同一个环境密钥,支撑会话事件流、租约心跳与强制停止三类调用(runner.ts#L49-L53、#L73-L74); - webhook 签名则由独立的
ANTHROPIC_WEBHOOK_SECRET(whsec_*)负责,SDK 会内部解码其 URL-safe base64,所以代码中把密钥原样传入webhookKey(index.ts#L126-L128)。
这种"单密钥 + 签名密钥"的最小凭证面,意味着丢失或轮换 runner 凭证只需处理环境密钥一处;生产上还叠加了前文 2.2 节的日志脱敏,双保险。如何生成环境密钥、把密钥导出为宿主环境变量,见 docs/usage-guide.md 的步骤 1–2。
六、空闲策略:60 秒 end_turn 空闲即退出
两个层面都有空闲控制,且语义一致:
- SDK 缺省空闲策略(本变体所用):
toolRunner()在session.status_idle且stop_reason: end_turn后maxIdleMs(缺省 60 秒)退出;任何其他事件——包括requires_action的空闲(Agent 正被 DO 阻塞等待工具)——都会重置计时器(runner.ts#L12-L15)。想要"一直跑到会话结束",可把该参数传 0(#L79-L80注释)。 - CLI 变体的对照:容器类变体由
ant beta:worker run的--max-idle承载同样的"end_turn 空闲后退出、其他事件重置时钟"策略(usage-guide.md Flags 表)。
也就是说,空闲回收的责任不在 webhook、也不在控制面队列,而在会话侧 runner 自身:一次 end_turn 后若 60 秒内没有新事件,runner 自然结束 → finally 里 force-stop work 项 → 控制面随即回收该 session 的租约。这正是"Durable Object 租约人"模型的闭环:没有显式的全局清理器,靠的是 runner 自主退出 + 租约自然过期。
七、部署配置与本地运行
7.1 wrangler.toml 关键配置
wrangler.toml 的内容决定了整个部署形态:
name = "cma-self-hosted-sandbox-worker"
main = "src/index.ts"
compatibility_date = "2026-03-01"
compatibility_flags = ["nodejs_compat"]
[observability]
enabled = true
[[durable_objects.bindings]]
name = "RUNNER"
class_name = "SandboxRunner"
[[migrations]]
tag = "v1"
new_sqlite_classes = ["SandboxRunner"]
[vars]
ANTHROPIC_BASE_URL = "https://api.anthropic.com"
# 替换为你的自托管环境 id(Console → Environments)
ANTHROPIC_ENVIRONMENT_ID = "env_01..."
逐项说明:
main指向 src/index.ts,index.ts把SandboxRunner一并 re-export(#L12),供 DO 绑定解析;compatibility_flags = ["nodejs_compat"]是 SDK 得以在 Workers isolate 里运行的前提;compatibility_date需为支持所依赖 Durable Object SQLite 与 Worker 特性的较新日期;[[durable_objects.bindings]]声明绑定名RUNNER与类SandboxRunner,与 Env 接口 中的RUNNER: DurableObjectNamespace<SandboxRunner>一一对应;[[migrations]]用new_sqlite_classes为SandboxRunner创建 SQLite-backed DO 存储(FakeFS 的持久化基础);[vars]里ANTHROPIC_BASE_URL默认指向官方 API,需要时可改成你的网关;ANTHROPIC_ENVIRONMENT_ID占位env_01...,部署前必须替换为 Console → Environments 中实际的环境 id;ANTHROPIC_WEBHOOK_SECRET与ANTHROPIC_ENVIRONMENT_KEY是两类敏感凭证,只能走 secret 注入,wrangler.toml 里只留注释(#L22-L24)。
7.2 部署步骤(原文命令 + 注释补充)
原文档给出四步,此处原样继承并补充说明:
npm i # 安装 @anthropic-ai/sdk、zod 等依赖(见 package.json)
wrangler secret put ANTHROPIC_WEBHOOK_SECRET # 注册 webhook 时的 whsec_ 签名密钥
wrangler secret put ANTHROPIC_ENVIRONMENT_KEY # 环境的 sk-ant-oat01-... 密钥
# 先编辑 wrangler.toml 里的 ANTHROPIC_ENVIRONMENT_ID,然后:
wrangler deploy
package.json 中 依赖与脚本 还提供了三组常用命令:
npm run dev→wrangler dev:本地起 Worker,配合wrangler tail(runner 里logLevel: "info"即为此服务)可观察 runner 生命周期;npm run deploy→wrangler deploy:生产发布;npm run typecheck→tsc --noEmit:零输出类型检查(tsconfig.json 全程strict: true,面向 ES2022 + Workers types)。
7.3 自托管环境前置条件
本变体依赖的公共 API 需要以下请求头,且要求你的账号已被授权使用自托管环境能力(创建方法及代码级创建示例见 usage-guide.md 的步骤 1):
anthropic-version: 2023-06-01
anthropic-beta: managed-agents-2026-04-01
环境密钥在 Console → Workspace → Environments 中生成,作为 ANTHROPIC_ENVIRONMENT_KEY 使用;它认证该环境的整条 worker 流程——poll、ack、stop、heartbeat、session 事件流、技能下载(usage-guide 原文语),是 runner 唯一的所需凭证。
八、适用范围与局限:何时换用容器类变体
原文档明确划定了这个变体的边界,引用如下要点:
- 它是
SessionToolRunner的 TS 库用法参考,聚焦自定义工具 + DO 自持心跳/强制停止; - 由于 Workers isolate 里没有真实 shell 与进程模型,
bash只能是不可用桩,工具能力局限在内存文件系统; EnvironmentWorker(组合整个工作项生命周期的高层封装)不能在这里使用,因为它引入 Node 专用模块agent-toolset/node,Workers isolate 无法加载;- 因此,需要真实 shell、或希望使用
EnvironmentWorker时,请改用 Container 变体 或 Vercel/Modal 演示。
在实践中这意味着一个清晰的分级决策:
- 演示与库用法学、希望部署面最小、无需持久真实文件系统、Agent 只需读改写文本/搜文件 → 选 cf-worker;
- 需要真实命令行执行、装包、编译、运行进程 → 走 docker/ 或 cf/ 容器路线(runner 为
ant beta:worker run,工具集在真实 shell 上生效); - 需要开箱即用的
EnvironmentWorker高层编排 → 参考 vercel/ 或 modal/。
无论选哪种,上层契约相同(webhook 唤醒 → 排空队列 → 每会话 runner → 心跳 → 强制停止),因此控制面(webhook 注册、环境密钥)可以复用,只需要替换计算载体。
九、小结:从这份参考实现可以带走什么
纵观 README 与其 index.ts、runner.ts、tools.ts 三个实现文件,能提炼出五条可复用的工程结论:
- 用 Durable Object 实现"每会话沙箱":以 session id 命名 DO 实例即得天然的路由与隔离,
isLive()+ 幂等start()防止重复拉起; - webhook 只做唤醒:每次投递排空整个队列(上限
MAX_DRAIN),使系统对单次投递丢失天然免疫; - 租约生命周期属于执行器自身:DO 自行心跳、按服务端 TTL 自适应间隔、观测到
stopping/lease 未续即自中止,退出时force: true释放工作项; - 工具即函数:
betaZodTool+ Zod schema 即可把任意内存操作包装成与toolRunner兼容的工具,bash这类不可行操作应返回可读错误而非抛异常; - 最小凭证面:环境密钥唯一贯穿 poll/ack/stop/事件流/心跳,webhook 签名密钥独立分离,日志侧再做正则脱敏,三层防护形成闭环。
对想"零容器"跑通 Claude 托管 Agent 自托管沙箱、或想在自有 TS 代码里复用 SessionToolRunner 的开发者而言,这份实现是目前仓库中最直接、最完整的 TS 库用法范本;需要完整 shell 能力时,再在同一契约下平滑切换到容器变体即可。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0630
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00