> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tensorlake.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Drive Chrome over CDP

> Run Google Chrome inside an ubuntu-vnc sandbox and drive it locally through the Chrome DevTools Protocol over a tunnel.

The `tensorlake/ubuntu-vnc` image ships with Google Chrome pre-installed. Combined with [Local Tunnels](/sandboxes/tunnels), this gives you a real, sandboxed Chrome that any DevTools-Protocol client (Playwright, Puppeteer, `chrome-remote-interface`, plain WebSocket) can drive from your laptop as if it were running locally — no headless container, no screenshot polling, no public port.

If you want to drive the whole XFCE desktop (mouse, keyboard, screenshots) instead of just Chrome, use the higher-level [Computer Use](/sandboxes/computer-use) API — it talks to the same `tensorlake/ubuntu-vnc` image through `connect_desktop()` / `connectDesktop()`. The two workflows compose: keep the agent loop on CDP and attach a human reviewer over VNC.

This guide walks through:

1. Launching `tensorlake/ubuntu-vnc`.
2. Starting Chrome with CDP enabled on the desktop session.
3. Tunneling the CDP port to `127.0.0.1`.
4. Driving the browser from Python or Playwright.

## Prerequisites

```bash theme={null}
curl -fsSL https://tensorlake.ai/install | sh
export TENSORLAKE_API_KEY=your-api-key
```

You can also use `tl login` to obtain a Personal Access Token interactively. The desktop password for the managed `tensorlake/ubuntu-vnc` image is `tensorlake`.

## 1. Launch the Sandbox

```bash theme={null}
tl sbx create -i tensorlake/ubuntu-vnc -c 4 -m 4096 chrome-cdp
```

`chrome-cdp` is a name (optional, but it lets you suspend and resume later). The CLI prints the new sandbox id; reuse it as `<sandbox-id>` below. Four CPUs and 4 GiB of RAM is a comfortable default for a single Chrome session.

## 2. Start Chrome with CDP Enabled

Start Chrome on the existing VNC display (`:1`) as the desktop user (`tl-user`). Two flags matter:

* `--remote-debugging-port=9222` opens the DevTools Protocol endpoint on `127.0.0.1:9222` inside the sandbox.
* `--remote-allow-origins=*` is required by current Chrome versions before they will accept a WebSocket whose `Origin` is anything other than the request host. Without it the HTTP `/json/version` endpoint works but `ws://127.0.0.1:9222/devtools/...` returns `403 Forbidden`.

<Tabs>
  <Tab title="CLI">
    ```bash theme={null}
    tl sbx exec <sandbox-id> -- bash -lc '
      sudo -u tl-user bash -c "
        nohup env DISPLAY=:1 XAUTHORITY=/home/tl-user/.Xauthority \
          google-chrome \
            --no-first-run \
            --no-default-browser-check \
            --remote-debugging-port=9222 \
            --remote-allow-origins=* \
            --user-data-dir=/tmp/chrome-cdp \
            > /tmp/chrome-cdp.log 2>&1 &
        disown
      "
    '
    ```
  </Tab>

  <Tab title="Python">
    ```python theme={null}
    from tensorlake.sandbox import Sandbox

    with Sandbox.connect("<sandbox-id>") as sandbox:
        sandbox.start_process(
            "sudo",
            args=[
                "-u", "tl-user",
                "env",
                "DISPLAY=:1",
                "XAUTHORITY=/home/tl-user/.Xauthority",
                "google-chrome",
                "--no-first-run",
                "--no-default-browser-check",
                "--remote-debugging-port=9222",
                "--remote-allow-origins=*",
                "--user-data-dir=/tmp/chrome-cdp",
            ],
        )
    ```

    `start_process` returns immediately and the sandbox daemon keeps Chrome alive — no `nohup`, no shell, no log redirection. Stdout and stderr are captured by the daemon and reachable via `sandbox.get_stdout(pid)` / `sandbox.get_stderr(pid)` if you want to inspect them.
  </Tab>

  <Tab title="JavaScript">
    ```javascript theme={null}
    import { Sandbox } from "tensorlake";

    const sandbox = await Sandbox.connect({ sandboxId: "<sandbox-id>" });
    await sandbox.startProcess("sudo", {
      args: [
        "-u", "tl-user",
        "env",
        "DISPLAY=:1",
        "XAUTHORITY=/home/tl-user/.Xauthority",
        "google-chrome",
        "--no-first-run",
        "--no-default-browser-check",
        "--remote-debugging-port=9222",
        "--remote-allow-origins=*",
        "--user-data-dir=/tmp/chrome-cdp",
      ],
    });
    ```

    `startProcess` returns once the daemon has spawned the child; Chrome keeps running in the background. Read its output later with `sandbox.getStdout(pid)` / `sandbox.getStderr(pid)`.
  </Tab>
</Tabs>

Confirm CDP is up:

```bash theme={null}
tl sbx exec <sandbox-id> -- bash -lc 'curl -s http://127.0.0.1:9222/json/version'
```

You should see a JSON response with `Browser`, `Protocol-Version`, and `webSocketDebuggerUrl`.

<Note>
  Because Chrome is running on the VNC display `:1`, you can also attach a VNC viewer through the [Local Tunnels](/sandboxes/tunnels) workflow and watch it operate in real time. CDP control and human observation can run side by side.
</Note>

## 3. Open a Tunnel

Forward `127.0.0.1:9222` on your laptop to `127.0.0.1:9222` inside the sandbox:

<Tabs>
  <Tab title="CLI">
    ```bash theme={null}
    tl sbx tunnel <sandbox-id> 9222
    ```

    Leave the command running. Open a second terminal for the rest of this guide.
  </Tab>

  <Tab title="JavaScript">
    ```javascript theme={null}
    import { Sandbox } from "tensorlake";

    const sandbox = await Sandbox.connect({ sandboxId: "<sandbox-id>" });
    const tunnel = await sandbox.createTunnel(9222, { localPort: 9222 });
    console.log(`CDP at http://127.0.0.1:${tunnel.address().port}`);
    // ... drive the browser ...
    await tunnel.close();
    ```
  </Tab>
</Tabs>

Verify locally:

```bash theme={null}
curl http://127.0.0.1:9222/json/version
```

Same JSON, but reached from your laptop. Every byte transits an authenticated WebSocket — port `9222` never has to be in `exposed_ports`.

## 4. Drive the Browser

### Open a Tab

CDP exposes an HTTP control surface on the same port. Open a fresh tab with a `PUT`:

```bash theme={null}
curl -X PUT "http://127.0.0.1:9222/json/new?https://news.ycombinator.com"
```

The response includes a `webSocketDebuggerUrl` for the new tab. List all tabs with `curl http://127.0.0.1:9222/json/list` and close one with `curl http://127.0.0.1:9222/json/close/<target-id>`.

### Playwright

<Tabs>
  <Tab title="Python">
    ```python theme={null}
    from playwright.sync_api import sync_playwright

    with sync_playwright() as p:
        browser = p.chromium.connect_over_cdp("http://127.0.0.1:9222")
        context = browser.contexts[0]
        page = context.new_page()
        page.goto("https://news.ycombinator.com")
        titles = page.locator(".titleline > a").all_text_contents()
        print(titles[:5])
    ```
  </Tab>

  <Tab title="JavaScript">
    ```javascript theme={null}
    import { chromium } from "playwright";

    const browser = await chromium.connectOverCDP("http://127.0.0.1:9222");
    const [context] = browser.contexts();
    const page = await context.newPage();
    await page.goto("https://news.ycombinator.com");
    const titles = await page.locator(".titleline > a").allTextContents();
    console.log(titles.slice(0, 5));
    ```
  </Tab>
</Tabs>

### Raw CDP via WebSocket

When you want to issue protocol calls directly — `Runtime.evaluate`, `Page.navigate`, `DOM.getDocument` — connect to the per-tab WebSocket and exchange JSON messages:

```python theme={null}
import json
import urllib.request
import websocket  # pip install websocket-client

targets = json.loads(urllib.request.urlopen("http://127.0.0.1:9222/json/list").read())
page = next(t for t in targets if t["type"] == "page")

ws = websocket.create_connection(page["webSocketDebuggerUrl"])
ws.send(json.dumps({
    "id": 1,
    "method": "Runtime.evaluate",
    "params": {
        "expression": "document.title",
        "returnByValue": True,
    },
}))
print(json.loads(ws.recv())["result"]["result"]["value"])
ws.close()
```

This is also the path you take when wiring CDP into an LLM agent: expose `open_url`, `evaluate`, and `list_targets` as tools that wrap these calls.

### Coding Agents (`chrome-devtools` MCP)

Claude Code and OpenAI Codex can both drive the same sandboxed Chrome through the official [`chrome-devtools-mcp`](https://github.com/ChromeDevTools/chrome-devtools-mcp) server. The MCP attaches to an existing Chrome via `--browser-url`; match that URL to the tunnel's local port and no other configuration is needed — using Chrome's canonical `9222` on both sides keeps everything default-on-default.

Register the MCP once for your user:

<Tabs>
  <Tab title="Claude Code">
    ```bash theme={null}
    claude mcp add chrome-devtools -- npx chrome-devtools-mcp@latest \
      --browser-url http://127.0.0.1:9222
    ```

    Stored at user scope by default. Pass `--scope project` to write it to the current project's `.mcp.json` instead.
  </Tab>

  <Tab title="Codex">
    ```bash theme={null}
    codex mcp add chrome-devtools -- npx chrome-devtools-mcp@latest \
      --browser-url http://127.0.0.1:9222
    ```

    Writes to `~/.codex/config.toml` (or `$CODEX_HOME/config.toml`). Codex has no project-vs-user scope — the file is always user-global. The equivalent block, if you prefer to edit the file by hand:

    ```toml theme={null}
    [mcp_servers.chrome-devtools]
    command = "npx"
    args = ["chrome-devtools-mcp@latest", "--browser-url", "http://127.0.0.1:9222"]
    ```
  </Tab>
</Tabs>

The `--browser-url` flag is what tells the MCP to attach to an existing Chrome instead of launching its own.

With Chrome already running inside the sandbox (step 2) and a tunnel open at the default local port:

```bash theme={null}
tl sbx tunnel <sandbox-id> 9222
```

restart the agent so it picks up the new MCP (Claude Code re-reads on launch; Codex reads `config.toml` at startup and does not hot-reload), then ask it to do something in the browser:

```
> open https://news.ycombinator.com and read the first headline
```

The agent routes that through `chrome-devtools` → `127.0.0.1:9222` → tunnel → sandbox Chrome on display `:1`.

If port `9222` is already taken on your laptop (a local Chrome with debugging on, another tunnel, etc.), pick any free port for both sides and keep them aligned:

<Tabs>
  <Tab title="Claude Code">
    ```bash theme={null}
    # tunnel the sandbox's 9222 to local 12222
    tl sbx tunnel <sandbox-id> 9222 --listen-port 12222
    # point the MCP at the same local port
    claude mcp add chrome-devtools -- npx chrome-devtools-mcp@latest \
      --browser-url http://127.0.0.1:12222
    ```
  </Tab>

  <Tab title="Codex">
    ```bash theme={null}
    # tunnel the sandbox's 9222 to local 12222
    tl sbx tunnel <sandbox-id> 9222 --listen-port 12222
    # point the MCP at the same local port
    codex mcp add chrome-devtools -- npx chrome-devtools-mcp@latest \
      --browser-url http://127.0.0.1:12222
    ```
  </Tab>
</Tabs>

<Note>
  Verify the path before you point an agent at it: `curl http://127.0.0.1:9222/json/version` should return Chrome's JSON. The tunnel CLI keeps the local port bound even when the sandbox upstream goes away (terminated, suspended without auto-resume), so a hung `curl` usually means the sandbox is gone, not that the MCP is misconfigured.
</Note>

## 5. Tear Down

Stop the tunnel with `Ctrl+C`. Stop Chrome inside the sandbox when you no longer need it:

```bash theme={null}
tl sbx exec <sandbox-id> -- bash -lc 'sudo -u tl-user pkill -f google-chrome || true'
```

Suspend the sandbox to keep the user-data-dir warm for next time, or terminate it to release resources:

```bash theme={null}
tl sbx suspend <sandbox-id>     # named sandboxes only
tl sbx terminate <sandbox-id>
```

## Notes and Pitfalls

* **`--remote-allow-origins=*` is required** for Chrome ≥ 111. Without it, the HTTP CDP endpoints work but every WebSocket handshake fails with `403`. Restart Chrome with the flag if you forget.
* **Bind address.** `--remote-debugging-port` only listens on `127.0.0.1` by default, which is exactly what you want — the tunnel forwards to `127.0.0.1` inside the sandbox, so DevTools stays unreachable from anywhere else.
* **`--user-data-dir` is required for CDP.** Chrome ≥ 136 refuses to enable `--remote-debugging-port` against the default profile and prints `DevTools remote debugging requires a non-default data directory. Specify this using --user-data-dir.` to its log. Always pass `--user-data-dir=/tmp/<something>` (or any path other than `~/.config/google-chrome`).
* **Headless mode.** If you do not need the VNC view, you can launch with `--headless=new` instead of attaching to display `:1`. The tunneling and CDP usage remain identical.
* **Sandboxing inside containers.** Chrome's setuid sandbox sometimes fails inside container/VM combinations. If you see `Failed to move to new namespace` errors, add `--no-sandbox` to the launch flags.
* **Multiple agents.** Each tab has its own `webSocketDebuggerUrl`. Two clients can drive different tabs of the same Chrome at the same time — useful when an agent loop and a human reviewer both want a window.

## Related Guides

* [Computer Use](/sandboxes/computer-use) — drive the full XFCE desktop (mouse, keyboard, screenshots) on the same `tensorlake/ubuntu-vnc` image.
* [Local Tunnels](/sandboxes/tunnels) — the tunneling primitive that carries CDP traffic from your laptop into the sandbox.
* [Snapshots](/sandboxes/snapshots) — fork a warmed-up Chrome profile so parallel agents start with cookies, history, and extensions already in place.
