Skip to main content

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.

The ubuntu-vnc image ships with Google Chrome pre-installed. Combined with Local 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. This guide walks through:
  1. Launching 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

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 ubuntu-vnc image is tensorlake.

1. Launch the Sandbox

tl sbx create -i 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.
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
  "
'
Confirm CDP is up:
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.
Because Chrome is running on the VNC display :1, you can also attach a VNC viewer through the Local Tunnels workflow and watch it operate in real time. CDP control and human observation can run side by side.

3. Open a Tunnel

Forward 127.0.0.1:9222 on your laptop to 127.0.0.1:9222 inside the sandbox:
tl sbx tunnel <sandbox-id> 9222
Leave the command running. Open a second terminal for the rest of this guide.
Verify locally:
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:
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

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])
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));

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:
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.

5. Tear Down

Stop the tunnel with Ctrl+C. Stop Chrome inside the sandbox when you no longer need it:
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:
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.