第 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,共享同一个
pandasDataFrame,而不是每段都重新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 的容器边界
方案选择
| 方案 | 适合什么 | 用吗 |
|---|---|---|
手搓 Container + Hono /exec | 有特殊系统依赖、不想吃 Cloudflare 锁 | 不用,见上面 250 行的代价 |
Workers Code Mode(createExecuteTool) | LLM 写一段 JS、单步、纯 V8 沙箱 | 不用,跑不了 npm install 这类需要真文件系统的事(Ch8 我们才用它) |
| Cloudflare Sandboxes | agent 要 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 });
// },
// };
}
Sandbox 是 Container 的 DO 子类。outboundByHost 是 GA 后的“凭证注入“机制 —— sandbox 内部的任何进程(curl、git、pip)对 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"] }
]
}
实测要点(踩出来的):
- 本地必须装 Docker(或 OrbStack),wrangler deploy 会调本地 Docker CLI build & push 镜像到 Cloudflare 的 registry。Docker daemon 没起 wrangler 直接报
The Docker CLI could not be launched。 - 首次部署 + 首次冷启动各等一次。部署阶段 wrangler 拉 base image + push 到 Cloudflare 大概 30s-2min;部署成功后第一次 curl 调 sandbox,Cloudflare 还要 2-3 分钟把 Firecracker microVM 起起来,期间会 504/超时。
instance_type: "lite"是免费 tier 唯一选项,提供约 256MB RAM + 单 vCPU,跑bun/python3/bash完全够;要更大资源在 dashboard 升级 plan。- 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 不要无脑回填到 LLM。
npm install一次几千行,塞进消息历史会瞬间烧 context。runShell工具里截断到 4 KB 是底线,真要细看让 LLM 调专门的tail工具或grep。
延伸阅读
下一章预告
agent 现在有手了,但 sandbox 一睡 /workspace 就空。下一章我们用 Artifacts(Git for agents)+ R2 把 agent 的产物留下来:每个会话一个 git 远端,改一行就 commitToArtifact,大文件丢 R2,跨 sandbox / 跨会话都能找回。