首页
/ 在 Cloudflare 上自托管 Claude Agent 沙箱:Webhook 排空队列 + 按会话容器的完整架构实战

在 Cloudflare 上自托管 Claude Agent 沙箱:Webhook 排空队列 + 按会话容器的完整架构实战

2026-09-07 13:47:14作者:宣利权Counsellor

本文基于 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)都遵循同一份契约:

  1. 接收 session.status_run_started webhook(用 client.beta.webhooks.unwrap() 做签名校验);
  2. 排空环境工作队列(work queue),让单次投递也能恢复此前错过的所有工作项;
  3. 对每个工作项,启动一个按会话(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.0ant CLI 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 SandboxContainersrc/container.ts:拥有 Cloudflare 容器的生命周期。它按会话 ID 作为唯一键,负责启动容器、持续续约 sleepAfter,避免 CF 把一个仍在运行的会话容器回收掉。
  • 容器内 CLI(ant beta:worker run:拥有会话的业务生命周期——心跳、积压对账(backlog reconcile)、SSE 事件流、默认工具集、空闲(idle)策略,以及退出时对工作项的 force-stop。

这种「Worker 只管唤醒、DO 只管容器存活、CLI 只管会话业务」的分层,让每一层的职责都能独立演进,也天然回答了「为什么容器的存活由 DO 管理,而会话的空闲退出却由 CLI 决定」这类问题。

为什么需要三层而不是两层?

可以反推其动机:

  • Webhook 必须快速响应:Worker 的 fetch handler 有响应时限,不能长期阻塞在轮询上(见下文排空队列的 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 });
}

要点如下:

  1. unwrap() 完成 Webhook 签名校验。校验失败立即返回 401,连业务逻辑都不进入;
  2. 只处理 session.status_run_startedevent.data.type !== "session.status_run_started" 时直接返回 { status: "ignored" },其它事件一律忽略;
  3. Webhook 只是「唤醒信号」——代码注释原话是 "The webhook is a wake-up signal only"。它并不针对单个事件做精确处理,而是触发一次全量排空(见下一节),因此即使有若干 webhook 投递失败/迟到,任何一次成功投递都能把积压工作补齐。这是该实现在「at-least-once 投递」下保持自愈的关键设计。

依据 SDK 注释,anthropic.beta.webhooks.unwrap 对应 HTTP 头约束 anthropic-version: 2023-06-01anthropic-beta: managed-agents-2026-04-01(详见 managed_agents/self_hosted_sandboxes/docs/usage-guide.md)。

排空工作队列:一次投递恢复所有遗漏

drainWorksrc/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 错误的 statusrequestID,让 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"]

传给容器的环境变量契约

dispatchstart() 传入的 envVarssrc/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:两道不同语义的「超时」

这是最容易混淆的部分,注意区分两把计时器:

  1. CLI 的空闲策略:容器入口命令带 --max-idle 60s——在收到 session.status_idlestop_reason: end_turn)后 60 秒退出;其它任何事件都会重置计时。它管的是「会话业务结束」,决定容器里的进程何时自愿退出。
  2. 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_terminatedsession.deleted,就主动 this.stop() 停掉容器;网络瞬时断开则按 1s 起步、指数翻倍至 10s 封顶的退避策略重连,onStop() 里会 abort 当前的 watch 控制器,避免容器已停止还挂着循环。

容器内入口:ant beta:worker run 与 Dockerfile

容器的 Dockerfiledebian: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 devwrangler dev)、npm run deploynpm run typechecktsc --noEmit),方便本地联调与类型把关。

wrangler.toml[vars] 与密钥的分工非常清晰:

  • 明文 varsANTHROPIC_BASE_URL = "https://api.anthropic.com" 与占位的 ANTHROPIC_ENVIRONMENT_ID = "env_01..."(需替换成 Console → Environments 中自托管环境的真实 ID);
  • 密钥 secretsANTHROPIC_WEBHOOK_SECRETANTHROPIC_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,它只用寥寥数段就概括了整个系统的骨架,其背后的可迁移经验值得总结:

  1. Webhook 只做唤醒,恢复靠排空——把「精确处理每条投递」降级为「每次唤醒后全量对账」,天然容忍投递丢失/乱序,是事件驱动系统里简单有效的自愈范式;
  2. 生命周期按层归属——CLI 管会话业务(idle/心跳/force-stop),DO 管容器存活(sleepAfter 续约),互不越权,且各有兜底(DO 的 5m backstop + 事件流断开指数退避);
  3. 凭据最小化与日志卫生——执行环境只持 environment key,错误信息过 redact(),业务日志过字段白名单;
  4. 一份契约、多平台落地——runner 固定为 ant beta:worker run,控制面(webhook/队列/DO)才随平台变化,这让自托管沙箱能低成本地迁移到任何能跑容器的计算平台。

若需把同样的沙箱方案落到其它厂商,核心思路不变:先按 usage-guide 建好 self-hosted environment、注册 webhook、注入 environment key,再把本文的三层控制面替换为对应平台的「触发器 + 会话键控的调度器 + 每会话执行器」即可。

登录后查看全文
热门项目推荐
相关项目推荐