Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第 6 章:能动手 — Cloudflare Sandboxes(GA)

一句话定位:你会给 agent 配一台真 Linux —— 能 git clone、能 npm install、能跑长进程、能拿到预览 URL,而你只动 SDK,不写一行 Dockerfile。

想要什么

走到这里,你的 agent 会聊天、有记忆、能调工具、懂规矩,但还有一道墙:它没有手

具体的痛点:

  • 用户贴一个 GitHub 链接说“帮我看看这个项目能不能跑“。你希望 agent 真的把仓库 clone 下来,跑一次 npm install && npm test,把红色的 stack trace 读回来 —— 而不是基于文件名瞎猜。
  • 用户问“这个 React 改完之后长什么样?“。你希望 agent 起一个 npm run dev,把 localhost:3000 暴露成一个公网链接,直接发给用户预览。
  • 用户传来一个 CSV 说“按 region 算一下平均利润率“。你希望 agent 写两段 Python,共享同一个 pandas DataFrame,而不是每段都重新 read_csv

这些都不是“能不能调到 LLM“的问题,而是 agent 有没有一台属于它自己的电脑的问题。

为什么

v1 版的这一章,我们手搓过一个方案:写一份 Dockerfile,塞一个 Hono server 在里面,暴露 /exec/git/clone/files 这些 HTTP 端点,在 Worker 里通过 Container.containerFetch() 跨进程调它。光这一套底子就 200 多行,还没算端口暴露、长进程管理、PTY 这些。

更糟的是,每一次 agent 想跑个 npm run dev 看预览,你都得自己拉一遍 cloudflared 隧道;每一次想给容器塞 GitHub Token,都得自己写 secret 管理逻辑。这些活既没乐趣,也没差异化。

Cloudflare Sandboxes(@cloudflare/[email protected],2026-04-13 GA)就是 Cloudflare 替我们把这一摞全做了:base image 内置 Node / Python / git / 常见工具链,SDK 一行 getSandbox(env.Sandbox, id) 就拿到一台带身份的 Linux,exec / gitCheckout / startProcess / exposePort / terminal / runCode / watch 全是一等公民方法。我们这一章把 v1 的手搓全删了,只用 SDK。

图 6-1:v1 vs v2 的容器边界

v1 自己造,v2 用 Sandbox SDK 同一件事,你写多少代码 v1:你自己造 - Dockerfile(node + python + git) - server.js(Hono /exec /files) - Container DO 子类 - Container.containerFetch() - 端口转发 / cloudflared - 自己管 stdin/stdout 流 - 凭证手动注入到 env 大约 250 行 + 一份 Dockerfile v2:Sandbox SDK class CoderSandbox extends Sandbox static outboundByHost = {...} getSandbox(env.Sandbox, id) .gitCheckout(url) .exec("npm", ["test"]) .startProcess("npm run dev") .exposePort(3000) → URL 大约 30 行,镜像由 SDK 提供

方案选择

方案适合什么用吗
手搓 Container + Hono /exec有特殊系统依赖、不想吃 Cloudflare 锁不用,见上面 250 行的代价
Workers Code Mode(createExecuteTool)LLM 写一段 JS、单步、纯 V8 沙箱不用,跑不了 npm install 这类需要真文件系统的事(Ch8 我们才用它)
Cloudflare Sandboxesagent 要 git / 真 Linux / 长进程 / PTY / 预览 URL
E2B / Daytona / 本地 Docker不在 Cloudflare 内、要跑你自己的 base image不用,我们已经全栈在 Cloudflare 上

Sandboxes 在 2026-04 是 GA(blog sandbox-ga)。Workers Paid 计划可用,定价走 Active CPU(只算活跃 CPU,等 LLM 时不烧钱)。

落地

装 SDK

# Terminal
npm install @cloudflare/sandbox

这里没有 Dockerfile 也没有 server.js —— Sandbox SDK 自己提供了 base image(Node 22 + Python 3 + git + 常见工具),通过 wrangler 的 containers.image 字段直接引用 SDK 内置路径。v1 那一摞自定义镜像,全删

写 Sandbox 子类

// src/sandbox.ts
import { Sandbox } from "@cloudflare/sandbox";

// Sandbox 跟 agent 共用 Cloudflare.Env(wrangler 自动生成)
// 后面 ch09 接 GitHub 时会在 outboundByHost 里用 GITHUB_TOKEN
type SandboxEnv = Cloudflare.Env;

export class CoderSandbox extends Sandbox<SandboxEnv> {
  // 这里以后可以挂 outboundByHost,把 host -> 注入凭证的 fetcher 写成静态属性
  // (Cloudflare Sandbox 0.9.x 的"凭证只活在 Worker 一侧"机制,详见 sandbox-auth blog)
  // 例如(等到 ch09 加 GITHUB_TOKEN 后):
  // static outboundByHost = {
  //   "api.github.com": (req: Request, env: SandboxEnv) => {
  //     const headers = new Headers(req.headers);
  //     headers.set("authorization", `Bearer ${env.GITHUB_TOKEN}`);
  //     return fetch(req, { headers });
  //   },
  // };
}

SandboxContainer 的 DO 子类。outboundByHost 是 GA 后的“凭证注入“机制 —— sandbox 内部的任何进程(curlgitpip)对 api.github.com 发请求,出口代理就替它补上 Authorization 头。LLM 写出的代码再“聪明“,也读不到 env.GITHUB_TOKEN

blog sandbox-auth 给的字段名是 outboundByHost,但 0.9.2 的 d.ts 没显式标这个属性 —— 它在 Container 父类的运行时反射里。如果你的 IDE 报红,装 @cloudflare/sandbox@latest 让类型补齐,或临时加 // @ts-expect-error

写一个一行 Dockerfile + 配 wrangler.jsonc

@cloudflare/sandbox 在 npm 里附带的 Dockerfile 是它自己 monorepo 源码构建用的(turbo prune ...),end-user 直接指过去 docker build 会因为缺 packageManager 字段而报错。真正用法是写一个 1 行 Dockerfile 从 docker hub 拉预构建镜像:

# Dockerfile(项目根目录)
FROM docker.io/cloudflare/sandbox:0.9.2

EXPOSE 8080

然后 wrangler.jsonc:

// wrangler.jsonc(ch04 基础上加 containers + 第二个 DO)
{
  "containers": [
    {
      "class_name": "CoderSandbox",
      "image": "./Dockerfile",
      "instance_type": "lite",      // lite/small/medium,免费 tier 用 lite
      "max_instances": 1            // demo 用 1 个,生产按并发量调
    }
  ],
  "durable_objects": {
    "bindings": [
      { "class_name": "CoderAgent", "name": "CODER_AGENT" },
      { "class_name": "CoderSandbox", "name": "Sandbox" }
    ]
  },
  "migrations": [
    { "tag": "v1", "new_sqlite_classes": ["CoderAgent"] },
    { "tag": "v2", "new_sqlite_classes": ["CoderSandbox"] }
  ]
}

实测要点(踩出来的):

  1. 本地必须装 Docker(或 OrbStack),wrangler deploy 会调本地 Docker CLI build & push 镜像到 Cloudflare 的 registry。Docker daemon 没起 wrangler 直接报 The Docker CLI could not be launched
  2. 首次部署 + 首次冷启动各等一次。部署阶段 wrangler 拉 base image + push 到 Cloudflare 大概 30s-2min;部署成功后第一次 curl 调 sandbox,Cloudflare 还要 2-3 分钟把 Firecracker microVM 起起来,期间会 504/超时。
  3. instance_type: "lite" 是免费 tier 唯一选项,提供约 256MB RAM + 单 vCPU,跑 bun/python3/bash 完全够;要更大资源在 dashboard 升级 plan。
  4. Sandbox 是独立 DO 类,单独 migration tag(v2),不能跟 CoderAgent 写一起。

在 worker 入口透传预览 URL

// src/index.ts(在 Ch1 基础上加)
import { routeAgentRequest } from "agents";
import { proxyToSandbox } from "@cloudflare/sandbox";

export { CoderAgent } from "./agent";
export { CoderSandbox } from "./sandbox";

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    // sandbox 暴露的端口走 *.sandbox.<your-domain> 子域名
    const proxied = await proxyToSandbox(request, env);
    if (proxied) return proxied;

    return (
      (await routeAgentRequest(request, env)) ??
      new Response("Not found", { status: 404 })
    );
  },
};

proxyToSandbox 一行,后面 exposePort 给出来的 URL 就能在浏览器打开了。

暴露给 LLM 的工具:runShell

// src/tools/shell.ts
import { tool } from "ai";
import { z } from "zod";
import { getSandbox } from "@cloudflare/sandbox";
import type { Env } from "../agent";

// 工厂签名显式吃 (env, sessionId) —— Agent 子类的 env 是 protected,
// 所以从外部代码不能写 agent.env.X,得让调用方(在类内部)把 this.env 传进来。
export function createShellTools(env: Env, sessionId: string) {
  const sb = getSandbox(env.Sandbox, sessionId);

  return {
    runShell: tool({
      description: "在 sandbox 里跑一条命令,返回 stdout / stderr / exitCode",
      inputSchema: z.object({
        cmd: z.string().describe("可执行文件,如 'npm' 'node' 'git'"),
        args: z.array(z.string()).default([]),
        cwd: z.string().default("/workspace"),
      }),
      execute: async ({ cmd, args, cwd }) => {
        // sandbox.exec 现在只接受 (command: string, options?) —— 把 args 拼回字符串
        const command = args.length ? `${cmd} ${args.join(" ")}` : cmd;
        const r = await sb.exec(command, { cwd });
        return {
          stdout: r.stdout.slice(0, 4000),
          stderr: r.stderr.slice(0, 2000),
          exitCode: r.exitCode,
        };
      },
    }),
  };
}

把这一组工具挂到 getTools()(完整文件,在 Ch4 的基础上加 Sandbox binding 与 createShellTools):

// src/agent.ts
import { Think } from "@cloudflare/think";
import { createWorkersAI } from "workers-ai-provider";
import { buildExecuteTool } from "./tools/execute";
import { getCurrentTime, getWeather } from "./tools/index";
import { createShellTools } from "./tools/shell";
import type { CoderSandbox } from "./sandbox";

export type Env = {
  AI: Ai;
  CODER_AGENT: DurableObjectNamespace<CoderAgent>;
  LOADER: WorkerLoader;
  Sandbox: DurableObjectNamespace<CoderSandbox>;
};

export class CoderAgent extends Think<Env> {
  getModel() {
    const wai = createWorkersAI({ binding: this.env.AI });
    return wai("@cf/moonshotai/kimi-k2.5");
  }

  getSystemPrompt() {
    return [
      "你是一个编程助手。",
      "对于多步、组合性的任务(grep + 统计、批量改写、跨文件分析)优先调 execute,",
      "在沙盒里写一段 JS 完成。简单查询(时间、天气)直接调对应 tool。",
      "碰到环境探查类问题,直接调 runShell,不要先描述计划。",
    ].join("\n");
  }

  getTools() {
    return {
      execute: buildExecuteTool(this.workspace, this.env.LOADER),
      getCurrentTime,
      getWeather,
      // env 是 protected,从外部代码不能直接 agent.env.X;
      // 在类内部把 (this.env, this.name) 显式传给 factory
      ...createShellTools(this.env, this.name),
    };
  }
}

agent.name 是 Think 自动维护的 DO 实例 id —— 我们直接拿它当 sandbox id,一个对话 = 一个 sandbox,后续所有调用都路由到同一台机器,文件、安装好的依赖、跑着的进程都还在。

演示:Sandbox 的五件事

下面五段都假定你已经在 agent 里、能拿到 sb = getSandbox(this.env.Sandbox, this.name)。先 clone 再做事。

// 1. clone 一个公开仓库到 /workspace
await sb.gitCheckout("https://github.com/cloudflare/workers-sdk", {
  targetDir: "/workspace",
  depth: 1,
});

// 2. 跑测试,流式拿 stdout
const stream = await sb.execStream("npm", ["test"], { cwd: "/workspace" });
for await (const chunk of stream) {
  // chunk 是 Uint8Array,推到前端 / 写日志
}

// 3. 起 dev server,等真出 ready 信号,再暴露端口
const server = await sb.startProcess("npm run dev", { cwd: "/workspace" });
await server.waitForLog(/Local:.*localhost:(\d+)/);
const { url } = await sb.exposePort(3000, { hostname: "preview.example.com" });
// url 就是公网可访问的预览链接

// 4. 持久 Python 解释器,context 跨调用共享变量
const ctx = await sb.createCodeContext({ language: "python" });
await sb.runCode(`
  import pandas as pd
  df = pd.read_csv('/workspace/sales.csv')
`, { context: ctx });
const r = await sb.runCode(
  `df.groupby('region')['margin'].mean().to_json()`,
  { context: ctx },
);
// r.text 是 JSON 字符串,df 还在内存里

// 5. SSE 监听文件变化,触发重跑
const watch = await sb.watch("/workspace/src", {
  recursive: true,
  include: ["*.ts"],
});
// watch 是 ReadableStream<FileWatchSSEEvent>,for await 消费即可

PTY(sandbox.terminal(request, { cols, rows }))和这些是同等公民 —— 当你想把 xterm.js 接到前端、让用户在浏览器里直接跟 sandbox shell 对话时,把 WebSocket 升级请求转过去就行。这一章不细展开,真要做的话照 Sandbox docs/terminal 例子抄一遍,十几行。

验证

让 agent 在 sandbox 里报告自己的工具链,顺手 clone 一个仓库再 ls

# Terminal
npx wrangler dev

新开一个终端,WebSocket 连过去:

# Terminal
npx wscat -c ws://localhost:8787/agents/coder-agent/demo
> {"type":"cf_agent_use_chat_request","init":{"messages":[{"role":"user","parts":[{"type":"text","text":"请用 runShell 工具跑 node -v、python3 --version、git --version,然后 git clone https://github.com/cloudflare/workers-sdk 到 /workspace,再 ls /workspace/workers-sdk"}]}]}}

观察 SSE 流里 tool-result 的内容,你应该看到类似:

node -v       → v22.x.x
python3 -V    → Python 3.11.x
git --version → git version 2.40.x
ls /workspace/workers-sdk → packages/  README.md  pnpm-workspace.yaml ...

如果 LLM 在第一次 runShell 之前就先写了“我会用 sandbox 跑…“然后停住,通常是 system prompt 没鼓励它直接开干 —— 在 getSystemPrompt() 里加一句“碰到环境探查类问题,直接调 runShell,不要先描述计划”。

边界与坑

  • sandbox id 强绑会话getSandbox(env.Sandbox, X) 同样的 X 拿到的是同一台机器。误传一个新 id 等于开了新 sandbox,文件全丢,而且账上多算并发。永远用 this.name(Think 给的 DO 实例 id)。
  • sleepAfter 默认 10 分钟。idle 之后容器睡眠,内存里的进程会丢。重要的中间产物要么 commit 到 Artifacts(下一章),要么 PUT 到 R2,要么用 createBackup 落盘。
  • 凭证只能从 Worker 一侧注入outboundByHost 是设计成 sandbox 内进程拿不到 raw secret,这是特性不是 bug。LLM 想拿 token 只能让你“工具地写到环境变量“,不要这么干
  • exposePort 需要域名。本地 dev 模式给一个 *.localhost 的预览地址即可;线上要么自定义域,要么用 workers.dev。详见 Preview URLs
  • stdout 不要无脑回填到 LLMnpm install 一次几千行,塞进消息历史会瞬间烧 context。runShell 工具里截断到 4 KB 是底线,真要细看让 LLM 调专门的 tail 工具或 grep

延伸阅读

下一章预告

agent 现在有手了,但 sandbox 一睡 /workspace 就空。下一章我们用 Artifacts(Git for agents)+ R2 把 agent 的产物留下来:每个会话一个 git 远端,改一行就 commitToArtifact,大文件丢 R2,跨 sandbox / 跨会话都能找回。