跳转到内容
本站为社区维护的非官方中文翻译,与 Cloudflare, Inc. 无关联。本站不提供账户登录,请勿在任何非官方站点输入您的 Cloudflare 账户信息。官方文档:developers.cloudflare.com

构建编程 Agent runner

最后更新 查看 MarkdownAgent 设置

在本教程中,你将构建一个 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 的结果和更改。

前置条件

  1. 注册 Cloudflare 账户 ↗。
  2. 安装 Node.js ↗。

Node.js 版本管理器

使用 Vite+ (vp env) ↗ 安装和管理 Node.js 版本。

有关支持的 Node.js 版本,请参阅 Wrangler 系统要求。

当您运行 wrangler deploy 时,必须在本地运行 Docker。对于大多数人来说,安装 Docker 的最佳方式是按照安装 Docker Desktop 的文档 ↗进行操作。其他工具(如 Colima ↗)也可能有效。

您可以通过在终端中运行 docker info 命令来检查 Docker 是否正常运行。如果 Docker 正在运行,该命令将成功执行。如果 Docker 未运行,docker info 命令将挂起或返回包含 "Cannot connect to the Docker daemon" 消息的错误。

你还需要:

1. 创建项目

  1. 创建一个 Worker 项目:

    npm create cloudflare@latest -- sandbox-coding-agent --category=hello-world --type=hello-world --lang=ts --no-deploy --no-git --no-agents
  2. 进入项目目录:

    cd sandbox-coding-agent
  3. 安装 @cloudflare/sandbox 包和 Zod ↗:

    npm i @cloudflare/sandbox zod
  4. 在项目根目录创建 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 标签。

  5. 替换 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 都是带有自己容器的 AgentSandbox Durable Object。部署时 Wrangler 会构建 Dockerfile,Durable Object 将构建的镜像作为 this.ctx.container.images.agent 启动。MODEL 是你的 gateway 可以提供的 Anthropic 模型 ID。若缺少 gateway token,secrets.required 会使 wrangler deploy 失败。

  6. 为绑定、变量和 secret 生成类型:

    npx wrangler types

接下来的三节中,你将创建 src/outbound.ts 和 src/sandbox.ts,并替换 src/index.ts。本教程会添加或替换这些文件:

  • Dockerfile
  • wrangler.jsonc
  • src
    • outbound.ts
    • sandbox.ts
    • index.ts

2. 仅允许访问 gateway 和 GitHub

创建 src/outbound.ts。sandbox 会将端口 80 上的每个 HTTP 请求和端口 443 上的每个 HTTPS 请求通过 Worker 中的此入口点发送。没有 Internet 访问时,sandbox 无法访问其他端口:

src/outbound.jsjs
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 },
		);
	}
}
src/outbound.tsts
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 安全。

3. 在 sandbox 中运行 Claude Code

创建 src/sandbox.ts。AgentSandbox Durable Object 会克隆仓库、一次运行一个 Claude Code 任务,并报告任务状态和仓库 diff:

src/sandbox.jsjs
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;
	}
}
src/sandbox.tsts
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。

启动 sandbox

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

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。

保持 sandbox 运行

正在运行的进程不会让容器保持运行,因此 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 已暂存的更改。

4. 路由请求

将 src/index.ts 替换为以下 Worker。它导出这两个类,并将每条路由映射到 URL 中命名的 sandbox 上的方法:

src/index.jsjs
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 });
		}
	},
};
src/index.tsts
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 返回仓库中的更改。

5. 部署

  1. 创建名为 .secrets.json 的文件,并填入你的 gateway token:

    .secrets.jsonjson
    { "AI_GATEWAY_TOKEN": "<AI_GATEWAY_TOKEN>" }
  2. 使用该 secret 部署 Worker:

    npx wrangler deploy --secrets-file .secrets.json

    Wrangler 会用 Docker 构建镜像,将其推送到你的账户,并上传带有 AI_GATEWAY_TOKEN secret 的 Worker。

  3. 删除 secrets 文件:

    rm .secrets.json
  4. 将 Wrangler 打印的 workers.dev URL 保存到 shell 变量:

    WORKER_URL=https://sandbox-coding-agent.<YOUR_SUBDOMAIN>.workers.dev

6. 运行任务

  1. 将 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"
    }
  2. 启动任务:

    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" }
  3. 检查任务,直到其状态为 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 运行,因此你不必持续检查。

  4. 读取更改:

    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。

后续步骤

这篇文档对您有帮助吗?