Skip to content

Open a terminal in the browser

Last updated View as MarkdownAgent setup

Expose a live shell in a Linux sandbox on a web page. The page runs xterm.js ↗︎ and connects it over a WebSocket to a shell in a tmux ↗︎ session. The shell keeps running when the page reloads or the connection drops.

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*

Prerequisites

You must have Docker running locally when you run wrangler deploy. For most people, the best way to install Docker is to follow the docs for installing Docker Desktop ↗︎. Other tools like Colima ↗︎ may also work.

You can check that Docker is running properly by running the docker info command in your terminal. If Docker is running, the command will succeed. If Docker is not running, the docker info command will hang or return an error including the message "Cannot connect to the Docker daemon".

Open a terminal

  1. Create a Dockerfile in the project root with tmux:

    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 keeps the container running so that it can accept exec() calls for each terminal.

  2. In wrangler.jsonc, build the Dockerfile as a named image in your containers entry, then generate types for it:

    {
    	"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. Add an inactivity timeout to your Durable Object. The constructor sets the timeout again when a restarted Durable Object finds the container running:

    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),
    			);
    		}
    	}
    }

    The container keeps running for 5 minutes after the last terminal closes, so a reload finds the same tmux session.

    Then add a fetch() handler that attaches a WebSocket to a tmux session:

    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("The container binding is not 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 });
    	}
    }

    Each WebSocket gets its own tmux client. tmux new-session -A attaches the client to the named session, or creates the session if it does not exist. The handler writes keystrokes to the standard input of the client, applies resize messages, and forwards the client output unchanged.

    The handler accepts the WebSocket with accept() instead of the Hibernation WebSocket API. Hibernation would discard the process handle of the tmux client.

    The shell runs as root. The user option of exec(), such as user: "1000:1000", changes the Linux user of the shell. It does not limit what the shell can read or change. For more information, refer to Sandbox security.

    The close listener skips a client that has already exited. For more information, refer to Process lifetime.

  4. Create a terminal page:

    <!doctype html>
    <html lang="en">
    	<head>
    		<meta charset="utf-8" />
    		<title>Sandbox terminal</title>
    		<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@6.0.0/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/xterm@6.0.0/lib/xterm.mjs";
    			import { FitAddon } from "https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.11.0/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 session = new URLSearchParams(location.search).get("session") || "main";
    			const encoder = new TextEncoder();
    			let socket;
    			let attempts = 0;
    
    			const send = (data) => {
    				if (socket.readyState === WebSocket.OPEN) socket.send(data);
    			};
    
    			const connect = () => {
    				const url = new URL("terminal", location.href);
    				url.protocol = location.protocol === "https:" ? "wss:" : "ws:";
    				url.searchParams.set("session", session);
    				url.searchParams.set("cols", terminal.cols);
    				url.searchParams.set("rows", terminal.rows);
    
    				socket = new WebSocket(url);
    				socket.binaryType = "arraybuffer";
    
    				socket.addEventListener("open", () => {
    					attempts = 0;
    					// tmux redraws the whole screen when a client attaches.
    					terminal.reset();
    				});
    				socket.addEventListener("message", (event) => {
    					terminal.write(new Uint8Array(event.data));
    				});
    				socket.addEventListener("close", (event) => {
    					terminal.writeln("");
    					// Code 1000 means the session ended or the client detached.
    					if (event.code === 1000 || attempts >= 10) {
    						terminal.writeln("[" + (event.reason || "Disconnected") + "]");
    						return;
    					}
    					const delay = Math.min(1000 * 2 ** attempts++, 30000);
    					terminal.writeln("[Reconnecting in " + delay / 1000 + " s]");
    					setTimeout(connect, delay);
    				});
    			};
    			connect();
    
    			terminal.onData((data) => send(encoder.encode(data)));
    			terminal.onResize(({ cols, rows }) => send(JSON.stringify({ cols, rows })));
    			addEventListener("resize", () => fitAddon.fit());
    		</script>
    	</body>
    </html>

    The page sends keystrokes as binary messages and window sizes as JSON text messages. When the connection drops, it reconnects after 1 second, doubling the delay up to 30 seconds, and gives up after 10 attempts. It does not reconnect after code 1000, which means the session ended or the client detached.

    The page loads xterm.js from a CDN. In production, install @xterm/xterm and @xterm/addon-fit from npm and serve them with static assets.

  5. Add routes to your Worker that serve the page and pass the WebSocket to the 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>;

    Anyone who opens the page gets a root shell in the sandbox named in the URL. Authenticate callers first, and derive the sandbox name from the caller's identity. For more information, refer to Sandbox security.

  6. Deploy your Worker:

    npx wrangler deploy
  7. Open the terminal for the sandbox named ada in your browser. Replace the example hostname with the workers.dev URL that Wrangler prints, and keep the trailing slash:

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

    After the sandbox starts, a shell prompt appears with a green tmux status line at the bottom.

  8. Run stty size. The shell prints its rows and columns, such as 32 109. Resize the browser window and run stty size again. The numbers change to match the window, minus one row for the status line.

  9. Start a command that keeps printing, then reload the page:

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

    The terminal attaches to the same shell, and the count has kept going while the page was away. tmux keeps earlier output in its history. Scroll back with the mouse wheel, and press q to leave the history.

  10. Open the same URL in a second browser tab. Both tabs show the same screen, and you can type in either. When the tabs are different sizes, tmux fits the screen to the tab used most recently and fills the rest of a larger tab with dots. To open a separate shell in the same sandbox, add ?session=build to the URL.

Run exit in the shell to end the session. The page shows [Terminal closed with code 0] and does not reconnect. To leave the session running, detach with CTRL + B then D, and reload the page to attach again.

Run another command in the terminal

To run an interactive command instead of the default shell, such as the command-line interface of a coding agent, add it after the session name in the exec() call. tmux runs the command when it creates the session:

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

Install the command in the Dockerfile.

A coding agent also calls its model over the network, which enableInternet: false blocks. To route its requests through your Worker and allow only the hostnames that it needs, refer to interceptOutboundHttps().

End a session from your Worker

To end a session and every process in it without a browser, run tmux kill-session in your Durable Object:

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

The = matches the session name exactly instead of as a prefix. To list sessions, run tmux list-sessions.

Was this helpful?