| name | fly-browser |
| description | Install and drive a long-lived, headed Chromium on a Fly.io machine the user already provisioned, exposed over CDP for AI automation with a live watch-along UI for humans. Use when the user wants to turn their existing Fly Machine into a remote browser, needs a persistent browser an agent can control across sessions, asks to scrape a page, automate a browser flow, fill a form, log into a site, take a screenshot, or otherwise drive a real remote browser. Also use when asked to install, reinstall, or check the status of the fly-browser stack on a box. |
fly-browser
Turns a Fly Machine the user already owns and has provisioned into a
persistent remote browser: a docker-compose stack (headed Chromium + CDP +
KasmVNC watch-along UI) installed onto the box over SSH, kept running by
systemd. This skill never creates, deploys, or destroys the Fly app/machine
itself — that's the user's box, on their terms. It only installs software on
it and drives the resulting browser.
| Endpoint | Who uses it | Reachable from |
|---|
| CDP, port 9222 | agents, via a WireGuard tunnel | nowhere publicly — only through fly proxy / scripts/cdp-tunnel.sh |
| KasmVNC web UI, port 3000 | the human, to watch / take over | nowhere publicly — only through fly proxy / scripts/watch-tunnel.sh, behind HTTP Basic auth |
The Chrome user-data dir persists at /var/lib/fly-browser/chrome-profile on
the box's own disk, so logins, cookies, and extensions survive container and
box restarts. It's a single shared browser — no per-session isolation unless
you create separate browser contexts (see "Caveats" below).
Prerequisites
- A Fly Machine the user has already created, reachable via
fly ssh console -a <app-name> (true by default for Fly Machines whose
image runs an OpenSSH server trusting Fly's CA — standard for most Fly
base images / fly launch flows) with root access.
- A systemd-based Linux distro on that box (Ubuntu/Debian, etc.).
- The
fly CLI installed locally and logged in (fly auth login).
If fly ssh console -a <app-name> doesn't connect, that's a box
provisioning issue outside this skill's scope — sort that out with the user
first.
Installing
scripts/remote-install.sh <app-name>
This uploads docker-compose.yml, the systemd unit, and install.sh to the
box (fly ssh sftp put), then runs install.sh as root over
fly ssh console -C. It's idempotent — installs Docker if missing, writes
the compose stack and env file, installs + enables the systemd unit, and
restarts the stack. Re-run it any time you change docker-compose.yml or
systemd/fly-browser-chrome.service to push the update.
The first run generates and prints a watch-along username/password — save
it, it isn't shown again. Re-run with WATCH_PASSWORD=... scripts/remote-install.sh <app-name>
to rotate it (also restarts the stack, picking up the new credential).
Driving it for automation
CDP is never exposed publicly — you always go through a private WireGuard
tunnel authenticated by your own fly auth login:
scripts/cdp-tunnel.sh <app-name>
Run that in one terminal/background process for the duration of your
automation session, then drive the browser through the local port.
agent-browser (if available — a CLI built for AI agents that returns
accessibility snapshots with stable refs instead of selectors):
agent-browser --cdp 9222 open https://example.com
agent-browser --cdp 9222 snapshot -i
agent-browser --cdp 9222 click @e2
agent-browser --cdp 9222 screenshot /tmp/page.png
Playwright (Python):
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.connect_over_cdp("http://127.0.0.1:9222")
ctx = browser.contexts[0]
page = ctx.new_page()
page.goto("https://example.com")
Puppeteer (Node):
import puppeteer from "puppeteer-core";
const browser = await puppeteer.connect({ browserURL: "http://127.0.0.1:9222" });
const page = await browser.newPage();
await page.goto("https://example.com");
await browser.disconnect();
Get the raw WebSocket debugger URL with curl -s http://127.0.0.1:9222/json/version | jq -r .webSocketDebuggerUrl.
When a human needs to step in
scripts/watch-tunnel.sh <app-name>
Open http://127.0.0.1:3000, log in with the Basic Auth credentials from
install, do the login / captcha / MFA, then say "continue" — your existing
CDP connection keeps using the page they finished setting up.
Caveats
- One shared browser. Every automation session and every human watcher
attaches to the same Chromium instance and profile. Open your own tab or
browser context rather than reusing/closing others' tabs.
- Shared profile. A login one session performs is visible to the next.
For isolation, create a new browser context (Playwright
new_context())
rather than using the default one.
- Never close the browser. Puppeteer:
disconnect(), not close().
agent-browser: never close --all.
- CDP is private by design. Don't publish port 9222/9223 as a public Fly
service — unauthenticated CDP access is full remote control of the browser
(cookies, sessions, arbitrary JS execution).
- This skill doesn't manage the box's lifecycle. No app creation, no
volumes, no
fly deploy, no teardown. If the user wants the browser stack
removed without destroying the box: fly ssh console -a <app-name> -C "systemctl disable --now fly-browser-chrome".
Troubleshooting
fly ssh console -a <app-name> hangs or refuses → the box itself isn't
reachable over Fly SSH; not something this skill can fix. Check with the
user how the box was provisioned.
fly proxy connects but CDP calls hang/reset → Chromium is still starting;
retry in a few seconds, or check fly ssh console -a <app-name> -C "docker compose -f /opt/fly-browser/docker-compose.yml logs chrome".
- Watch-along UI loads but shows a black screen → Chromium crashed inside
its container.
fly ssh console -a <app-name> -C "systemctl restart fly-browser-chrome" (the profile volume survives). If it keeps crashing,
the box likely needs more memory — Chromium needs enough /dev/shm (the
compose file sets shm_size: 1gb; that in turn needs enough host RAM).
401 on the watch-along tunnel → wrong Basic Auth credentials; re-run
scripts/remote-install.sh <app-name> with WATCH_PASSWORD=... set to
rotate them.
scripts/remote-install.sh fails uploading files → fly ssh sftp put
needs the box reachable the same way fly ssh console does; if console
works but sftp doesn't, retry (sftp session setup is occasionally flaky on
first connect).
scripts/cdp-tunnel.sh / watch-tunnel.sh hang with no error → fly proxy tunnels over WireGuard (UDP), which some restricted/sandboxed
network environments block outbound (only allowing HTTPS through a proxy).
If the box itself is healthy (verify via fly ssh console), this is a
local network limitation, not a problem with the installed stack.