在本教程中,你将构建一个 Worker,在 Linux sandbox 中对 GitHub 仓库运行编程 Agent。你发送提示,Agent 在 sandbox 内编辑仓库,然后你将其更改作为 diff 读取。该 runner 使用 Claude Code ↗。编程 Agent 中的页面可将其切换为其他 Agent。
Claude Code 通过 AI Gateway 调用 Anthropic 模型。你的 Worker 会为这些请求添加 gateway token,因此 token 永远不会进入 sandbox。sandbox 只能访问你的 gateway 和 github.com。
完成后,你可以让 Agent 向仓库添加文件,并读取其更改:
diff --git a/NOTES.md b/NOTES.md
new file mode 100644
index 0000000..95da11e
--- /dev/null
+++ b/NOTES.md
@@ -0,0 +1 @@
+This repository is a minimal test/example repo containing only a "Hello World!" README file.你将学习如何:
- 在 Agent 于 sandbox 中运行时,将模型凭据保留在 Worker 中。
- 仅允许 sandbox 访问任务所需的主机名。
- 在后台运行持续数分钟的任务,并在任务结束前保持 sandbox 运行。
- 在任务结束后读取 Agent 的结果和更改。
- 注册 Cloudflare 账户 ↗。
- 安装
Node.js↗。
当您运行 wrangler deploy 时,必须在本地运行 Docker。对于大多数人来说,安装 Docker 的最佳方式是按照安装 Docker Desktop 的文档 ↗进行操作。其他工具(如 Colima ↗)也可能有效。
您可以通过在终端中运行 docker info 命令来检查 Docker 是否正常运行。如果 Docker 正在运行,该命令将成功执行。如果 Docker 未运行,docker info 命令将挂起或返回包含 "Cannot connect to the Docker daemon" 消息的错误。
你还需要:
- 一个经过身份验证的 AI Gateway,以及具有 Run 权限的 gateway token。
- AI Gateway 中的 Anthropic 凭据,可以是 Unified Billing 额度,或作为 provider key 存储的 Anthropic API key。
- 你的 Cloudflare 账户 ID。
-
创建一个 Worker 项目:
npm create cloudflare@latest -- sandbox-coding-agent --category=hello-world --type=hello-world --lang=ts --no-deploy --no-git --no-agentsyarn create cloudflare sandbox-coding-agent --category=hello-world --type=hello-world --lang=ts --no-deploy --no-git --no-agentspnpm create cloudflare@latest sandbox-coding-agent --category=hello-world --type=hello-world --lang=ts --no-deploy --no-git --no-agents -
进入项目目录:
cd sandbox-coding-agent -
安装
@cloudflare/sandbox包和 Zod ↗:npm i @cloudflare/sandbox zodyarn add @cloudflare/sandbox zodpnpm add @cloudflare/sandbox zodbun add @cloudflare/sandbox zod -
在项目根目录创建
Dockerfile:Dockerfiledockerfile FROM node:24-trixie-slim RUN apt-get update \ && apt-get install -y --no-install-recommends bash ca-certificates git ripgrep \ && rm -rf /var/lib/apt/lists/* # The install script from the Claude Code package puts its native binary in place RUN npm install --global @anthropic-ai/[email protected] COPY --from=docker.io/cloudflare/sandbox:1.0.1 /usr/local/bin/sandbox-shim /usr/local/bin/sandbox-shim WORKDIR /workspace # Keep the container running between requests CMD ["sleep", "infinity"]在镜像中安装 Agent 所需的一切,因为 sandbox 无法在运行时下载包。
请固定 Claude Code 版本。其命令行标志和事件格式可能在版本之间变化。使用与你安装的
@cloudflare/sandbox版本匹配的cloudflare/sandbox标签。 -
替换
wrangler.jsonc。将<ACCOUNT_ID>替换为你的账户 ID;若 gateway ID 不是default,也请一并替换:{ "$schema": "node_modules/wrangler/config-schema.json", "name": "sandbox-coding-agent", "main": "src/index.ts", // Set this to today's date "compatibility_date": "2026-11-14", "compatibility_flags": ["nodejs_compat"], "observability": { "enabled": true, }, "upload_source_maps": true, "vars": { "AI_GATEWAY_ACCOUNT_ID": "<ACCOUNT_ID>", "AI_GATEWAY_ID": "default", "MODEL": "claude-sonnet-5", }, "secrets": { "required": ["AI_GATEWAY_TOKEN"], }, "containers": [ { "class_name": "AgentSandbox", "scheduling_policy": "durable_object", "images": { "agent": { "dockerfile": "./Dockerfile", }, }, }, ], "durable_objects": { "bindings": [ { "class_name": "AgentSandbox", "name": "SANDBOX", }, ], }, "exports": { "AgentSandbox": { "type": "durable-object", "storage": "sqlite", }, }, }"$schema" = "node_modules/wrangler/config-schema.json" name = "sandbox-coding-agent" main = "src/index.ts" # Set this to today's date compatibility_date = "2026-11-14" compatibility_flags = [ "nodejs_compat" ] upload_source_maps = true [observability] enabled = true [vars] AI_GATEWAY_ACCOUNT_ID = "<ACCOUNT_ID>" AI_GATEWAY_ID = "default" MODEL = "claude-sonnet-5" [secrets] required = [ "AI_GATEWAY_TOKEN" ] [[containers]] class_name = "AgentSandbox" scheduling_policy = "durable_object" [containers.images.agent] dockerfile = "./Dockerfile" [[durable_objects.bindings]] class_name = "AgentSandbox" name = "SANDBOX" [exports.AgentSandbox] type = "durable-object" storage = "sqlite"每个 sandbox 都是带有自己容器的
AgentSandboxDurable Object。部署时 Wrangler 会构建Dockerfile,Durable Object 将构建的镜像作为this.ctx.container.images.agent启动。MODEL是你的 gateway 可以提供的 Anthropic 模型 ID。若缺少 gateway token,secrets.required会使wrangler deploy失败。 -
为绑定、变量和 secret 生成类型:
npx wrangler typesyarn wrangler typespnpm wrangler types
接下来的三节中,你将创建 src/outbound.ts 和 src/sandbox.ts,并替换 src/index.ts。本教程会添加或替换这些文件:
- Dockerfile
- wrangler.jsonc
- src
- outbound.ts
- sandbox.ts
- index.ts
创建 src/outbound.ts。sandbox 会将端口 80 上的每个 HTTP 请求和端口 443 上的每个 HTTPS 请求通过 Worker 中的此入口点发送。没有 Internet 访问时,sandbox 无法访问其他端口:
import { WorkerEntrypoint } from "cloudflare:workers";
const gatewayHost = "gateway.ai.cloudflare.com";
export class Outbound extends WorkerEntrypoint {
async fetch(request) {
const url = new URL(request.url);
const gatewayPath = `/v1/${this.env.AI_GATEWAY_ACCOUNT_ID}/${this.env.AI_GATEWAY_ID}`;
if (url.protocol !== "https:") {
return new Response(`${url.hostname} is reachable only over HTTPS\n`, {
status: 403,
});
}
if (
url.hostname === gatewayHost &&
(url.pathname === gatewayPath ||
url.pathname.startsWith(`${gatewayPath}/`))
) {
const headers = new Headers(request.headers);
headers.delete("x-api-key");
headers.set(
"cf-aig-authorization",
`Bearer ${this.env.AI_GATEWAY_TOKEN}`,
);
return fetch(new Request(request, { headers }));
}
if (url.hostname === "github.com") {
return fetch(request);
}
return new Response(
`${url.hostname} is not reachable from this sandbox\n`,
{ status: 403 },
);
}
}import { WorkerEntrypoint } from "cloudflare:workers";
const gatewayHost = "gateway.ai.cloudflare.com";
export class Outbound extends WorkerEntrypoint<Env> {
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
const gatewayPath = `/v1/${this.env.AI_GATEWAY_ACCOUNT_ID}/${this.env.AI_GATEWAY_ID}`;
if (url.protocol !== "https:") {
return new Response(`${url.hostname} is reachable only over HTTPS\n`, {
status: 403,
});
}
if (
url.hostname === gatewayHost &&
(url.pathname === gatewayPath ||
url.pathname.startsWith(`${gatewayPath}/`))
) {
const headers = new Headers(request.headers);
headers.delete("x-api-key");
headers.set(
"cf-aig-authorization",
`Bearer ${this.env.AI_GATEWAY_TOKEN}`,
);
return fetch(new Request(request, { headers }));
}
if (url.hostname === "github.com") {
return fetch(request);
}
return new Response(
`${url.hostname} is not reachable from this sandbox\n`,
{ status: 403 },
);
}
}该入口点允许两个目标:
- 发往你账户 ID 和 gateway ID 下的 gateway 的请求。Worker 会移除 Claude Code 发送的占位 API key,并添加 gateway token。随后 AI Gateway 使用其持有的凭据调用 Anthropic。若占位符保留,AI Gateway 会将其转发给 Anthropic,模型请求会失败。
- 发往
github.com的请求,以便 Git 克隆公共仓库。
该入口点仅允许通过 HTTPS 访问这两个目标。Worker 会使用容器所用的协议发起 fetch,因此对 gateway 的明文 HTTP 请求会以未加密方式发送 gateway token。其他每个请求都会得到 403 响应,因此 Claude Code 在工作时无法从 npm 安装包或抓取网页。
要允许另一个目标,请将其添加到此入口点。你添加的每个目标都是数据离开 sandbox 的另一条路径。更多信息,请参阅 Sandbox 安全。
创建 src/sandbox.ts。AgentSandbox Durable Object 会克隆仓库、一次运行一个 Claude Code 任务,并报告任务状态和仓库 diff:
import { Files, SandboxFileError } from "@cloudflare/sandbox";
import { DurableObject } from "cloudflare:workers";
import { z } from "zod";
const repositoryDirectory = "/workspace/repo";
const taskDirectory = "/workspace/task";
const eventsPath = `${taskDirectory}/stdout.log`;
const stderrPath = `${taskDirectory}/stderr.log`;
const exitCodePath = `${taskDirectory}/exit-code`;
const pidPath = `${taskDirectory}/pid`;
const inactivityTimeout = 30 * 60 * 1000;
const taskCheckInterval = 60 * 1000;
const taskKey = "task";
const caPath = "/etc/cloudflare/certs/cloudflare-containers-ca.crt";
// The CA alone replaces the system trust store. This works because
// Outbound intercepts every HTTPS request from the container.
const trustEnv = {
NODE_EXTRA_CA_CERTS: caPath,
GIT_SSL_CAINFO: caPath,
CURL_CA_BUNDLE: caPath,
SSL_CERT_FILE: caPath,
};
// Runs the command in its own process group. Records its process ID when
// it starts and its exit code when it ends.
const taskScript = `dir=$1; shift
setsid sh -c 'echo "$$ $(cat /proc/sys/kernel/random/boot_id)" >"$0/pid"; exec "$@"' \\
"$dir" "$@" >"$dir/stdout.log" 2>"$dir/stderr.log"
echo "$?" >"$dir/exit-code.tmp" && mv "$dir/exit-code.tmp" "$dir/exit-code"`;
// Succeeds while the process in the file runs and started in this instance.
const taskRunningScript = `read -r pid boot <"$1" &&
[ "$boot" = "$(cat /proc/sys/kernel/random/boot_id)" ] &&
kill -0 "$pid"`;
const ResultEvent = z.object({
type: z.literal("result"),
subtype: z.string(),
is_error: z.boolean(),
result: z.string().optional(),
});
export class AgentSandbox extends DurableObject {
container;
files;
setup;
constructor(ctx, env) {
super(ctx, env);
const container = ctx.container;
if (!container) {
throw new Error("No container is configured");
}
this.container = container;
this.files = new Files(container);
if (container.running) {
void ctx.blockConcurrencyWhile(() =>
container.setInactivityTimeout(inactivityTimeout),
);
}
}
async cloneRepository(repository, ref) {
await this.startSandbox();
const branch = ref === undefined ? [] : ["--branch", ref];
return this.run(
[
"git",
"clone",
"--depth",
"1",
...branch,
"--",
repository,
repositoryDirectory,
],
"/workspace",
trustEnv,
);
}
startTask(prompt) {
return this.ctx.blockConcurrencyWhile(async () => {
await this.startSandbox();
if ((await this.taskStatus()).state === "running") {
return "busy";
}
await this.files.remove(taskDirectory, { recursive: true, force: true });
await this.files.mkdir(taskDirectory);
await this.container.exec(
[
"/bin/sh",
"-c",
taskScript,
"agent",
taskDirectory,
...this.agentCommand(prompt),
],
{
cwd: repositoryDirectory,
env: { ...trustEnv, ...this.agentEnv() },
stdout: "ignore",
stderr: "ignore",
},
);
this.ctx.storage.kv.put(taskKey, "started");
await this.ctx.storage.setAlarm(Date.now() + taskCheckInterval);
return "started";
});
}
async alarm() {
if (!this.container.running) {
return;
}
if ((await this.taskStatus()).state === "running") {
await this.ctx.storage.setAlarm(Date.now() + taskCheckInterval);
return;
}
this.ctx.storage.kv.delete(taskKey);
}
async readTask() {
if (!this.container.running) {
return this.ctx.storage.kv.get(taskKey) === undefined
? { state: "none" }
: { state: "lost" };
}
return this.taskStatus();
}
async readDiff() {
await this.startSandbox();
return this.run(
["/bin/sh", "-c", "git add --intent-to-add . && git diff HEAD"],
repositoryDirectory,
{},
);
}
agentCommand(prompt) {
return [
"claude",
"--print",
"--output-format",
"stream-json",
"--verbose",
"--dangerously-skip-permissions",
"--no-session-persistence",
"--model",
this.env.MODEL,
"--",
prompt,
];
}
agentEnv() {
return {
ANTHROPIC_BASE_URL: `https://gateway.ai.cloudflare.com/v1/${this.env.AI_GATEWAY_ACCOUNT_ID}/${this.env.AI_GATEWAY_ID}/anthropic`,
ANTHROPIC_API_KEY: "provided-by-worker",
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
IS_SANDBOX: "1",
};
}
async startSandbox() {
// Set up each new container, and a running container
// after this Durable Object restarts.
if (this.setup === undefined || !this.container.running) {
this.setup = this.setUpSandbox().catch((error) => {
this.setup = undefined;
throw error;
});
}
await this.setup;
}
async setUpSandbox() {
if (!this.container.running) {
this.ctx.storage.kv.delete(taskKey);
this.container.start({
image: this.container.images.agent,
instance: "standard-1",
enableInternet: false,
});
}
try {
await this.container.interceptAllOutboundHttp(this.ctx.exports.Outbound);
await this.container.interceptOutboundHttps(
"*",
this.ctx.exports.Outbound,
);
await this.container.setInactivityTimeout(inactivityTimeout);
} catch (error) {
// The next request starts a new container.
await this.container.destroy();
throw error;
}
}
async taskStatus() {
const exitCode = await this.readOptionalText(exitCodePath);
if (exitCode !== undefined) {
return this.outcome(Number.parseInt(exitCode, 10));
}
const pid = await this.readOptionalText(pidPath);
if (pid === undefined) {
// The task writes its process ID right after it starts.
return this.ctx.storage.kv.get(taskKey) === undefined
? { state: "none" }
: { state: "running" };
}
const probe = await this.run(
["/bin/sh", "-c", taskRunningScript, "probe", pidPath],
"/",
{},
);
if (probe.exitCode === 0) {
return { state: "running" };
}
// The task can finish after the first read, so read the exit code
// again.
const lateExitCode = await this.readOptionalText(exitCodePath);
if (lateExitCode !== undefined) {
return this.outcome(Number.parseInt(lateExitCode, 10));
}
return { state: "lost" };
}
async outcome(exitCode) {
let result;
const events = await this.files.readFile(eventsPath);
for await (const line of readLines(events.body)) {
const parsed = ResultEvent.safeParse(parseJson(line));
if (parsed.success) {
result = parsed.data;
}
}
if (result === undefined) {
const stderr = (await this.readOptionalText(stderrPath)) ?? "";
return {
state: "failed",
error: `claude exited with ${exitCode}: ${stderr.slice(-2000)}`,
};
}
if (result.is_error) {
return { state: "failed", error: result.result ?? result.subtype };
}
return { state: "succeeded", result: result.result ?? "" };
}
async readOptionalText(path) {
try {
return await (await this.files.readFile(path)).text();
} catch (error) {
if (SandboxFileError.is(error) && error.code === "ENOENT") {
return undefined;
}
throw error;
}
}
async run(command, cwd, env) {
const process = await this.container.exec(command, { cwd, env });
const output = await process.output();
const decoder = new TextDecoder();
return {
exitCode: output.exitCode,
stdout: decoder.decode(output.stdout),
stderr: decoder.decode(output.stderr),
};
}
}
function parseJson(line) {
try {
return JSON.parse(line);
} catch {
return undefined;
}
}
async function* readLines(body) {
if (body === null) {
return;
}
const decoder = new TextDecoder();
let buffered = "";
for await (const chunk of body) {
buffered += decoder.decode(chunk, { stream: true });
const lines = buffered.split("\n");
buffered = lines.pop() ?? "";
yield* lines;
}
buffered += decoder.decode();
if (buffered !== "") {
yield buffered;
}
}import { Files, SandboxFileError } from "@cloudflare/sandbox";
import { DurableObject } from "cloudflare:workers";
import { z } from "zod";
const repositoryDirectory = "/workspace/repo";
const taskDirectory = "/workspace/task";
const eventsPath = `${taskDirectory}/stdout.log`;
const stderrPath = `${taskDirectory}/stderr.log`;
const exitCodePath = `${taskDirectory}/exit-code`;
const pidPath = `${taskDirectory}/pid`;
const inactivityTimeout = 30 * 60 * 1000;
const taskCheckInterval = 60 * 1000;
const taskKey = "task";
const caPath = "/etc/cloudflare/certs/cloudflare-containers-ca.crt";
// The CA alone replaces the system trust store. This works because
// Outbound intercepts every HTTPS request from the container.
const trustEnv = {
NODE_EXTRA_CA_CERTS: caPath,
GIT_SSL_CAINFO: caPath,
CURL_CA_BUNDLE: caPath,
SSL_CERT_FILE: caPath,
};
// Runs the command in its own process group. Records its process ID when
// it starts and its exit code when it ends.
const taskScript = `dir=$1; shift
setsid sh -c 'echo "$$ $(cat /proc/sys/kernel/random/boot_id)" >"$0/pid"; exec "$@"' \\
"$dir" "$@" >"$dir/stdout.log" 2>"$dir/stderr.log"
echo "$?" >"$dir/exit-code.tmp" && mv "$dir/exit-code.tmp" "$dir/exit-code"`;
// Succeeds while the process in the file runs and started in this instance.
const taskRunningScript = `read -r pid boot <"$1" &&
[ "$boot" = "$(cat /proc/sys/kernel/random/boot_id)" ] &&
kill -0 "$pid"`;
const ResultEvent = z.object({
type: z.literal("result"),
subtype: z.string(),
is_error: z.boolean(),
result: z.string().optional(),
});
export type TaskStatus =
| { state: "none" }
| { state: "running" }
| { state: "lost" }
| { state: "succeeded"; result: string }
| { state: "failed"; error: string };
export type CommandResult = {
exitCode: number;
stdout: string;
stderr: string;
};
export class AgentSandbox extends DurableObject<Env> {
private readonly container: Container;
private readonly files: Files;
private setup: Promise<void> | undefined;
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
const container = ctx.container;
if (!container) {
throw new Error("No container is configured");
}
this.container = container;
this.files = new Files(container);
if (container.running) {
void ctx.blockConcurrencyWhile(() =>
container.setInactivityTimeout(inactivityTimeout),
);
}
}
async cloneRepository(
repository: string,
ref: string | undefined,
): Promise<CommandResult> {
await this.startSandbox();
const branch = ref === undefined ? [] : ["--branch", ref];
return this.run(
[
"git",
"clone",
"--depth",
"1",
...branch,
"--",
repository,
repositoryDirectory,
],
"/workspace",
trustEnv,
);
}
startTask(prompt: string): Promise<"started" | "busy"> {
return this.ctx.blockConcurrencyWhile(async () => {
await this.startSandbox();
if ((await this.taskStatus()).state === "running") {
return "busy";
}
await this.files.remove(taskDirectory, { recursive: true, force: true });
await this.files.mkdir(taskDirectory);
await this.container.exec(
[
"/bin/sh",
"-c",
taskScript,
"agent",
taskDirectory,
...this.agentCommand(prompt),
],
{
cwd: repositoryDirectory,
env: { ...trustEnv, ...this.agentEnv() },
stdout: "ignore",
stderr: "ignore",
},
);
this.ctx.storage.kv.put(taskKey, "started");
await this.ctx.storage.setAlarm(Date.now() + taskCheckInterval);
return "started";
});
}
async alarm(): Promise<void> {
if (!this.container.running) {
return;
}
if ((await this.taskStatus()).state === "running") {
await this.ctx.storage.setAlarm(Date.now() + taskCheckInterval);
return;
}
this.ctx.storage.kv.delete(taskKey);
}
async readTask(): Promise<TaskStatus> {
if (!this.container.running) {
return this.ctx.storage.kv.get(taskKey) === undefined
? { state: "none" }
: { state: "lost" };
}
return this.taskStatus();
}
async readDiff(): Promise<CommandResult> {
await this.startSandbox();
return this.run(
["/bin/sh", "-c", "git add --intent-to-add . && git diff HEAD"],
repositoryDirectory,
{},
);
}
private agentCommand(prompt: string): string[] {
return [
"claude",
"--print",
"--output-format",
"stream-json",
"--verbose",
"--dangerously-skip-permissions",
"--no-session-persistence",
"--model",
this.env.MODEL,
"--",
prompt,
];
}
private agentEnv(): Record<string, string> {
return {
ANTHROPIC_BASE_URL: `https://gateway.ai.cloudflare.com/v1/${this.env.AI_GATEWAY_ACCOUNT_ID}/${this.env.AI_GATEWAY_ID}/anthropic`,
ANTHROPIC_API_KEY: "provided-by-worker",
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
IS_SANDBOX: "1",
};
}
private async startSandbox(): Promise<void> {
// Set up each new container, and a running container
// after this Durable Object restarts.
if (this.setup === undefined || !this.container.running) {
this.setup = this.setUpSandbox().catch((error) => {
this.setup = undefined;
throw error;
});
}
await this.setup;
}
private async setUpSandbox(): Promise<void> {
if (!this.container.running) {
this.ctx.storage.kv.delete(taskKey);
this.container.start({
image: this.container.images.agent,
instance: "standard-1",
enableInternet: false,
});
}
try {
await this.container.interceptAllOutboundHttp(this.ctx.exports.Outbound);
await this.container.interceptOutboundHttps(
"*",
this.ctx.exports.Outbound,
);
await this.container.setInactivityTimeout(inactivityTimeout);
} catch (error) {
// The next request starts a new container.
await this.container.destroy();
throw error;
}
}
private async taskStatus(): Promise<TaskStatus> {
const exitCode = await this.readOptionalText(exitCodePath);
if (exitCode !== undefined) {
return this.outcome(Number.parseInt(exitCode, 10));
}
const pid = await this.readOptionalText(pidPath);
if (pid === undefined) {
// The task writes its process ID right after it starts.
return this.ctx.storage.kv.get(taskKey) === undefined
? { state: "none" }
: { state: "running" };
}
const probe = await this.run(
["/bin/sh", "-c", taskRunningScript, "probe", pidPath],
"/",
{},
);
if (probe.exitCode === 0) {
return { state: "running" };
}
// The task can finish after the first read, so read the exit code
// again.
const lateExitCode = await this.readOptionalText(exitCodePath);
if (lateExitCode !== undefined) {
return this.outcome(Number.parseInt(lateExitCode, 10));
}
return { state: "lost" };
}
private async outcome(exitCode: number): Promise<TaskStatus> {
let result: z.infer<typeof ResultEvent> | undefined;
const events = await this.files.readFile(eventsPath);
for await (const line of readLines(events.body)) {
const parsed = ResultEvent.safeParse(parseJson(line));
if (parsed.success) {
result = parsed.data;
}
}
if (result === undefined) {
const stderr = (await this.readOptionalText(stderrPath)) ?? "";
return {
state: "failed",
error: `claude exited with ${exitCode}: ${stderr.slice(-2000)}`,
};
}
if (result.is_error) {
return { state: "failed", error: result.result ?? result.subtype };
}
return { state: "succeeded", result: result.result ?? "" };
}
private async readOptionalText(path: string): Promise<string | undefined> {
try {
return await (await this.files.readFile(path)).text();
} catch (error) {
if (SandboxFileError.is(error) && error.code === "ENOENT") {
return undefined;
}
throw error;
}
}
private async run(
command: string[],
cwd: string,
env: Record<string, string>,
): Promise<CommandResult> {
const process = await this.container.exec(command, { cwd, env });
const output = await process.output();
const decoder = new TextDecoder();
return {
exitCode: output.exitCode,
stdout: decoder.decode(output.stdout),
stderr: decoder.decode(output.stderr),
};
}
}
function parseJson(line: string): unknown {
try {
return JSON.parse(line);
} catch {
return undefined;
}
}
async function* readLines(
body: ReadableStream<Uint8Array> | null,
): AsyncGenerator<string> {
if (body === null) {
return;
}
const decoder = new TextDecoder();
let buffered = "";
for await (const chunk of body) {
buffered += decoder.decode(chunk, { stream: true });
const lines = buffered.split("\n");
buffered = lines.pop() ?? "";
yield* lines;
}
buffered += decoder.decode();
if (buffered !== "") {
yield buffered;
}
}只有 ResultEvent、agentCommand()、agentEnv() 和 outcome() 是 Claude Code 特有的。编程 Agent 中的其他页面会替换这些部分,以便在同一个 Worker 中运行不同的 Agent。
startSandbox() 会从 Wrangler 构建的镜像启动容器,且没有直接 Internet 访问。本示例中,容器使用 standard-1 实例类型。请选择适合你的 Agent 和仓库的大小。
每次启动后,startSandbox() 会将容器在端口 80 和 443 上的 HTTP 和 HTTPS 请求路由到 Outbound 入口点。拦截会随容器结束,因此 startSandbox() 会在每次启动时重新注册。更多信息,请参阅 interceptOutboundHttps()。
start() 一返回,running 即为 true,此时拦截尚未注册。设置期间到达的请求会等待同一次 setUpSandbox() 调用,因此在拦截就绪之前不会运行命令。若某个设置步骤失败,setUpSandbox() 会停止容器,下一个请求会启动新容器。
部署会重启 Durable Object,并可能在 start() 之后中断 setUpSandbox()。重启后的 Durable Object 会对已在运行的容器再次运行 setUpSandbox()。再次注册拦截会替换先前的拦截,因此容器会在下一条命令之前获得拦截。
Containers 会使用 Cloudflare 证书颁发机构重新签署来自容器的 HTTPS 流量,以便 Outbound 入口点可以读取。trustEnv 变量会将 Git、Node.js 和其他工具指向该证书。Durable Object 会将它们传递给每个发出 HTTPS 请求的命令。SSL_CERT_FILE、CURL_CA_BUNDLE 和 GIT_SSL_CAINFO 会用这一张证书替换系统信任存储,这仅在 Outbound 入口点接收每个 HTTPS 请求时有效。若你将它们复制到只拦截部分主机名的类中,请改为将该证书添加到系统信任存储。更多信息,请参阅信任 CA 证书。
Claude Code 任务可能运行数分钟,长于请求应等待的时间。startTask() 会启动任务并返回。客户端随后通过后续请求检查任务状态。
Claude Code 在没有自己的权限提示的情况下运行,因为 sandbox 限制了其命令能访问的内容。该命令设置这些选项:
--print在没有交互式终端的情况下运行 Claude Code。--output-format stream-json --verbose每行写入一个 JSON 事件,包括最终的result事件。--dangerously-skip-permissions允许 Claude Code 在不询问的情况下运行命令和编辑文件。Claude Code 需要IS_SANDBOX=1才能以root身份允许此行为。ANTHROPIC_BASE_URL将模型请求发送到你 gateway 的 Anthropic 端点。ANTHROPIC_API_KEY是占位符。没有 API key,Claude Code 不会启动,而Outbound入口点会从每个请求中移除该 key。CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC会关闭更新检查、遥测和错误报告,因此 Claude Code 只调用你的 gateway。
如果仓库有 CLAUDE.md 文件,Claude Code 会读取它,因此该文件中的说明会应用于任务。
进程将输出写入文件,而不是发回 Durable Object。带管道输出的进程会在启动它的请求结束后收到 SIGPIPE,从而在任务中途停止 Claude Code。
taskScript 是运行后台进程中的 RUN 脚本。setsid 会在自己的进程组中启动 Claude Code,脚本会在启动时记录 Claude Code 的进程 ID。Claude Code 退出时,脚本将退出码写入临时文件再重命名,因此 Durable Object 永远不会读取不完整的退出码。
任务文件位于仓库外的 /workspace/task,因此不会出现在 diff 中。
当两个请求同时启动任务时,blockConcurrencyWhile() 会依次运行它们。第二个请求看到正在运行的任务并返回 busy。
正在运行的进程不会让容器保持运行,因此 startTask() 会安排一个 alarm,每分钟检查任务直到结束。每次检查都会保持容器运行。更多信息,请参阅 Sandbox 生命周期。
任务具有以下状态之一:
running:Claude Code 进程仍存在。succeeded:带有 Claude Code 的最终回复。failed:带有错误消息。lost:进程或其容器在 Claude Code 记录退出码之前停止。none:此容器中尚未运行过任务。
即使模型请求失败,Claude Code 也会成功退出。结果来自最终 result 事件的 is_error 字段,而不是退出码。事件文件来自 sandbox,因此 Durable Object 在读取每个事件前会用 Zod 验证。没有 result 事件时,任务会失败,并带上 Claude Code 标准错误输出的末尾。
在没有退出码时,Durable Object 会运行 kill -0 检查进程是否仍存在。该命令通过 sh 运行,因为 kill 是 shell 内置命令,且精简镜像没有单独的 kill 二进制文件。若进程已消失,Durable Object 会再次读取退出码,因为任务可能在两次读取之间结束。只有在没有退出码的情况下结束的进程才是 lost。
进程 ID 文件还会记录 boot ID,该 ID 在每个实例中都会变化。若你从 snapshot 恢复工作区,该文件会回来,且新实例会重用相同的进程 ID。检查会忽略来自其他实例的进程 ID,因此拍摄 snapshot 时正在运行的任务会变成 lost。
readDiff() 会使用 git add --intent-to-add 标记新文件,以便差异中包含这些文件。git diff HEAD 会将文件与当前检出的提交进行比较,因此也会包含 Agent 已暂存的更改。
将 src/index.ts 替换为以下 Worker。它导出这两个类,并将每条路由映射到 URL 中命名的 sandbox 上的方法:
import { z } from "zod";
export { Outbound } from "./outbound";
export { AgentSandbox } from "./sandbox";
const sandboxName = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
// Linux rejects a command argument of 128 KiB or more, including its NUL byte.
const maxPromptBytes = 128 * 1024 - 1;
const RepositoryRequest = z.object({
url: z.url({ protocol: /^https$/, hostname: /^github\.com$/ }),
ref: z.string().min(1).optional(),
});
export default {
async fetch(request, env) {
const url = new URL(request.url);
const match = /^\/sandboxes\/([^/]+)\/(repository|task|diff)$/.exec(
url.pathname,
);
if (!match) {
return new Response("Not found", { status: 404 });
}
const [, name, resource] = match;
if (!sandboxName.test(name)) {
return new Response(
"The sandbox name must be 1-63 lowercase letters, digits, or hyphens, with no hyphen at either end",
{ status: 400 },
);
}
const sandbox = env.SANDBOX.getByName(name);
try {
if (resource === "repository" && request.method === "POST") {
const body = RepositoryRequest.safeParse(
await request.json().catch(() => null),
);
if (!body.success) {
return new Response(
'Send {"url": "https://github.com/OWNER/REPOSITORY"}',
{ status: 400 },
);
}
const result = await sandbox.cloneRepository(
body.data.url,
body.data.ref,
);
return Response.json(result, {
status: result.exitCode === 0 ? 200 : 502,
});
}
if (resource === "task" && request.method === "POST") {
const prompt = await request.text();
if (prompt.trim() === "") {
return new Response("Send a prompt", { status: 400 });
}
if (new TextEncoder().encode(prompt).byteLength > maxPromptBytes) {
return new Response("Send a prompt smaller than 128 KiB", {
status: 413,
});
}
if ((await sandbox.startTask(prompt)) === "busy") {
return new Response("A task is already running", { status: 409 });
}
return Response.json({ state: "running" }, { status: 202 });
}
if (resource === "task" && request.method === "GET") {
return Response.json(await sandbox.readTask());
}
if (resource === "diff" && request.method === "GET") {
const result = await sandbox.readDiff();
if (result.exitCode !== 0) {
return Response.json(result, { status: 502 });
}
return new Response(result.stdout, {
headers: { "content-type": "text/plain" },
});
}
return new Response("Method not allowed", { status: 405 });
} catch (error) {
console.error("Sandbox request failed", error);
return new Response("Sandbox request failed", { status: 500 });
}
},
};import { z } from "zod";
export { Outbound } from "./outbound";
export { AgentSandbox } from "./sandbox";
const sandboxName = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
// Linux rejects a command argument of 128 KiB or more, including its NUL byte.
const maxPromptBytes = 128 * 1024 - 1;
const RepositoryRequest = z.object({
url: z.url({ protocol: /^https$/, hostname: /^github\.com$/ }),
ref: z.string().min(1).optional(),
});
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const match = /^\/sandboxes\/([^/]+)\/(repository|task|diff)$/.exec(
url.pathname,
);
if (!match) {
return new Response("Not found", { status: 404 });
}
const [, name, resource] = match;
if (!sandboxName.test(name)) {
return new Response(
"The sandbox name must be 1-63 lowercase letters, digits, or hyphens, with no hyphen at either end",
{ status: 400 },
);
}
const sandbox = env.SANDBOX.getByName(name);
try {
if (resource === "repository" && request.method === "POST") {
const body = RepositoryRequest.safeParse(
await request.json().catch(() => null),
);
if (!body.success) {
return new Response(
'Send {"url": "https://github.com/OWNER/REPOSITORY"}',
{ status: 400 },
);
}
const result = await sandbox.cloneRepository(
body.data.url,
body.data.ref,
);
return Response.json(result, {
status: result.exitCode === 0 ? 200 : 502,
});
}
if (resource === "task" && request.method === "POST") {
const prompt = await request.text();
if (prompt.trim() === "") {
return new Response("Send a prompt", { status: 400 });
}
if (new TextEncoder().encode(prompt).byteLength > maxPromptBytes) {
return new Response("Send a prompt smaller than 128 KiB", {
status: 413,
});
}
if ((await sandbox.startTask(prompt)) === "busy") {
return new Response("A task is already running", { status: 409 });
}
return Response.json({ state: "running" }, { status: 202 });
}
if (resource === "task" && request.method === "GET") {
return Response.json(await sandbox.readTask());
}
if (resource === "diff" && request.method === "GET") {
const result = await sandbox.readDiff();
if (result.exitCode !== 0) {
return Response.json(result, { status: 502 });
}
return new Response(result.stdout, {
headers: { "content-type": "text/plain" },
});
}
return new Response("Method not allowed", { status: 405 });
} catch (error) {
console.error("Sandbox request failed", error);
return new Response("Sandbox request failed", { status: 500 });
}
},
} satisfies ExportedHandler<Env>;Worker 只接受 https://github.com/ 仓库 URL,因为 Outbound 入口点会阻止其他所有 Git 服务器。路由为:
POST /sandboxes/<NAME>/repository将仓库克隆到 sandbox。POST /sandboxes/<NAME>/task以请求正文作为提示启动 Claude Code。该提示会成为claude命令的一个参数,因此 128 KiB 或更大的提示会得到413响应。GET /sandboxes/<NAME>/task返回任务状态。GET /sandboxes/<NAME>/diff返回仓库中的更改。
-
创建名为
.secrets.json的文件,并填入你的 gateway token:.secrets.jsonjson { "AI_GATEWAY_TOKEN": "<AI_GATEWAY_TOKEN>" } -
使用该 secret 部署 Worker:
npx wrangler deploy --secrets-file .secrets.jsonyarn wrangler deploy --secrets-file .secrets.jsonpnpm wrangler deploy --secrets-file .secrets.jsonWrangler 会用 Docker 构建镜像,将其推送到你的账户,并上传带有
AI_GATEWAY_TOKENsecret 的 Worker。 -
删除 secrets 文件:
rm .secrets.json -
将 Wrangler 打印的
workers.devURL 保存到 shell 变量:WORKER_URL=https://sandbox-coding-agent.<YOUR_SUBDOMAIN>.workers.dev
-
将 GitHub 的
Hello-World仓库克隆到名为agent-1的 sandbox:curl "$WORKER_URL/sandboxes/agent-1/repository" \ --json '{"url": "https://github.com/octocat/Hello-World"}'第一个请求会启动容器,因此可能需要大约一分钟。Git 会将进度写入标准错误:
{ "exitCode": 0, "stdout": "", "stderr": "Cloning into '/workspace/repo'...\n" } -
启动任务:
curl "$WORKER_URL/sandboxes/agent-1/task" \ --data "Add a file NOTES.md with a one-sentence summary of this repository. Do not commit."Worker 会返回
202和任务状态:{ "state": "running" } -
检查任务,直到其状态为
succeeded或failed:curl "$WORKER_URL/sandboxes/agent-1/task"已完成的任务包含 Claude Code 的最终回复:
{ "state": "succeeded", "result": "Created `NOTES.md` with a one-sentence summary. Left uncommitted as requested." }每次运行的回复都不同。此任务需要几秒钟,更大的任务需要数分钟。alarm 会在 Claude Code 退出前保持 sandbox 运行,因此你不必持续检查。
-
读取更改:
curl "$WORKER_URL/sandboxes/agent-1/diff"响应是仓库的
git diff。新文件会出现,因为readDiff()用--intent-to-add标记了它:diff --git a/NOTES.md b/NOTES.md new file mode 100644 index 0000000..95da11e --- /dev/null +++ b/NOTES.md @@ -0,0 +1 @@ +This repository is a minimal test/example repo containing only a "Hello World!" README file.
若模型请求失败,任务状态为 failed,且 error 包含来自 AI Gateway 的消息:
Failed to authenticate. API Error: 401 Unauthorized表示 gateway 拒绝了 token。请检查该 token 是否属于wrangler.jsonc中的账户和 gateway。Claude Code 会对失败的模型请求重试 10 次,因此此错误大约三分钟后才会出现。API Error: 402 Insufficient wholesale credits表示 gateway 使用 Unified Billing 且账户没有额度。请添加额度,或在 gateway 中存储 Anthropic API key。
- 在同一个 Worker 中运行另一个 Agent。请参阅 Codex、OpenCode 或 Pi。
- 为每个 Agent 部署一个完成的 runner。请参阅 coding agents 示例 ↗,它会为每个 Agent 部署一个 Worker,而不是在一个 Worker 中切换。
- 在容器停止后保留 sandbox 的文件。请参阅 使用 snapshot 保存并恢复 sandbox。
- 使用保留在 Worker 中的 token 克隆私有仓库。请参阅克隆私有仓库。
- 决定一个 sandbox 应保存什么。请参阅 Sandbox 安全。
- 从其他环境经 AI Gateway 路由 Claude Code。请参阅 AI Gateway 中的 Claude Code。