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

在浏览器中打开终端

最后更新 查看 MarkdownAgent 设置

在网页上暴露 Linux Sandbox 中的实时 shell。页面运行 xterm.js ↗,并通过 WebSocket 将其连接到 tmux ↗ 会话中的 shell。页面重新加载或连接断开时,shell 会继续运行。

https://<YOUR_WORKER>.<YOUR_SUBDOMAIN>.workers.dev/sandboxes/ada/
root@fbed6b5a41ac5bf7fa2b2db27f4d6a8268aab85bdaf6be012a2e881812eb617d:~# stty size
21 95
root@fbed6b5a41ac5bf7fa2b2db27f4d6a8268aab85bdaf6be012a2e881812eb617d:~# for i in $(seq 1 600); do echo "line $i"; sleep 1; done
line 1
line 2
line 3
line 4
line 5
[main] 0:sleep*

前置条件

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

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

打开终端

  1. 在项目根目录创建带 tmux 的 Dockerfile:

    Dockerfiledockerfile
    FROM node:24-trixie-slim
    
    RUN apt-get update \
        && apt-get install -y --no-install-recommends tmux \
        && rm -rf /var/lib/apt/lists/*
    
    # Scroll back with the mouse wheel, and keep more history.
    RUN printf 'set -g mouse on\nset -g history-limit 50000\n' > /etc/tmux.conf
    
    CMD ["sleep", "infinity"]

    sleep infinity 保持容器运行,以便每个终端都能接受 exec() 调用。

  2. 在 wrangler.jsonc 中,将 Dockerfile 构建为 containers 条目中的命名镜像,然后为其生成类型:

    {
        "containers": [
            {
                "class_name": "MyContainer",
                "scheduling_policy": "durable_object",
                "images": {
                    "terminal": {
                        "dockerfile": "./Dockerfile",
                    },
                },
            },
        ],
    }
    [[containers]]
    class_name = "MyContainer"
    scheduling_policy = "durable_object"
    
    [containers.images.terminal]
    dockerfile = "./Dockerfile"
    npx wrangler types
  3. 为 Durable Object 添加不活动超时。当重启后的 Durable Object 发现容器仍在运行时,构造函数会再次设置超时:

    src/index.tsts
    const INACTIVITY_TIMEOUT_MS = 5 * 60 * 1000;
    
    export class MyContainer extends DurableObject<Env> {
        constructor(ctx: DurableObjectState, env: Env) {
            super(ctx, env);
            const container = ctx.container;
            if (container?.running) {
                void ctx.blockConcurrencyWhile(() =>
                    container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS),
                );
            }
        }
    }

    5 分钟的超时时间可让重新加载的页面或断开的连接重新接入同一个 tmux 会话。

    然后添加一个 fetch() 处理程序,将 WebSocket 附加到 tmux 会话:

    src/index.tsts
    export class MyContainer extends DurableObject<Env> {
        // ...
    
        async fetch(request: Request): Promise<Response> {
            const container = this.ctx.container;
    
            if (!container) {
                throw new Error("No container is configured");
            }
    
            if (request.headers.get("Upgrade") !== "websocket") {
                return new Response("Expected a WebSocket upgrade", { status: 426 });
            }
    
            // tmux session names cannot contain "." or ":".
            const url = new URL(request.url);
            const session = url.searchParams.get("session") || "main";
    
            if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(session)) {
                return new Response("Invalid session name", { status: 400 });
            }
    
            if (!container.running) {
                container.start({
                    image: container.images.terminal,
                    // Block commands in the terminal from reaching the Internet
                    enableInternet: false,
                });
                await container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS);
            }
    
            // Attach to the tmux session, or create it. The session keeps the shell
            // running after this WebSocket closes.
            const abort = new AbortController();
            const tmux = await container.exec(
                ["tmux", "new-session", "-A", "-s", session],
                {
                    // Run the tmux client attached to a pseudo-terminal.
                    pty: {
                        cols: Number(url.searchParams.get("cols")) || 80,
                        rows: Number(url.searchParams.get("rows")) || 24,
                    },
                    env: { TERM: "xterm-256color" },
                    cwd: "/root",
                    signal: abort.signal,
                },
            );
    
            const [client, server] = Object.values(new WebSocketPair());
    
            // Receive binary messages as ArrayBuffer instead of Blob.
            server.binaryType = "arraybuffer";
    
            // Keep this Durable Object in memory while the terminal is open.
            server.accept();
    
            const stdin = tmux.stdin!.getWriter();
    
            // Listeners must not throw. An exception closes the socket without running
            // the `close` listener, which leaves the tmux client running.
            server.addEventListener("message", (event) => {
                if (typeof event.data === "string") {
                    try {
                        const { cols, rows } = JSON.parse(event.data);
                        tmux.resize(cols, rows);
                    } catch {
                        // Ignore malformed resize messages so the terminal keeps running.
                    }
                } else {
                    stdin.write(new Uint8Array(event.data)).catch(() => {});
                }
            });
    
            let exited = false;
            const markExited = () => {
                exited = true;
            };
            tmux.exitCode.then(markExited, markExited);
    
            // Stop the tmux client when the browser disconnects. The session and its
            // shell keep running.
            server.addEventListener("close", () => {
                // Signaling a process that has exited raises an error.
                if (!exited) abort.abort();
            });
    
            const forward = async () => {
                for await (const chunk of tmux.stdout!) {
                    server.send(chunk);
                }
    
                server.close(1000, `Terminal closed with code ${await tmux.exitCode}`);
            };
    
            void forward().catch(() => server.close(1011, "Terminal output failed"));
    
            return new Response(null, { status: 101, webSocket: client });
        }
    }

    每个 WebSocket 都有自己的 tmux 客户端。tmux new-session -A 会将客户端附加到命名会话,若不存在则创建该会话。处理程序将按键写入客户端的标准输入,应用调整大小消息,并无修改地转发客户端输出。

    处理程序使用 accept() 接受 WebSocket,而不是 Hibernation WebSocket API。Hibernation 会丢弃 tmux 客户端的进程句柄。

    shell 以 root 身份运行。exec() 的 user 选项(例如 user: "1000:1000")会更改 shell 的 Linux 用户。它不会限制 shell 能读取或更改的内容。更多信息,请参阅 Sandbox 安全。

  4. 创建终端页面:

    <!doctype html>
    <html lang="en">
        <head>
            <meta charset="utf-8" />
            <title>Sandbox terminal</title>
            <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/[email protected]/css/xterm.css" />
            <style>
                html, body, #terminal { height: 100%; margin: 0; background: #000; }
            </style>
        </head>
        <body>
            <div id="terminal"></div>
            <script type="module">
                import { Terminal } from "https://cdn.jsdelivr.net/npm/@xterm/[email protected]/lib/xterm.mjs";
                import { FitAddon } from "https://cdn.jsdelivr.net/npm/@xterm/[email protected]/lib/addon-fit.mjs";
    
                // With tmux mouse mode on, hold Shift (Option on macOS) to select text.
                const terminal = new Terminal({ macOptionClickForcesSelection: true });
                const fitAddon = new FitAddon();
                terminal.loadAddon(fitAddon);
                terminal.open(document.getElementById("terminal"));
                fitAddon.fit();
    
                const url = new URL("terminal", location.href);
                url.protocol = location.protocol === "https:" ? "wss:" : "ws:";
                url.searchParams.set("session", new URLSearchParams(location.search).get("session") || "main");
                url.searchParams.set("cols", terminal.cols);
                url.searchParams.set("rows", terminal.rows);
    
                const socket = new WebSocket(url);
                socket.binaryType = "arraybuffer";
                const encoder = new TextEncoder();
                const send = (data) => {
                    if (socket.readyState === WebSocket.OPEN) socket.send(data);
                };
    
                socket.addEventListener("message", (event) => {
                    terminal.write(new Uint8Array(event.data));
                });
                socket.addEventListener("close", (event) => {
                    terminal.writeln("");
                    terminal.writeln("[" + (event.reason || "Disconnected") + "]");
                });
    
                terminal.onData((data) => send(encoder.encode(data)));
                terminal.onResize(({ cols, rows }) => send(JSON.stringify({ cols, rows })));
                addEventListener("resize", () => fitAddon.fit());
            </script>
        </body>
    </html>

    页面以二进制消息发送按键,以 JSON 文本消息发送窗口大小。连接关闭时,页面会显示原因;重新加载页面即可再次连接。如需页面自动重连,请参阅终端工作区示例 ↗。

    页面从 CDN 加载 xterm.js。在生产环境中,从 npm 安装 @xterm/xterm 和 @xterm/addon-fit,并用静态资源提供它们。

  5. 在 Worker 中添加路由,用于提供页面并将 WebSocket 传递给 Sandbox:

    src/index.jsjs
    const page = `[... HTML from step 4 ...]`;
    
    export default {
    	async fetch(request, env) {
    		const url = new URL(request.url);
    		const match =
    			/^\/sandboxes\/([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\/(terminal)?$/.exec(
    				url.pathname,
    			);
    
    		if (!match) {
    			return new Response("Not found", { status: 404 });
    		}
    
    		const sandbox = env.MY_CONTAINER.getByName(match[1]);
    
    		if (match[2]) {
    			return sandbox.fetch(request);
    		}
    
    		return new Response(page, {
    			headers: { "Content-Type": "text/html; charset=utf-8" },
    		});
    	},
    };
    src/index.tsts
    const page = `[... HTML from step 4 ...]`;
    
    export default {
        async fetch(request: Request, env: Env): Promise<Response> {
            const url = new URL(request.url);
            const match = /^\/sandboxes\/([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\/(terminal)?$/.exec(url.pathname);
    
            if (!match) {
                return new Response("Not found", { status: 404 });
            }
    
            const sandbox = env.MY_CONTAINER.getByName(match[1]);
    
            if (match[2]) {
                return sandbox.fetch(request);
            }
    
            return new Response(page, {
                headers: { "Content-Type": "text/html; charset=utf-8" },
            });
        },
    } satisfies ExportedHandler<Env>;

    打开该页面的任何人都会在 URL 中命名的 Sandbox 里获得一个 root shell。请先对调用者进行身份验证,并根据调用者身份推导 Sandbox 名称。更多信息,请参阅 Sandbox 安全。

  6. 部署 Worker:

    npx wrangler deploy
  7. 在浏览器中打开名为 ada 的 Sandbox 终端。将示例主机名替换为 Wrangler 打印的 workers.dev URL,并保留末尾斜杠:

    https://<YOUR_WORKER>.<YOUR_SUBDOMAIN>.workers.dev/sandboxes/ada/

    Sandbox 启动后,会出现带底部绿色 tmux 状态行的 shell 提示符。

  8. 运行 stty size。shell 会打印行数和列数,例如 32 109。调整浏览器窗口大小后再运行 stty size。数字会随窗口变化,并减去状态行占用的一行。

  9. 启动一条持续打印的命令,然后重新加载页面:

    for i in $(seq 1 600); do echo "line $i"; sleep 1; done

    终端会附加到同一个 shell,页面离开期间计数仍在继续。tmux 会在历史中保留更早的输出。用鼠标滚轮回滚,按 q 离开历史。

  10. 在第二个浏览器标签页中打开同一 URL。两个标签页显示同一屏幕,你可以在任一标签页中输入。当标签页大小不同时,tmux 会将屏幕适配到最近使用的标签页,并在较大标签页的其余部分填充圆点。要在同一 Sandbox 中打开单独的 shell,在 URL 中添加 ?session=build。

在 Shell 中运行 exit 以结束会话。页面会显示 [Terminal closed with code 0]。如需让会话继续运行,请按 CTRL + B,再按 D 以分离;之后重新加载页面即可重新接入。

在终端中运行其他命令

要运行交互式命令而不是默认 shell(例如编程 Agent 的命令行界面),在 exec() 调用中将会话名称后追加该命令。tmux 在创建会话时会运行该命令:

src/index.tsts
const tmux = await container.exec(
    ["tmux", "new-session", "-A", "-s", session, "claude"],
    {
        // Same options as before.
    },
);

在 Dockerfile 中安装该命令。

编程 Agent 还会通过网络调用其模型,而 enableInternet: false 会阻止该调用。要将其请求经 Worker 路由并仅允许它需要的主机名,请参阅 interceptOutboundHttps()。

从 Worker 结束会话

如需在没有浏览器的情况下结束会话,请在 Durable Object 中运行 tmux kill-session:

src/index.tsts
await container.exec(["tmux", "kill-session", "-t", `=${session}`]);

= 会精确匹配会话名称,而不是作为前缀。要列出会话,运行 tmux list-sessions。

相关资源

这篇文档对您有帮助吗?