#!/bin/bash # Sets up a private cloud browser on an exe.dev VM (Ubuntu 24.04, Docker): # Google Chrome (or Microsoft Edge, or Chromium) in a LinuxServer.io Selkies # container, streamed to a browser tab on port 3000, behind exe.dev login: # https://.exe.xyz:3000/ # Chrome's DevTools protocol (CDP) for Playwright, Puppeteer and agents on # port 9222: http://localhost:9222 on the VM, https://.exe.xyz:9222/ elsewhere. # Playwright MCP on localhost:8931 for agents on the VM. # Everything lives in ~/browser; the profile (logins, cookies) in ~/browser/config. # Run it as the VM's user (exedev); it uses sudo. Safe to run again. # BROWSER=edge or BROWSER=chromium picks another browser (default: chrome). # TZ=Europe/Berlin sets the browser's time zone (default: Etc/UTC). # From https://exe.dev/deploy/cloud-browser set -euo pipefail BROWSER=${BROWSER:-chrome} TZ=${TZ:-Etc/UTC} DIR=$HOME/browser case "$BROWSER" in chrome) IMAGE=lscr.io/linuxserver/chrome:latest CLI=CHROME_CLI TITLE="Google Chrome" ;; edge) IMAGE=lscr.io/linuxserver/msedge:latest CLI=EDGE_CLI TITLE="Microsoft Edge" ;; chromium) IMAGE=lscr.io/linuxserver/chromium:latest CLI=CHROME_CLI TITLE="Chromium" ;; *) echo "BROWSER must be chrome, edge or chromium" >&2; exit 1 ;; esac echo "== docker" # Docker is socket-activated on exe.dev's image; enable it so the browser comes back after a reboot. sudo systemctl enable --now docker.service echo "== files in $DIR" mkdir -p "$DIR/config/Downloads" "$DIR/init" # The browser's flags. A profile outside the default place is what lets Chrome # open its DevTools port; the port itself stays inside the container. FLAGS="--user-data-dir=/config/profile --remote-debugging-port=9222 --hide-crash-restore-bubble" cat > "$DIR/compose.yaml" < "$DIR/cdp.conf" <<'NGINX' map $http_upgrade $connection_upgrade { default upgrade; '' close; } map $http_x_forwarded_proto $ws_scheme { https wss; default ws; } server { listen 9223; location / { proxy_pass http://127.0.0.1:9222; proxy_http_version 1.1; proxy_set_header Host localhost; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Accept-Encoding ""; proxy_read_timeout 1d; sub_filter_types application/json; sub_filter_once off; sub_filter "ws://localhost/" "$ws_scheme://$http_host/"; } } NGINX cat > "$DIR/close-browser.py" <<'PY' #!/lsiopy/bin/python3 # Ask the browser to quit over its DevTools port, then give it a moment to save. import json, time, urllib.request from websockets.sync.client import connect try: url = json.load(urllib.request.urlopen("http://127.0.0.1:9222/json/version", timeout=3))["webSocketDebuggerUrl"] with connect(url, open_timeout=3) as ws: ws.send(json.dumps({"id": 1, "method": "Browser.close"})) ws.recv(timeout=10) except Exception as e: print("close-browser:", e) time.sleep(2) PY chmod 755 "$DIR/close-browser.py" # The browser locks its profile to the container's host name and process; a # lock left by a stopped container would keep it from opening the profile. cat > /tmp/clear-locks.sh <<'SH' #!/bin/sh rm -f /config/profile/Singleton* SH sudo install -o root -g root -m 755 /tmp/clear-locks.sh "$DIR/init/clear-locks.sh" rm -f /tmp/clear-locks.sh echo "== start (the first pull is about 2 GB)" cd "$DIR" docker compose pull -q docker compose up -d --remove-orphans echo "== checks" for i in $(seq 90); do curl -fs -o /dev/null localhost:9222/json/version && break; sleep 2; done curl -fsS -o /dev/null -w 'Browser page: HTTP %{http_code}\n' localhost:3000/ curl -fsS localhost:9222/json/version | grep -E '"Browser"' echo "Open: https://$(hostname).exe.xyz:3000/" echo "CDP: http://localhost:9222 on the VM, https://$(hostname).exe.xyz:9222/ elsewhere" echo "MCP: http://localhost:8931/mcp on the VM"