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

迁移其他 0.x 功能

最后更新 查看 MarkdownAgent 设置

本页替换或移除 Sandbox SDK 0.12 中在 1.0 没有对应类的功能。每个章节独立。仅遵循应用使用的功能对应的章节。

开始之前

你的 1.0 类已替换 0.12 Sandbox 类,并具有 替换 Sandbox 类 中的 container getter、ensureRunning() 和 startContainer() 方法,以及 ENV 常量。通过 this.ctx.exports 访问的类需要兼容日期 2025-11-17 或更晚,或 enable_ctx_exports 标志。

每个 0.12 功能对应一节:

0.12 功能 章节
docker:dind-rootless 镜像中的 Docker 在 sandbox 内运行 Docker
读取绑定的 outboundByHost handler 从容器访问 Workers 绑定
@cloudflare/sandbox/opencode 启动 OpenCode 服务器
@cloudflare/sandbox/openai 为 OpenAI 智能体提供 shell 和编辑器
@cloudflare/sandbox/bridge 替换 bridge
WarmPool 移除 WarmPool

在 sandbox 内运行 Docker

0.x Docker 设置从 docker:dind-rootless 开始,切换到 USER root,并复制 0.x 服务器的 musl 构建。在 1.0 容器上,其 Docker 守护进程会以 failed to set IP forwarding '/proc/sys/net/ipv4/ip_forward' = '1': open /proc/sys/net/ipv4/ip_forward: read-only file system 退出。

  1. 替换你的 Dockerfile:

    Dockerfiledockerfile
    FROM docker:dind
    
    COPY --from=docker.io/cloudflare/sandbox:1.0.1 /usr/local/bin/sandbox-shim /usr/local/bin/sandbox-shim
    
    ENTRYPOINT ["sh", "-c", "dockerd-entrypoint.sh dockerd --iptables=false --ip6tables=false --ip-forward=false > /var/log/dockerd.log 2>&1 & exec sleep infinity"]

    Docker 守护进程以 root 身份运行,并关闭 iptables 和 IP 转发。它会记录到 /var/log,因为 docker:dind 入口点会挂载一个空的 /tmp。sleep infinity 可保持容器运行。仅当你的类使用 Files、S3Mount 或 DirectoryBackup 时,才保留 COPY 行。sandbox-shim 是静态链接的,因此可在基于 Alpine 的 docker:dind 镜像上运行。

  2. 在 start() 中保持 enableInternet: true,以便 Docker 可以拉取镜像。0.12 默认允许 Internet 访问。

  3. 等待 Docker,并用 --network=host 运行容器:

    src/index.ts (0.12)ts
    const sandbox = getSandbox(env.Sandbox, "docker-sandbox");
    const run = await sandbox.exec(
        "docker run --rm --network=host alpine:3.22 " +
            "echo Hello from Docker",
    );
    src/index.ts (1.0)ts
    export class MySandbox extends DurableObject<Env> {
        // ...
    
        private async waitForDocker(): Promise<void> {
            const wait = await this.container.exec([
                "timeout",
                "30",
                "sh",
                "-c",
                "until docker version > /dev/null 2>&1; do sleep 0.2; done",
            ]);
    
            if ((await wait.exitCode) !== 0) {
                throw new Error("Docker did not start within 30 seconds");
            }
        }
    
        async runImage(): Promise<string> {
            await this.ensureRunning();
            // The Docker daemon starts after the container, so wait for it
            await this.waitForDocker();
    
            const run = await this.container.exec(
                [
                    "docker",
                    "run",
                    "--rm",
                    "--network=host",
                    "alpine:3.22",
                    "echo",
                    "Hello from Docker",
                ],
                { env: ENV },
            );
            const result = await run.output();
            return new TextDecoder().decode(result.stdout);
        }
    }

    没有 --network=host 时,docker run 会以 failed to disable IPv6 on container's interface eth0 失败。使用网络的 docker build 步骤(例如安装包)也需要 docker build --network=host。以这种方式启动的内部容器共享 sandbox 的网络访问。

  4. 从 Worker 调用 runImage()。它返回 Hello from Docker。第一次调用还会拉取 alpine:3.22,Docker 将其进度写入 stderr。

更多信息请参阅 Containers FAQ 中的 Docker-in-Docker。

从容器访问 Workers 绑定

在 0.12 中,outboundByHost handler 响应例如 my.kv 的主机名请求,并从 env 参数读取 Workers 绑定。在 1.0 中,WorkerEntrypoint 响应它们,你的 Durable Object 为该主机名注册它。

  1. 将每个 handler 替换为 WorkerEntrypoint,并从 Worker 的主模块导出它:

    src/index.ts (0.12)ts
    MySandbox.outboundByHost = {
        "my.kv": async (request: Request, env: Env) => {
            const key = new URL(request.url).pathname.slice(1);
            const value = await env.KV.get(key);
            return new Response(value ?? "", {
                status: value ? 200 : 404,
            });
        },
    };
    src/index.ts (1.0)ts
    import { WorkerEntrypoint } from "cloudflare:workers";
    
    type KvProps = { containerId: string };
    
    export class KvGateway extends WorkerEntrypoint<Env, KvProps> {
        async fetch(request: Request): Promise<Response> {
            const key = new URL(request.url).pathname.slice(1);
            const value = await this.env.KV.get(key);
            return new Response(value ?? "", {
                status: value === null ? 404 : 200,
            });
        }
    }

    this.env 具有与 env 参数相同的绑定。原先 0.12 handler 读取 ctx.containerId 的地方,改为读取 this.ctx.props.containerId。

  2. 在 startContainer() 的 try 块中注册入口点:

    src/index.tsts
    export class MySandbox extends DurableObject<Env> {
        // ...
    
        private async startContainer(): Promise<void> {
            const newContainer = !this.container.running;
    
            if (newContainer) {
                this.container.start({
                    image: this.container.images.sandbox,
                    instance: "standard-1",
                    env: ENV,
                    // 0.12 allowed Internet access by default.
                    enableInternet: true,
                });
            }
    
            try {
                if (newContainer) {
                    // Replaces onStart().
                    await this.ctx.storage.put("startedAt", Date.now());
                }
                await this.container.interceptOutboundHttp(
                    "my.kv",
                    this.ctx.exports.KvGateway({
                        props: { containerId: this.ctx.id.toString() },
                    }),
                );
                await this.container.setInactivityTimeout(
                    INACTIVITY_TIMEOUT_MS,
                );
            } catch (error) {
                // The next request starts a new container.
                await this.container.destroy();
                throw error;
            }
        }
    }

    this.ctx.id.toString() 是 0.12 作为 containerId 传递的值,因此从它构建的键和路径保持不变。该拦截也适用于 enableInternet: false。为每个主机名注册一个入口点。

    如果你的类还使用 迁移出站规则 中的 Outbound 入口点,改为将 handler 添加到其 HANDLERS 映射。在 interceptAllOutboundHttp() 之后注册的主机名拦截收不到请求。

  3. 从容器请求该主机名。在将 hello from KV 存储到键 greeting 下之后,此命令会打印该值:

    node -e 'fetch("http://my.kv/greeting").then(async (r) => console.log(r.status, await r.text()))'
    200 hello from KV

启动 OpenCode 服务器

在 0.12 中,createOpencode() 在 sandbox 中启动 opencode serve,并返回通过 sandbox 到达它的 OpenCode SDK 客户端。在 1.0 中,你的 Durable Object 启动服务器并创建客户端,使用连接到服务器端口的 fetch 函数。

  1. 在 Dockerfile 中安装 OpenCode,如 在 sandbox 中运行 OpenCode 所述:

    Dockerfiledockerfile
    RUN npm install --global --ignore-scripts [email protected] \
        && ln -s /usr/local/lib/node_modules/opencode-linux-x64-baseline/bin/opencode /usr/local/bin/opencode

    0.12 opencode 镜像包含 OpenCode。用 standard-1 或更大的实例类型启动容器,如 startContainer() 所做。在 lite 上,服务器在 30 秒内未响应请求。

  2. 在 Worker 项目中安装 OpenCode SDK:

    npm i @opencode-ai/[email protected]
  3. 用为每个容器启动一次服务器的方法替换 createOpencode()。它使用 在后台运行服务器 第 1 步中的脚本和函数:

    src/index.ts (0.12)ts
    const sandbox = getSandbox(env.Sandbox, "my-agent");
    const { client } = await createOpencode(sandbox, {
        directory: "/workspace",
        config: OPENCODE_CONFIG,
    });
    const session = await client.session.create({ title: "Fix tests" });
    src/index.ts (1.0)ts
    import {
        createOpencodeClient,
        type OpencodeClient,
    } from "@opencode-ai/sdk/v2/client";
    
    const OPENCODE_PORT = 4096;
    const OPENCODE_DIR = `${SERVERS}/opencode`;
    
    // Your OpenCode configuration, without apiKey values.
    const OPENCODE_CONFIG = {};
    
    const OPENCODE = [
        "opencode",
        "serve",
        "--port",
        String(OPENCODE_PORT),
        "--hostname",
        "0.0.0.0",
    ];
    src/index.ts (1.0)ts
    export class MySandbox extends DurableObject<Env> {
        // ...
    
        private opencodeReady = false;
    
        private async openCode(): Promise<OpencodeClient> {
            await this.ensureRunning();
    
            if (!this.opencodeReady) {
                await startServer(this.container, OPENCODE_DIR, OPENCODE, {
                    cwd: "/workspace",
                    env: {
                        ...ENV,
                        OPENCODE_CONFIG_CONTENT:
                            JSON.stringify(OPENCODE_CONFIG),
                        ANTHROPIC_API_KEY: this.env.ANTHROPIC_API_KEY,
                    },
                });
                await waitForServer(
                    this.container,
                    OPENCODE_DIR,
                    OPENCODE_PORT,
                    30_000,
                );
                this.opencodeReady = true;
            }
    
            const port = this.container.getTcpPort(OPENCODE_PORT);
            return createOpencodeClient({
                baseUrl: "http://opencode",
                fetch: (request) => port.fetch(request),
            });
        }
    
        async createSession(title: string): Promise<string> {
            const client = await this.openCode();
            const session = await client.session.create({ title });
    
            if (!session.data) {
                throw new Error("OpenCode did not create the session");
            }
    
            return session.data.id;
        }
    }

    从 OpenCode 配置中移除 apiKey,并将每个密钥存储为 secret。与 0.12 一样,该方法将配置作为 OPENCODE_CONFIG_CONTENT 传递,并将密钥放在例如 ANTHROPIC_API_KEY 的提供商变量中。容器可以读取该密钥。要将模型凭据保留在 Worker 中,请参阅 在 sandbox 中运行 OpenCode。

    在 startContainer() 中、if (newContainer) 内部设置 this.opencodeReady = false,因为新容器没有服务器。如果 Durable Object 在容器运行时重启,startServer() 会找到已经在运行的服务器。在类的方法中使用客户端,例如 createSession()。

  4. 从 Durable Object 的 fetch() 处理程序提供 OpenCode Web UI:

    src/index.tsts
    export class MySandbox extends DurableObject<Env> {
        // ...
    
        async fetch(request: Request): Promise<Response> {
            const url = new URL(request.url);
            const accept = request.headers.get("Accept") ?? "";
            const page =
                accept.includes("text/html") || url.pathname === "/";
    
            // The UI calls the server at the origin in ?url=.
            if (
                request.method === "GET" &&
                page &&
                !url.searchParams.has("url")
            ) {
                url.searchParams.set("url", url.origin);
                return Response.redirect(url.toString(), 302);
            }
    
            await this.openCode();
            // The container port accepts only http: URLs.
            url.protocol = "http:";
            return this.container
                .getTcpPort(OPENCODE_PORT)
                .fetch(new Request(url, request));
        }
    }

    这替换了 proxyToOpencode(),后者做相同的重定向。UI 从 / 加载其文件,因此在主机名的根路径提供它,并将每个路径转发到 Durable Object,例如用 env.Sandbox.getByName("my-agent").fetch(request)。UI 可以在 sandbox 中运行命令,因此请在 Worker 中认证这些请求。

  5. 从 Worker 调用 createSession("Fix tests")。它返回新会话 ID,例如 ses_f1822cedcffe1CpcMlwM9eQSGZ。在浏览器中打开 Worker URL。它会重定向到 /?url= 并加载 OpenCode。

为 OpenAI 智能体提供 shell 和编辑器

在 0.12 中,@cloudflare/sandbox/openai 中的 Shell 和 Editor 在 sandbox 中运行 OpenAI 智能体的命令和文件编辑,而智能体在 Worker 中运行。在 1.0 中,你在 container.exec() 和 Files 上编写这两个类,并在 Durable Object 中运行智能体(容器所在之处)。

  1. 添加用 bash 在 /workspace 中运行每条命令的 Shell:

    src/index.tsts
    import { Files } from "@cloudflare/sandbox";
    import {
        Agent,
        applyDiff,
        applyPatchTool,
        run,
        shellTool,
        type ApplyPatchOperation,
        type ApplyPatchResult,
        type Editor,
        type Shell,
        type ShellAction,
        type ShellResult,
    } from "@openai/agents";
    
    class SandboxShell implements Shell {
        constructor(private readonly container: Container) {}
    
        async run(action: ShellAction): Promise<ShellResult> {
            const timeoutMs = action.timeoutMs ?? 60_000;
            const seconds = String(Math.ceil(timeoutMs / 1000));
            const limit = ["timeout", "--kill-after=5", seconds];
            const output: ShellResult["output"] = [];
            const decoder = new TextDecoder();
    
            for (const command of action.commands) {
                const process = await this.container.exec(
                    [...limit, "bash", "-c", command],
                    { cwd: "/workspace", env: ENV },
                );
                const result = await process.output();
                const timedOut = result.exitCode === 124 || result.exitCode === 137;
    
                output.push({
                    stdout: decoder.decode(result.stdout),
                    stderr: decoder.decode(result.stderr),
                    outcome: timedOut
                        ? { type: "timeout" }
                        : { type: "exit", exitCode: result.exitCode },
                });
    
                // 0.12 also skipped the rest after a timeout.
                if (timedOut) {
                    break;
                }
            }
    
            return {
                output,
                providerData: { working_directory: "/workspace" },
            };
        }
    }

    timeout 会停止命令及其启动的进程,这些进程必须仍位于其进程组中。命令会以代码 124 退出;如果 timeout 还发送了 SIGKILL,则以 137 退出。0.12 在操作未设置 timeoutMs 时不会限制时间,而此类将限制为 60 秒。

  2. 添加用 Files 应用智能体补丁的 Editor:

    src/index.tsts
    type Operation<T> = Extract<ApplyPatchOperation, { type: T }>;
    
    class SandboxEditor implements Editor {
        constructor(private readonly files: Files) {}
    
        // Reads each path as relative to /workspace, as 0.12 did, and throws
        // when `..` leaves /workspace.
        private path(path: string): string {
            const root = "/workspace";
            const inRoot = path === root || path.startsWith(`${root}/`);
            const relative = inRoot ? path.slice(root.length) : path;
            const segments: string[] = [];
    
            for (const segment of relative.split("/")) {
                if (segment === "..") {
                    if (segments.pop() === undefined) {
                        throw new Error(`Operation outside workspace: ${path}`);
                    }
                } else if (segment !== "" && segment !== ".") {
                    segments.push(segment);
                }
            }
    
            return [root, ...segments].join("/");
        }
    
        async createFile(
            operation: Operation<"create_file">,
        ): Promise<ApplyPatchResult> {
            const path = this.path(operation.path);
            const parent = path.slice(0, path.lastIndexOf("/"));
            await this.files.mkdir(parent, { recursive: true });
            const content = applyDiff("", operation.diff, "create");
            await this.files.writeFile(path, content);
            return {
                status: "completed",
                output: `Created ${operation.path}`,
            };
        }
    
        async updateFile(
            operation: Operation<"update_file">,
        ): Promise<ApplyPatchResult> {
            const path = this.path(operation.path);
            const file = await this.files.readFile(path);
            const current = await file.text();
            const content = applyDiff(current, operation.diff);
            await this.files.writeFile(path, content);
            return {
                status: "completed",
                output: `Updated ${operation.path}`,
            };
        }
    
        async deleteFile(
            operation: Operation<"delete_file">,
        ): Promise<ApplyPatchResult> {
            await this.files.remove(this.path(operation.path));
            return {
                status: "completed",
                output: `Deleted ${operation.path}`,
            };
        }
    }

    path() 将 /etc/passwd 读取为 /workspace/etc/passwd,并对 ../etc/passwd 抛出,与 0.12 相同。它仅检查路径。/workspace 中的符号链接仍可能指向其外部。更多信息请参阅 接受调用方提供的路径。

    失败的 Files 调用会抛出 SandboxFileError。两个 0.12 类都保留 results 列表,0.12 Editor 在再次抛出错误之前会向其中添加 failed 结果。如果你的代码读取该列表,请在每个方法中记录结果。

  3. 在类的方法中运行智能体:

    src/index.ts (0.12)ts
    const sandbox = getSandbox(env.Sandbox, `session-${sessionId}`);
    const agent = new Agent({
        name: "Sandbox agent",
        instructions: INSTRUCTIONS,
        tools: [
            shellTool({ shell: new Shell(sandbox) }),
            applyPatchTool({
                editor: new Editor(sandbox, "/workspace"),
            }),
        ],
    });
    const result = await run(agent, input);
    src/index.ts (1.0)ts
    export class MySandbox extends DurableObject<Env> {
        // ...
    
        private readonly files = new Files(this.container);
    
        async runAgent(input: string): Promise<string | undefined> {
            await this.ensureRunning();
    
            const shell = new SandboxShell(this.container);
            const editor = new SandboxEditor(this.files);
            const agent = new Agent({
                name: "Sandbox agent",
                instructions: INSTRUCTIONS,
                tools: [shellTool({ shell }), applyPatchTool({ editor })],
            });
            const result = await run(agent, input);
            return result.finalOutput;
        }
    }

    Worker 在 getByName() 返回的 stub 上调用 runAgent()。Agents SDK 从环境读取 OPENAI_API_KEY,与在 Worker 中一样。

    如果你的类已有 更改文件调用 中的 files 字段,保留两者之一。

  4. 不使用模型,通过直接调用检查这两个类。shell.run({ commands: ["pwd", "sleep 10"], timeoutMs: 2000 }) 返回工作目录,然后是超时:

    {
        "output": [
            {
                "stdout": "/workspace\n",
                "stderr": "",
                "outcome": { "type": "exit", "exitCode": 0 }
            },
            {
                "stdout": "",
                "stderr": "",
                "outcome": { "type": "timeout" }
            }
        ],
        "providerData": { "working_directory": "/workspace" }
    }

    editor.createFile({ type: "create_file", path: "notes/todo.md", diff: "+first\n+second\n" }) 返回 { "status": "completed", "output": "Created notes/todo.md" }。/workspace/notes/todo.md 随后包含行 first 和 second。

替换 bridge

0.12 bridge(@cloudflare/sandbox/bridge 中的 bridge())为 Workers 外部的客户端通过 Sandbox 调用提供 HTTP API,并检查 SANDBOX_API_KEY bearer 令牌。1.0 没有 bridge。为客户端发出的每次调用添加 Worker 路由,并让路由调用 Durable Object 的方法。像 bridge 那样在 Worker 中检查令牌。

为 bridge API 编写的客户端(例如 Python OpenAI Agents SDK 中的 CloudflareSandboxClient)仅适用于 0.x bridge Worker。在客户端调用你自己的路由之前,将该 Worker 保持在 0.x。

移除 WarmPool

在请求需要时启动每个 sandbox,并移除 WarmPool。该池会提前启动 0.x sandbox,因为 0.x 启动需要数秒。采用 Durable Object 调度策略后,独立基准测试中从创建 sandbox 到运行命令的中位时间为 648 毫秒,因此连接池只能节省很少时间。

  1. 从 Worker 主模块移除 WarmPool 导出,并从 Wrangler 配置的 durable_objects 中移除其绑定。

  2. 以 Worker 添加该类的相同方式删除该类。如果 Worker 使用 migrations,添加一次迁移:

    {
        "migrations": [
            { "new_sqlite_classes": ["Sandbox"], "tag": "v1" },
            { "new_sqlite_classes": ["WarmPool"], "tag": "v2" },
            { "deleted_classes": ["WarmPool"], "tag": "v3" },
        ],
    }
    [[migrations]]
    new_sqlite_classes = [ "Sandbox" ]
    tag = "v1"
    
    [[migrations]]
    new_sqlite_classes = [ "WarmPool" ]
    tag = "v2"
    
    [[migrations]]
    deleted_classes = [ "WarmPool" ]
    tag = "v3"

    如果 Worker 使用 exports,改为将该类标记为已删除:

    {
        "exports": {
            "WarmPool": {
                "type": "durable-object",
                "state": "deleted",
            },
        },
    }
    [exports.WarmPool]
    type = "durable-object"
    state = "deleted"
  3. 移除 WARM_POOL_TARGET、WARM_POOL_REFRESH_INTERVAL、WARM_POOL_MAX_INSTANCES 和 WARM_POOL_SCALE_BATCH_SIZE 变量。bridge() 从其 scheduled() 处理程序填充该池,因此如果没有其他内容使用它,也请移除 cron 触发器。

该池启动的 sandbox 是 0.12 sandbox,因此 规划迁移 像对待其他 sandbox 一样覆盖它们。

如果应用仍需要那最后几毫秒,为用户即将使用的 sandbox 调用 start(),例如在他们打开会话时。不要保留已启动 sandbox 的池。每个在等待时都会计费,你的代码必须将其分配给会话。

相关资源

这篇文档对您有帮助吗?