在 Cloudflare 上自托管 Claude Agent 沙箱:Webhook 排空队列 + 按会话容器的完整架构实战
本文基于 claude-cookbooks 仓库中 managed_agents/self_hosted_sandboxes/cf/ 参考实现,讲解如何在 Cloudflare Workers 与 Cloudflare Containers 之上搭建一套自托管(self-hosted)执行沙箱,让 Claude Managed Agents 的会话(session)工具调用(bash/read/write/edit/glob/grep)真正跑在你自己的基础设施里,而不是 Anthropic 托管环境。读完本文,你将掌握:Webhook 签名校验与「排空工作队列」恢复机制的设计、按会话粒度启动容器的 Durable Object 编程模型、ant beta:worker run 作为容器入口的职责划分,以及完整的 wrangler.toml 配置与密钥部署流程。
项目定位:一份多平台的「自托管沙箱」参考实现
在 claude-cookbooks 仓库的 managed_agents/self_hosted_sandboxes/README.md 中,自托管沙箱被定义为一组「把 Claude Managed Agents 会话指向你自己执行的沙箱」的参考实现。每个变体(variant)都遵循同一份契约:
- 接收
session.status_run_startedwebhook(用client.beta.webhooks.unwrap()做签名校验); - 排空环境工作队列(work queue),让单次投递也能恢复此前错过的所有工作项;
- 对每个工作项,启动一个按会话(per-session) 的沙箱,运行 SDK/CLI 工具运行器,对租约(lease)做心跳,并把
tool_result回传给会话。
不同变体只是把同一契约映射到不同计算平台:普通 Docker(docker/)、Cloudflare Containers(cf/)、纯 Cloudflare Workers 无容器(cf-worker/)、Modal、Daytona、Vercel 等。本主题对应的 cf/README.md 正是其中的 Container 变体:Webhook 由 Worker 处理,真正的工具执行放进 Cloudflare 的每个会话独立容器。
涉及的核心版本以仓库为准:TypeScript SDK
@anthropic-ai/sdk ^0.97.0、antCLI v1.9.0、wrangler ^4.0.0、@cloudflare/containers ^0.0.28(见 cf/package.json)。
三层职责划分:Worker、Durable Object 与容器内的 CLI
理解该架构的关键,是看清三份代码各自拥有什么生命周期:
- Worker(src/index.ts):只负责「接收 webhook 并唤醒」,是触发信号而非执行者。
- Durable Object
SandboxContainer(src/container.ts):拥有 Cloudflare 容器的生命周期。它按会话 ID 作为唯一键,负责启动容器、持续续约sleepAfter,避免 CF 把一个仍在运行的会话容器回收掉。 - 容器内 CLI(
ant beta:worker run):拥有会话的业务生命周期——心跳、积压对账(backlog reconcile)、SSE 事件流、默认工具集、空闲(idle)策略,以及退出时对工作项的 force-stop。
这种「Worker 只管唤醒、DO 只管容器存活、CLI 只管会话业务」的分层,让每一层的职责都能独立演进,也天然回答了「为什么容器的存活由 DO 管理,而会话的空闲退出却由 CLI 决定」这类问题。
为什么需要三层而不是两层?
可以反推其动机:
- Webhook 必须快速响应:Worker 的
fetchhandler 有响应时限,不能长期阻塞在轮询上(见下文排空队列的 25 次上限); - 容器必须绑定会话:Durable Object 的
idFromName(sessionId)让同一会话的多次 webhook 始终命中同一个 DO 实例; - 执行逻辑必须可移植:
ant beta:worker run是 Anthropic CLI 的通用命令,这意味着把同样的 runner 迁到 Modal / Daytona / Vercel 时容器内逻辑完全不用改,只替换控制面。
Webhook 入口:签名校验与「仅唤醒」语义
Worker 的入口是 src/index.ts 中的 fetch handler。收到请求后第一件事是用 SDK 做签名校验:
const anthropic = new Anthropic({
authToken: env.ANTHROPIC_ENVIRONMENT_KEY,
baseURL: env.ANTHROPIC_BASE_URL,
// 直接传 whsec_ 密钥:SDK 内部会处理其 URL-safe base64 解码
webhookKey: env.ANTHROPIC_WEBHOOK_SECRET,
});
const body = await req.text();
let event: ReturnType<typeof anthropic.beta.webhooks.unwrap>;
try {
event = anthropic.beta.webhooks.unwrap(body, { headers: Object.fromEntries(req.headers) });
} catch (e) {
console.warn(`[webhook] signature reject: ${e instanceof Error ? e.constructor.name : "Error"}`);
return new Response("signature verification failed", { status: 401 });
}
要点如下:
unwrap()完成 Webhook 签名校验。校验失败立即返回 401,连业务逻辑都不进入;- 只处理
session.status_run_started:event.data.type !== "session.status_run_started"时直接返回{ status: "ignored" },其它事件一律忽略; - Webhook 只是「唤醒信号」——代码注释原话是 "The webhook is a wake-up signal only"。它并不针对单个事件做精确处理,而是触发一次全量排空(见下一节),因此即使有若干 webhook 投递失败/迟到,任何一次成功投递都能把积压工作补齐。这是该实现在「at-least-once 投递」下保持自愈的关键设计。
依据 SDK 注释,
anthropic.beta.webhooks.unwrap对应 HTTP 头约束anthropic-version: 2023-06-01、anthropic-beta: managed-agents-2026-04-01(详见 managed_agents/self_hosted_sandboxes/docs/usage-guide.md)。
排空工作队列:一次投递恢复所有遗漏
drainWork(src/index.ts)实现了一个「poll → ack → dispatch,直到排空」的循环。代码中的常量 MAX_DRAIN = 25 是单次 webhook 最多轮询的工作项数,保证 fetch handler 能在时限内返回。核心流程:
for (let i = 0; i < MAX_DRAIN; i++) {
work = await anthropic.beta.environments.work.poll(env.ANTHROPIC_ENVIRONMENT_ID, {
reclaim_older_than_ms: 2000, // 超过 2s 未被心跳续约的工作项可被重新认领
});
if (!work) break; // 队列空,退出
if (work.data.type !== "session") continue;
await anthropic.beta.environments.work.ack(work.id, { environment_id: ... });
const stub = env.SANDBOX_CONTAINER.get(env.SANDBOX_CONTAINER.idFromName(sessionId));
const wasLive = await stub.isLive();
if (!wasLive) {
await stub.dispatch({ sessionId, environmentKey, workId, environmentId, baseURL });
}
spawned.push({ session_id, work_id, created: !wasLive });
}
实现中几个值得借鉴的工程细节:
poll的 404 容错:/work/poll可能在 Redis 队列中取到「会话已消失」的陈旧条目时返回 404。此时服务端已消费该条目,continue重试即可越过;其它状态码才break停止排空、等待下一个 webhook 重来(src/index.ts);- 单条失败不中断整体排空:ack 或 dispatch 抛错时只记录
FAILED work=...,继续排空,因为「租约(lease)会过期,下一个 webhook 会重新认领」; - 幂等启动:dispatch 前先查
stub.isLive(),容器已在运行就不重复启动,wasLive=false才真正触发; - 日志白名单:工作项日志只输出
id / environment_id / data.type / created_at / acknowledged_at / latest_heartbeat_at等字段,显式避免把actor(PII)与metadata(用户提供)写进日志; - 凭据脱敏:
redact()会剥掉sk-ant-...、whsec_...、Bearer ...形态的密钥再镜像错误信息(src/index.ts),errDetail()则额外带上 SDK 错误的status与requestID,让 API 故障能对应到服务端链路追踪。
为什么 webhook 不直接对单个 session 发 stop?因为租约归容器里的 CLI 所有——Worker 的注释说得很清楚:"The container owns the lease (heartbeat + force-stop), so the webhook never posts stop." 这再次印证了职责划分的严格性。
按会话的 Cloudflare Container:Durable Object 生命周期管理
src/container.ts 定义了 SandboxContainer extends Container<Env>。它对外暴露两个方法:
isLive():返回this.ctx.container?.running ?? false,即底层容器进程是否在跑;dispatch(opts):若已存活直接返回;否则start({ envVars })启动容器,并挂起一个lifecycleWatch事件循环。
wrangler.toml 中对应的绑定如下(managed_agents/self_hosted_sandboxes/cf/wrangler.toml):
[[durable_objects.bindings]]
name = "SANDBOX_CONTAINER"
class_name = "SandboxContainer"
[[containers]]
class_name = "SandboxContainer"
image = "./container/Dockerfile"
max_instances = 50
# 调试 runner 时可执行 `wrangler containers ssh <instance-id>`
ssh = { enabled = true }
[[migrations]]
tag = "v1"
new_sqlite_classes = ["SandboxRunner", "SandboxContainer"]
[[migrations]]
tag = "v2"
deleted_classes = ["SandboxRunner"]
传给容器的环境变量契约
dispatch 中 start() 传入的 envVars(src/container.ts)是 ant beta:worker run 的完整环境契约:
| 变量 | 值 | 用途 |
|---|---|---|
ANTHROPIC_BASE_URL |
opts.baseURL |
API 基地址(默认 https://api.anthropic.com) |
ANTHROPIC_ENVIRONMENT_KEY |
opts.environmentKey |
工作项调用(心跳 / ack / force-stop)与会话事件流认证 |
ANTHROPIC_AUTH_TOKEN |
与上面同一个值 | 见下文的 skill 下载细节 |
ANTHROPIC_SESSION_ID |
opts.sessionId |
会话标识 |
ANTHROPIC_ENVIRONMENT_ID |
opts.environmentId |
环境标识 |
ANTHROPIC_WORK_ID |
opts.workId |
工作项标识 |
其中 ANTHROPIC_AUTH_TOKEN = ANTHROPIC_ENVIRONMENT_KEY 是有原因的:CLI 的 skill 下载路径会构造一个普通 SDK client,它只从 ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN 解析认证——如果只传 environment key,skill 初始化会报 "no Anthropic credentials found"。源码注释特别对比了 Python/TS SDK runner 会把 key 直接织入各自的 client,不需要这层冗余,只有 Go CLI 的 skill 路径目前需要(src/container.ts)。
空闲策略与 sleepAfter:两道不同语义的「超时」
这是最容易混淆的部分,注意区分两把计时器:
- CLI 的空闲策略:容器入口命令带
--max-idle 60s——在收到session.status_idle(stop_reason: end_turn)后 60 秒退出;其它任何事件都会重置计时。它管的是「会话业务结束」,决定容器里的进程何时自愿退出。 - DO 的
sleepAfter = "5m":这只是兜底(backstop)——若lifecycleWatch事件流中途断开,5 分钟未续约时 CF 会回收容器。只要事件流活着,每个会话事件都会调用this.renewActivityTimeout()续约,--max-idle早就会先让容器退出,这个 5 分钟很少真正触发。
lifecycleWatch:给容器「续命」的看门狗
this.sleepAfter = "5m"; // 兜底:流断开后 CF 在 5 分钟后回收
this.manualStart = true;
private async lifecycleWatch(opts, ctrl) {
let backoff = STREAM_BACKOFF_MS; // 1s
while (!ctrl.signal.aborted) {
const stream = await client.beta.sessions.events.stream(opts.sessionId, undefined, {
signal: ctrl.signal,
});
for await (const ev of stream) {
backoff = STREAM_BACKOFF_MS;
this.renewActivityTimeout(); // 每个事件都续约
if (ev.type === "session.status_terminated" || ev.type === "session.deleted") {
await this.stop().catch(() => {});
return;
}
}
// 网络断开 → 指数退避重连:1s → 2s → 4s → ... 封顶 10s
if (ctrl.signal.aborted || !(await this.isLive())) break;
await new Promise((r) => setTimeout(r, backoff));
backoff = Math.min(backoff * 2, STREAM_BACKOFF_CAP_MS);
}
}
看门狗同时承担「续约」和「收尾」:一旦从会话事件流里读到 session.status_terminated 或 session.deleted,就主动 this.stop() 停掉容器;网络瞬时断开则按 1s 起步、指数翻倍至 10s 封顶的退避策略重连,onStop() 里会 abort 当前的 watch 控制器,避免容器已停止还挂着循环。
容器内入口:ant beta:worker run 与 Dockerfile
容器的 Dockerfile 以 debian:12-slim 为基础镜像,通过多阶段参数安装 ant CLI 后以 worker 模式作为入口:
FROM debian:12-slim
ARG ANT_VERSION=1.9.0
ARG TARGETOS=linux
ARG TARGETARCH=amd64
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
# 从 anthropic-cli 官方 release 下载 ant 二进制并解压到 /usr/local/bin
ADD https://github.com/anthropics/anthropic-cli/releases/download/v${ANT_VERSION}/ant_${ANT_VERSION}_${TARGETOS}_${TARGETARCH}.tar.gz /tmp/ant.tgz
RUN tar -xzf /tmp/ant.tgz -C /usr/local/bin ant \
&& chmod +x /usr/local/bin/ant && rm /tmp/ant.tgz
WORKDIR /workspace
ENTRYPOINT ["ant", "beta:worker", "run", "--workdir", "/workspace",
"--unrestricted-paths", "--max-idle", "60s", "--log-format", "json"]
这与 managed_agents/self_hosted_sandboxes/docs/usage-guide.md 中「把 worker 作为容器入口」的标准写法一脉相承——ant beta:worker run 会直接挂接单个会话而非轮询:负责会话事件流、执行工具调用、对工作项租约做心跳,并在会话终止或 --max-idle 到期后以 0 退出。
按 usage-guide 的记录,ant beta:worker 的常用 flag 与对应环境变量可归纳为:
| Flag | 环境变量 | 默认值 | 本容器中的取值 |
|---|---|---|---|
--environment-id |
ANTHROPIC_ENVIRONMENT_ID |
必填 | 由 DO 注入 |
--environment-key |
ANTHROPIC_ENVIRONMENT_KEY |
必填 | 由 DO 注入 |
--worker-id |
ANTHROPIC_WORKER_ID |
hostname | — |
--workdir |
— | . |
/workspace |
--unrestricted-paths |
— | false |
开启(放开文件路径限制) |
--max-idle |
— | 1m |
60s(end_turn 空闲后退出) |
--log-format |
— | text |
json(便于日志采集) |
--base-url |
ANTHROPIC_BASE_URL |
api.anthropic.com |
由 DO 注入 |
注意 usage-guide 的提示同样适用于本方案:「worker 会直接在宿主机上执行 shell 与文件操作」,因此务必把它放进你掌控的隔离边界内——Cloudflare Container 正是这个边界。
部署步骤与安全边界
最小化部署命令
cf/README.md 给出了三步部署:
npm i
wrangler secret put ANTHROPIC_WEBHOOK_SECRET
wrangler secret put ANTHROPIC_ENVIRONMENT_KEY
# 编辑 wrangler.toml 中的 ANTHROPIC_ENVIRONMENT_ID,然后:
wrangler deploy
配套的 package.json 脚本(managed_agents/self_hosted_sandboxes/cf/package.json):npm run dev(wrangler dev)、npm run deploy、npm run typecheck(tsc --noEmit),方便本地联调与类型把关。
wrangler.toml 中 [vars] 与密钥的分工非常清晰:
- 明文
vars:ANTHROPIC_BASE_URL = "https://api.anthropic.com"与占位的ANTHROPIC_ENVIRONMENT_ID = "env_01..."(需替换成 Console → Environments 中自托管环境的真实 ID); - 密钥
secrets:ANTHROPIC_WEBHOOK_SECRET与ANTHROPIC_ENVIRONMENT_KEY必须用wrangler secret put注入,不进代码库。
关于 ANTHROPIC_ENVIRONMENT_ID 的来源,usage-guide 说明自托管环境在 Console → Environments → New → Self-hosted 创建(或通过 client.beta.environments.create({ name, config: { type: "self_hosted" } }) 在代码中创建),随后生成 environment key。环境 key(形如 sk-ant-oat01-...)被设计成该环境的唯一凭据:poll、ack、stop、心跳、会话事件流与 skill 下载全部由它认证。
为什么「组织级 API Key」到不了执行环境
这是该方案最核心的安全设计:没有任何组织级 API key 到达 runner。整个链路中容器只持有 environment key——正是 ant beta:worker run 用于事件流、租约心跳与工作项 force-stop 的那一份凭据。cf/README.md 原文即强调:"No org API key reaches the runner: the container authenticates with the environment key"。结合上文的 redact() 脱敏、日志字段白名单,恶意会话最多只能作用在受限环境与单会话容器内,无法窃取可跨环境的组织凭据。
调试手段
wrangler.toml 为容器开启了 ssh = { enabled = true },并注释给出调试命令:wrangler containers ssh <instance-id> 可直连运行中的容器进程检查 runner 状态。[observability] enabled = true 则让 webhook 侧的结构化日志(含 request_id)进入 CF 的可观测性面板,便于把 SDK 报错关联到服务端链路。
对照:纯 Worker 变体(cf-worker/)与其它平台
cf/README.md 末尾提示:同目录的 cf-worker/ 是纯 Worker 变体——不用真实容器,而是在 Durable Object 内用 TypeScript 的 SessionToolRunner 搭配隔离在 isolate 内的假文件系统(in-isolate fake filesystem) 来跑工具。两者的取舍很直观:
- cf/(本文):真实 Linux 容器 +
ant beta:worker run,工具执行环境完整、贴近生产、可ssh调试,代价是需要拉起/维护容器资源; - cf-worker/(纯 Worker):零容器开销、冷启动更快,但文件系统是模拟的,只适合不需要真实进程与真实磁盘语义的工具子集。
扩展阅读可对照 self_hosted_sandboxes/README.md 中其它平台(Docker / Modal / Daytona / Vercel)如何复用「同一份 sandbox_runner.py」或「同一个 ant beta:worker run」,以及 managed_agents/self_hosted_sandboxes/docs/usage-guide.md 中把 runner 以库形式嵌入自己进程(client.beta.environments.work.worker(...))与自定义工具集(betaAgentToolset20260401 / beta_agent_toolset_20260401)的做法。
小结:从这套参考实现中可迁移的工程经验
回到 cf/README.md,它只用寥寥数段就概括了整个系统的骨架,其背后的可迁移经验值得总结:
- Webhook 只做唤醒,恢复靠排空——把「精确处理每条投递」降级为「每次唤醒后全量对账」,天然容忍投递丢失/乱序,是事件驱动系统里简单有效的自愈范式;
- 生命周期按层归属——CLI 管会话业务(idle/心跳/force-stop),DO 管容器存活(
sleepAfter续约),互不越权,且各有兜底(DO 的 5m backstop + 事件流断开指数退避); - 凭据最小化与日志卫生——执行环境只持 environment key,错误信息过
redact(),业务日志过字段白名单; - 一份契约、多平台落地——runner 固定为
ant beta:worker run,控制面(webhook/队列/DO)才随平台变化,这让自托管沙箱能低成本地迁移到任何能跑容器的计算平台。
若需把同样的沙箱方案落到其它厂商,核心思路不变:先按 usage-guide 建好 self-hosted environment、注册 webhook、注入 environment key,再把本文的三层控制面替换为对应平台的「触发器 + 会话键控的调度器 + 每会话执行器」即可。
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 StartedRust0625
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
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