在网页上暴露 Linux Sandbox 中的实时 shell。页面运行 xterm.js ↗,并通过 WebSocket 将其连接到 tmux ↗ 会话中的 shell。页面重新加载或连接断开时,shell 会继续运行。
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
- 一个带有 Durable Object 的 Worker,该 Durable Object 使用 Durable Object 调度策略 启动容器。要创建一个,请参阅运行 Linux 命令。
当您运行 wrangler deploy 时,必须在本地运行 Docker。对于大多数人来说,安装 Docker 的最佳方式是按照安装 Docker Desktop 的文档 ↗进行操作。其他工具(如 Colima ↗)也可能有效。
您可以通过在终端中运行 docker info 命令来检查 Docker 是否正常运行。如果 Docker 正在运行,该命令将成功执行。如果 Docker 未运行,docker info 命令将挂起或返回包含 "Cannot connect to the Docker daemon" 消息的错误。
-
在项目根目录创建带 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()调用。 -
在
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 typesyarn wrangler typespnpm wrangler types -
为 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 安全。 -
创建终端页面:
<!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,并用静态资源提供它们。 -
在 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 里获得一个
rootshell。请先对调用者进行身份验证,并根据调用者身份推导 Sandbox 名称。更多信息,请参阅 Sandbox 安全。 -
部署 Worker:
npx wrangler deployyarn wrangler deploypnpm wrangler deploy -
在浏览器中打开名为
ada的 Sandbox 终端。将示例主机名替换为 Wrangler 打印的workers.devURL,并保留末尾斜杠:https://<YOUR_WORKER>.<YOUR_SUBDOMAIN>.workers.dev/sandboxes/ada/Sandbox 启动后,会出现带底部绿色 tmux 状态行的 shell 提示符。
-
运行
stty size。shell 会打印行数和列数,例如32 109。调整浏览器窗口大小后再运行stty size。数字会随窗口变化,并减去状态行占用的一行。 -
启动一条持续打印的命令,然后重新加载页面:
for i in $(seq 1 600); do echo "line $i"; sleep 1; done终端会附加到同一个 shell,页面离开期间计数仍在继续。tmux 会在历史中保留更早的输出。用鼠标滚轮回滚,按
q离开历史。 -
在第二个浏览器标签页中打开同一 URL。两个标签页显示同一屏幕,你可以在任一标签页中输入。当标签页大小不同时,tmux 会将屏幕适配到最近使用的标签页,并在较大标签页的其余部分填充圆点。要在同一 Sandbox 中打开单独的 shell,在 URL 中添加
?session=build。
在 Shell 中运行 exit 以结束会话。页面会显示 [Terminal closed with code 0]。如需让会话继续运行,请按 CTRL + B,再按 D 以分离;之后重新加载页面即可重新接入。
要运行交互式命令而不是默认 shell(例如编程 Agent 的命令行界面),在 exec() 调用中将会话名称后追加该命令。tmux 在创建会话时会运行该命令:
const tmux = await container.exec(
["tmux", "new-session", "-A", "-s", session, "claude"],
{
// Same options as before.
},
);在 Dockerfile 中安装该命令。
编程 Agent 还会通过网络调用其模型,而 enableInternet: false 会阻止该调用。要将其请求经 Worker 路由并仅允许它需要的主机名,请参阅 interceptOutboundHttps()。
如需在没有浏览器的情况下结束会话,请在 Durable Object 中运行 tmux kill-session:
await container.exec(["tmux", "kill-session", "-t", `=${session}`]);= 会精确匹配会话名称,而不是作为前缀。要列出会话,运行 tmux list-sessions。
- 流式传输命令输出:不使用终端运行命令并流式传输其输出。
- 终端工作区示例 ↗:可部署的 Worker,包含此终端以及列出和结束会话的路由。
- Durable Object 容器 API:每个
exec()选项。 - xterm.js 文档 ↗:终端选项和附加组件。