# Chrome and Chromium (https://tenki.cloud/docs/sandbox/chrome)

> For the complete documentation index, see [llms.txt](https://tenki.cloud/llms.txt)

Run Chrome-family browsers headless or headed in a Tenki Sandbox, connect over CDP, watch through noVNC, and give a computer-use agent control.

A sandbox session is a full Linux VM, so a browser runs in it the same way it runs on any Ubuntu box. This guide installs Google Chrome once, captures it in a snapshot, and then starts two kinds of browser sessions from that snapshot: a headless one for scraping, screenshots, and tests, and a headed one with a virtual display that a person can watch through noVNC while an agent drives it over the [Chrome DevTools Protocol (CDP)](https://chromedevtools.github.io/devtools-protocol/).

Two Tenki features matter for this guide. Snapshots capture memory as well as disk, so a headed browser session restores with Chrome, the display server, and the VNC bridge already running. `expose` returns HTTPS preview URLs that carry WebSocket traffic, which is all CDP and noVNC need to reach the session from outside.

Headless is the default choice and covers scraping, testing, screenshots, and anything else that works from the DOM. Reach for the headed stack when a person needs to see the browser.

The page is one path with a fork near the end:

1. [Bake Chrome into a snapshot](#bake-chrome-into-a-snapshot).
2. [Run one-shot headless commands](#headless-dump-a-page-and-take-a-screenshot) from that snapshot.
3. [Keep Chrome running as a service](#keep-chrome-running-and-connect-to-it), expose it through nginx, and attach with Playwright.
4. [Add a virtual display](#headed-add-a-display-someone-can-watch) when someone needs to watch.
5. [Patterns for the code that drives it](#patterns-for-callers), then [other browsers](#other-browsers) and [clean up](#clean-up).

You need the CLI or the TypeScript SDK installed and authenticated (see the [Quickstart](https://tenki.cloud/docs/sandbox/quickstart.md)), plus `playwright-core` in the project that will drive the browser:

```bash
npm install @tenkicloud/sandbox playwright-core
```

## ## Before you start

A few facts about the guest shape the commands that follow:

* You run as `tenki`, with passwordless `sudo`. Chrome's process sandbox works for a non-root user, so leave `--no-sandbox` out. Package installs need the `sudo` prefix.
* A fresh session has no apt package lists. `sudo apt-get update` comes before the first install.
* `/dev/shm` is a tmpfs sized to half the session's memory, 2 GB at the default 4 GB, so `--disable-dev-shm-usage` is unnecessary.
* The CLI's `exec` waits as long as the command takes. The SDKs stop waiting after 30 seconds unless you pass a longer timeout, so give installs one.
* `write`, `read`, and the SDK file APIs work inside `/home/tenki` only. Files that belong under `/etc` get written to the home directory first and moved with `sudo install`.
* Chrome prints DBus and `machine-id` errors on stderr in the minimal image. They do not indicate a failure.

Each shell script on this page appears once, as a file you keep next to your code. The CLI runs a script with `tenki sandbox exec -c "$(cat script.sh)"`. The TypeScript examples read the same file and run it through this helper, which uses `bash -lc` and throws on a non-zero exit:

```typescript

const sandbox = new TenkiSandbox(); // reads TENKI_API_KEY

async function sh(session: Session, script: string, timeoutMs = 600_000) {
  const result = await session.exec("bash", { args: ["-lc", script], timeoutMs });
  if (!isSuccess(result.status)) {
    throw new Error(`exit=${result.exitCode}\n${stdoutText(result)}\n${stderrText(result)}`);
  }
  return stdoutText(result);
}

const runScript = async (session: Session, file: string) => sh(session, await readFile(file, "utf8"));
```

Pick your tool in the first tab group and it sticks for the rest of the page.

## ## Bake Chrome into a snapshot

Installing the browser is the only slow step, so do it once. Create a builder session, install Chrome, snapshot, and throw the builder away. The script ends by printing the version so a broken install cannot slip into the snapshot silently:

```bash title="install-chrome.sh"
set -e
export DEBIAN_FRONTEND=noninteractive
sudo apt-get update -qq
curl -fsSL -o /tmp/google-chrome.deb https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
sudo -E apt-get install -y -qq --no-install-recommends /tmp/google-chrome.deb
rm /tmp/google-chrome.deb
google-chrome --version
```

**TypeScript**
```typescript
const builder = await sandbox.createAndWait({ name: "chrome-builder", diskSizeGb: 10 });

console.log(await runScript(builder, "./install-chrome.sh")); // ends with: Google Chrome 153...

const snapshot = await sandbox.createSnapshotAndWait(builder.id, { name: "chrome-stable", timeoutMs: 600_000 });
await builder.close();
```

**CLI**
```bash
tenki sandbox create --name chrome-builder --disk-size-gb 10

tenki sandbox exec -c "$(cat install-chrome.sh)"

# Waits for READY and prints the snapshot ID
tenki sandbox snapshot create --name chrome-stable

tenki sandbox terminate
```

From here on, any session started with this snapshot has `google-chrome` on its `PATH`. `create` selects the new session for the CLI commands that follow. A snapshot covers the whole root disk, so the 10 GB builder disk keeps it small while leaving room for the display stack later.

### ### The same recipe as a template

If you would rather commit the recipe to a file than snapshot a live session, put the install in a [template](https://tenki.cloud/docs/sandbox/templates.md). Without a Git context, one `run` step is all this needs:

```json title="chrome-template.json"
{
  "specVersion": "tenki.template.v1",
  "base": { "image": "sandbox" },
  "steps": [
    {
      "run": {
        "command": "export DEBIAN_FRONTEND=noninteractive && sudo apt-get update -qq && curl -fsSL -o /tmp/google-chrome.deb https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb && sudo -E apt-get install -y -qq --no-install-recommends /tmp/google-chrome.deb && rm /tmp/google-chrome.deb && google-chrome --version"
      }
    }
  ]
}
```

```bash
# Builds the image and prints its digest ref, <workspace>/chrome-stable@sha256:…
tenki sandbox template build chrome-stable --file chrome-template.json

# Sessions start from the image ref instead of a snapshot ID
tenki sandbox create --image <workspace>/chrome-stable
```

The rest of the guide sticks with snapshots because the headed stack is assembled interactively. Anywhere you see `--snapshot <id>`, `--image <ref>` works the same way.

## ## Headless: dump a page and take a screenshot

Start a session from the snapshot and call Chrome with its [headless](https://developer.chrome.com/docs/chromium/headless) flags. Nothing else needs to be running.

**TypeScript**
```typescript
import { writeFile } from "node:fs/promises";

// `await using` terminates the session when it leaves scope.
await using vm = await sandbox.createAndWait({ name: "chrome-headless", snapshotId: snapshot.id });

const chrome = "/usr/bin/google-chrome --headless";

const dom = await sh(vm, `${chrome} --dump-dom https://example.com 2>/dev/null | grep -o '<h1>.*</h1>'`);
console.log(dom.trim()); // <h1>Example Domain</h1>

await sh(vm, `${chrome} --window-size=1280,800 --screenshot=/home/tenki/example.png https://example.com 2>/dev/null`);

// Pull the screenshot down to the machine running this script
await writeFile("example.png", await vm.readFile("/home/tenki/example.png"));
```

**CLI**
```bash
tenki sandbox create --name chrome-headless --snapshot <snapshot-id>

tenki sandbox exec -c 'google-chrome --headless --dump-dom https://example.com 2>/dev/null | grep -o "<h1>.*</h1>"'

tenki sandbox exec -c 'google-chrome --headless --window-size=1280,800 --screenshot=/home/tenki/example.png https://example.com'

# Pull the screenshot down to your machine
tenki sandbox read --path /home/tenki/example.png --out ./example.png

# Done with it: the snapshot keeps the install, the session bills until terminated
tenki sandbox terminate
```

Save output under `/home/tenki` rather than `/tmp`. `read` and the SDK file APIs cannot see `/tmp`, and it is wiped when a session pauses.

## ## Keep Chrome running and connect to it

For anything more than one-shot commands, run Chrome as a systemd service with remote debugging on. Chrome binds the debugging port to `127.0.0.1` only, so nothing outside the VM can reach it until you put a proxy in front. That is the right default: anyone who can reach that port controls the browser.

```ini title="chrome-cdp.service"
[Unit]
Description=Headless Chrome DevTools
After=network-online.target

[Service]
User=tenki
ExecStart=/usr/bin/google-chrome --headless --remote-debugging-port=9222 --remote-allow-origins=* --user-data-dir=/home/tenki/.config/chrome-cdp about:blank
Restart=always

[Install]
WantedBy=multi-user.target
```

`--remote-allow-origins=*` is not optional. Chrome refuses WebSocket upgrades that carry an `Origin` header it does not recognize, and Playwright sends one, so without the flag `connectOverCDP` connects and is immediately dropped.

Two things stand between port 9222 and a preview URL. A preview URL only reaches ports bound on all interfaces, and Chrome's debugger listens on loopback, so exposing 9222 directly returns `502 Bad Gateway`. Chrome also checks the `Host` header on its DevTools HTTP endpoints and accepts only an IP address or `localhost`, which a forwarded preview hostname is not. A small nginx site solves both. It listens on 9333 on all interfaces, forwards to Chrome on 9222 with the `Host` header rewritten to the loopback origin, and keeps WebSocket upgrades intact with read timeouts that match the eight-hour exposures used below, so an idle CDP connection is not cut off before its preview URL expires:

```nginx title="chrome-cdp.nginx"
server {
  listen 9333;

  location / {
    proxy_pass http://127.0.0.1:9222;
    proxy_http_version 1.1;
    proxy_set_header Host 127.0.0.1:9222;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_read_timeout 8h;
    proxy_send_timeout 8h;
    proxy_buffering off;
  }
}
```

### ### Start it and expose it

This script installs nginx, puts both files in place, enables the two services, and polls the discovery endpoint through nginx until Chrome answers. The JSON it prints includes `webSocketDebuggerUrl`, which is what a CDP client connects to:

```bash title="start-cdp.sh"
set -e
export DEBIAN_FRONTEND=noninteractive
sudo apt-get update -qq
sudo -E apt-get install -y -qq --no-install-recommends nginx
sudo install -m 644 /home/tenki/chrome-cdp.service /etc/systemd/system/chrome-cdp.service
sudo install -m 644 /home/tenki/chrome-cdp.nginx /etc/nginx/sites-available/chrome-cdp
sudo rm -f /etc/nginx/sites-enabled/default
sudo ln -sf /etc/nginx/sites-available/chrome-cdp /etc/nginx/sites-enabled/chrome-cdp
sudo systemctl daemon-reload
sudo systemctl enable --now chrome-cdp nginx
for i in $(seq 1 30); do curl -fs -o /dev/null http://127.0.0.1:9333/json/version && break; sleep 1; done
curl -fsS http://127.0.0.1:9333/json/version
```

Start the session from the Chrome snapshot and mark it `sticky`. A browser that agents connect to over hours should not hit the default maximum duration or get paused for looking idle. You terminate it yourself when the work is done. Then expose 9333. `expose` returns an HTTPS preview URL, and the CDP WebSocket rides over it unchanged. Exposures belong to the session, not the snapshot, so repeat this after every restore.

**TypeScript**
```typescript
const cdpVm = await sandbox.createAndWait({ name: "chrome-cdp", snapshotId: snapshot.id, sticky: true });

for (const name of ["chrome-cdp.service", "chrome-cdp.nginx"]) {
  await cdpVm.writeFile(`/home/tenki/${name}`, await readFile(`./${name}`));
}
console.log(await runScript(cdpVm, "./start-cdp.sh"));

const cdp = await cdpVm.exposePort(9333, { ttlMs: 8 * 3600_000 });
console.log(`cdp: ${cdp.previewUrl}/json/version`);
```

**CLI**
```bash
tenki sandbox create --name chrome-cdp --snapshot <snapshot-id> --sticky

tenki sandbox write --path /home/tenki/chrome-cdp.service --data-file ./chrome-cdp.service
tenki sandbox write --path /home/tenki/chrome-cdp.nginx --data-file ./chrome-cdp.nginx
tenki sandbox exec -c "$(cat start-cdp.sh)"

tenki sandbox expose --port 9333 --ttl 8h
```

`<9333 preview URL>/json/version` is the CDP discovery endpoint.

Chrome advertises its WebSocket endpoint as `ws://127.0.0.1:9222/devtools/browser/<id>`, because that is the address it sees. Swap that origin for the CDP preview URL, as `wss://`, and Playwright can attach to it from anywhere. This helper does the discovery and the swap and returns a connected browser plus its first page:

```typescript

async function attach(cdpUrl: string): Promise<{ browser: Browser; page: Page }> {
  const res = await fetch(`${cdpUrl}/json/version`);
  if (!res.ok) throw new Error(`CDP discovery returned ${res.status}`);
  const { webSocketDebuggerUrl } = (await res.json()) as { webSocketDebuggerUrl: string };

  const endpoint = webSocketDebuggerUrl.replace("ws://127.0.0.1:9222", cdpUrl.replace(/^https:/, "wss:"));
  const browser = await chromium.connectOverCDP(endpoint);

  const context = browser.contexts()[0] ?? (await browser.newContext());
  const page = context.pages()[0] ?? (await context.newPage());
  return { browser, page };
}
```

With that in place, a session looks like ordinary Playwright:

```typescript
const { browser, page } = await attach(cdp.previewUrl);

await page.goto("https://example.com", { waitUntil: "domcontentloaded" });
console.log(await page.locator("h1").innerText()); // Example Domain
await page.screenshot({ path: "headless.png" });

await browser.close(); // disconnects; Chrome keeps running under systemd
```

`browser.close()` on a CDP connection only drops the connection. Chrome, its tabs, cookies, and profile all stay put, and the next `attach` finds them.

## ## Headed: add a display someone can watch

Headed is for when a person needs to see the browser: debugging a flow, taking over when the agent stalls, or supervising a computer-use agent that navigates by screenshots. It is also the answer for sites that behave differently without a real window. Unlike headless, it needs display packages.

A visible browser needs a display and a way to see it from outside. A whole desktop environment is overkill for that, and so is a window manager: Chrome sizes and places its own window from its flags. Three packages cover it:

* **Xvfb** runs an X server in memory, with no GPU or monitor.
* **x11vnc** serves that X display over VNC on loopback.
* **noVNC** (with websockify) turns the VNC stream into a web page.

nginx joins the list to front CDP, and `x11-utils` brings the `xdpyinfo` and `xwininfo` used to check the display.

### ### The display units

Each layer gets its own systemd unit, chained with `Requires` so they come up in order. Chrome runs as `tenki` so the browser profile lands in the home directory. Xvfb, x11vnc, and websockify run as root, which is fine on a single-user VM.

**xvfb.service**
```ini title="xvfb.service"
[Unit]
Description=Virtual X display

[Service]
ExecStart=/usr/bin/Xvfb :0 -screen 0 1440x900x24 -nolisten tcp
Restart=always

[Install]
WantedBy=multi-user.target
```

**x11vnc.service**
```ini title="x11vnc.service"
[Unit]
Description=VNC server for the headed browser
After=xvfb.service
Requires=xvfb.service

[Service]
ExecStart=/usr/bin/x11vnc -display :0 -forever -shared -nopw -localhost -rfbport 5900
Restart=always

[Install]
WantedBy=multi-user.target
```

**novnc.service**
```ini title="novnc.service"
[Unit]
Description=noVNC web client
After=x11vnc.service
Requires=x11vnc.service

[Service]
ExecStart=/usr/bin/websockify --web /usr/share/novnc 6080 localhost:5900
Restart=always

[Install]
WantedBy=multi-user.target
```

**chrome-headed.service**
```ini title="chrome-headed.service"
[Unit]
Description=Google Chrome on the virtual display
After=xvfb.service
Requires=xvfb.service

[Service]
User=tenki
Environment=DISPLAY=:0
ExecStartPre=/bin/bash -c 'for i in {1..30}; do xdpyinfo >/dev/null 2>&1 && exit 0; sleep 1; done; exit 1'
ExecStartPre=/bin/bash -c 'mkdir -p "$HOME/.config/chrome-headed"; rm -f "$HOME/.config/chrome-headed"/Singleton*'
ExecStart=/usr/bin/google-chrome --remote-debugging-port=9222 --remote-allow-origins=* --user-data-dir=/home/tenki/.config/chrome-headed --window-size=1440,900 --window-position=0,0 --no-first-run --no-default-browser-check https://example.com
Restart=always

[Install]
WantedBy=multi-user.target
```

`chrome-headed.service` is the headless unit with `--headless` removed, `DISPLAY` set, and window flags added. The first `ExecStartPre` waits for the X server; the second clears Chrome's singleton lock so a restart after a snapshot restore does not think another instance is running. The nginx site is `chrome-cdp.nginx` from the previous section, unchanged.

### ### Build it, check it, snapshot it

Start a second builder from the Chrome snapshot and copy in the four units and the nginx site. This script installs the packages, enables everything, and confirms three things before you take the snapshot: noVNC serves its page, CDP discovery answers through nginx, and a Chrome window exists on the display.

```bash title="setup-display.sh"
set -e
export DEBIAN_FRONTEND=noninteractive

# The window title the readiness checks wait for. Swap it with the binary:
# "Brave", "Microsoft Edge", "Chromium" (see Other browsers).
WINDOW_TITLE="Google Chrome"

sudo apt-get update -qq
sudo -E apt-get install -y -qq --no-install-recommends xvfb x11vnc novnc x11-utils nginx

sudo install -m 644 /home/tenki/*.service /etc/systemd/system/
sudo install -m 644 /home/tenki/chrome-cdp.nginx /etc/nginx/sites-available/chrome-cdp
sudo rm -f /etc/nginx/sites-enabled/default
sudo ln -sf /etc/nginx/sites-available/chrome-cdp /etc/nginx/sites-enabled/chrome-cdp
sudo systemctl daemon-reload
sudo systemctl enable --now xvfb chrome-headed x11vnc novnc nginx

for i in $(seq 1 45); do
  curl -fs -o /dev/null http://127.0.0.1:6080/vnc.html &&
  curl -fs -o /dev/null http://127.0.0.1:9333/json/version &&
  DISPLAY=:0 xwininfo -root -tree | grep -q "$WINDOW_TITLE" &&
  break
  sleep 1
done
systemctl is-active xvfb chrome-headed x11vnc novnc nginx
DISPLAY=:0 xwininfo -root -tree | grep "$WINDOW_TITLE"
```

**TypeScript**
```typescript
const headed = await sandbox.createAndWait({ name: "chrome-headed-builder", snapshotId: snapshot.id });

for (const name of ["xvfb.service", "x11vnc.service", "novnc.service", "chrome-headed.service", "chrome-cdp.nginx"]) {
  await headed.writeFile(`/home/tenki/${name}`, await readFile(`./${name}`));
}
console.log(await runScript(headed, "./setup-display.sh"));

const headedSnapshot = await sandbox.createSnapshotAndWait(headed.id, { name: "chrome-headed", timeoutMs: 600_000 });
await headed.close();
```

**CLI**
```bash
tenki sandbox create --name chrome-headed-builder --snapshot <snapshot-id>

for f in xvfb.service x11vnc.service novnc.service chrome-headed.service chrome-cdp.nginx; do
  tenki sandbox write --path "/home/tenki/$f" --data-file "./$f"
done
tenki sandbox exec -c "$(cat setup-display.sh)"

tenki sandbox snapshot create --name chrome-headed
tenki sandbox terminate
```

If any unit reports something other than `active`, `sudo journalctl -u <unit> --no-pager -n 50` will say why. Fix it before snapshotting, because the snapshot freezes whatever state the services are in.

A [snapshot](https://tenki.cloud/docs/sandbox/snapshots.md) includes memory, so a session restored from `chrome-headed` does not boot and start services. It resumes with Xvfb, Chrome, x11vnc, noVNC, and nginx in the state they were in when the snapshot was taken.

### ### Hand out the URLs

Start the browser session from the headed snapshot, sticky as before, and expose two ports: 6080 for people, 9333 for CDP clients. Both WebSocket streams, the VNC framebuffer and the CDP channel, ride over their preview URLs unchanged.

**TypeScript**
```typescript
const browserVm = await sandbox.createAndWait({
  name: "computer-use-browser",
  snapshotId: headedSnapshot.id,
  sticky: true,
});

const vnc = await browserVm.exposePort(6080, { ttlMs: 8 * 3600_000 });
const headedCdp = await browserVm.exposePort(9333, { ttlMs: 8 * 3600_000 });

console.log(`watch: ${vnc.previewUrl}/vnc.html?autoconnect=true&reconnect=true&resize=scale&path=websockify`);
console.log(`cdp:   ${headedCdp.previewUrl}/json/version`);
```

**CLI**
```bash
tenki sandbox create --name computer-use-browser --snapshot <headed-snapshot-id> --sticky

tenki sandbox expose --port 6080 --ttl 8h
tenki sandbox expose --port 9333 --ttl 8h
```

Open `<6080 preview URL>/vnc.html?autoconnect=true&reconnect=true&resize=scale&path=websockify` in a browser to watch. `<9333 preview URL>/json/version` is the CDP discovery endpoint.

The 6080 URL is the whole desktop, not one page. x11vnc runs with `-nopw` behind `-localhost`, so that preview URL is the only thing standing between the public internet and full keyboard and mouse control of a browser holding whatever you are signed in to. Treat it as a bearer secret: keep the TTL no longer than the work needs, keep it out of shared channels, and [unexpose](#clean-up) it as soon as you are done.

There is one Chrome behind both URLs. `attach` from the previous section works against the 9333 URL exactly as it did headless. Whatever Playwright does shows up in the noVNC tab as it happens, and if someone clicks or types in noVNC, Playwright's next screenshot reflects it.

## ## Patterns for callers

### ### Short-lived callers

Because `browser.close()` leaves Chrome running, the session is a good fit for stateless workers such as serverless functions or queue consumers. Each invocation attaches, does one bounded piece of work, and disconnects. Nothing about the browser needs to survive in the worker's memory between calls, because the session is holding it:

```typescript
export async function readHeadline(cdpUrl: string, target: string) {
  const { browser, page } = await attach(cdpUrl);
  try {
    await page.goto(target, { waitUntil: "domcontentloaded" });
    return { url: page.url(), headline: await page.locator("h1").first().innerText() };
  } finally {
    await browser.close();
  }
}
```

### ### A tool surface for an agent

Give a computer-use agent a small set of verbs rather than the whole Playwright API. Include what the model needs to see and act on the page, and nothing that touches infrastructure:

```typescript
function browserTools(page: Page) {
  return {
    look: () => page.screenshot({ type: "png" }),
    open: (url: string) => page.goto(url, { waitUntil: "domcontentloaded" }),
    click: (x: number, y: number) => page.mouse.click(x, y),
    type: (text: string) => page.keyboard.type(text),
    scroll: (dy: number) => page.mouse.wheel(0, dy),
    readText: () => page.locator("body").innerText(),
  };
}
```

Keep session lifecycle, snapshot deletion, and port exposure out of the agent's tools. Those calls belong to the code that runs the agent.

## ## Other browsers

The [base image](https://tenki.cloud/docs/sandbox/base-image.md) is Ubuntu 24.04. Ubuntu's `chromium-browser` apt package is only a stub that hands off to the Chromium snap, and sessions do not run `snapd`, so that package does not give you a browser. These three work:

| Browser                                   | How to install                                | Binary                                              |
| ----------------------------------------- | --------------------------------------------- | --------------------------------------------------- |
| Google Chrome Stable (used in this guide) | Google's `.deb` from `dl.google.com`          | `/usr/bin/google-chrome`                            |
| Chromium (Chrome for Testing build)       | `npx playwright install --with-deps chromium` | under `~/.cache/ms-playwright/`                     |
| Brave, Microsoft Edge                     | The vendor's apt repository                   | `/usr/bin/brave-browser`, `/usr/bin/microsoft-edge` |

They take the same headless, CDP, and display flags, so everything above applies to any of them once you swap the binary path in the units and the `WINDOW_TITLE` the readiness checks wait for.

**Chromium.** Playwright ships a Chromium build (Chrome for Testing) and knows which Ubuntu packages it depends on. Node and `npx` are already on the base image:

```bash
tenki sandbox exec --timeout 10m -c 'npx --yes playwright@latest install --with-deps chromium'
```

The browser lands at `/home/tenki/.cache/ms-playwright/chromium-<build>/chrome-linux64/chrome`, with a `chromium_headless_shell-<build>` variant beside it. Use that full path wherever this guide says `/usr/bin/google-chrome`. Pin the `playwright` version if you also plan to launch the browser from Playwright inside the session, since Playwright wants its matching build. A CDP connection from outside does not care.

**Brave and Edge.** Both publish apt repositories for Ubuntu. The image has no `gpg`, so the keys are stored as downloaded; apt reads an armored `.asc` key from `signed-by` directly. Register the repository, install, and reuse every unit in this guide with the new binary path:

```bash
tenki sandbox exec --timeout 10m -c 'set -e
export DEBIAN_FRONTEND=noninteractive

# Brave
sudo curl -fsSLo /usr/share/keyrings/brave-browser-archive-keyring.gpg https://brave-browser-apt-release.s3.brave.com/brave-browser-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/brave-browser-archive-keyring.gpg] https://brave-browser-apt-release.s3.brave.com/ stable main" | sudo tee /etc/apt/sources.list.d/brave-browser-release.list >/dev/null

# Microsoft Edge
sudo curl -fsSLo /usr/share/keyrings/microsoft-edge.asc https://packages.microsoft.com/keys/microsoft.asc
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/microsoft-edge.asc] https://packages.microsoft.com/repos/edge stable main" | sudo tee /etc/apt/sources.list.d/microsoft-edge.list >/dev/null

sudo apt-get update -qq
sudo -E apt-get install -y -qq --no-install-recommends brave-browser microsoft-edge-stable
brave-browser --version
microsoft-edge --version'
```

Brave installs to `/usr/bin/brave-browser` and Edge to `/usr/bin/microsoft-edge`.

## ## Clean up

Sessions bill per second while they run, and snapshots count against workspace storage. A snapshot cannot be deleted while a session created from it is still active, so terminate those sessions first:

```bash
tenki sandbox unexpose --port 6080
tenki sandbox unexpose --port 9333
tenki sandbox terminate
tenki sandbox snapshot delete <snapshot-id>
```