From fa68e33a78f73e8fb5efb63b0d79b68c5ef18d86 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Sat, 14 Feb 2026 16:31:04 +0100 Subject: [PATCH] feat: support HTTPS URLs in PLAYWRITER_HOST + remote access guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PLAYWRITER_HOST now accepts full URLs like `https://x-tunnel.traforo.dev` in addition to plain hostnames. Previously it always constructed `http://${host}:19988` which broke when using traforo tunnels (HTTPS on port 443). Added `parseRelayHost()` utility that detects URL protocols and returns correct HTTP/WebSocket base URLs. Updated all consumers: - cli.ts: getServerUrl() - mcp.ts: getLogServerUrl(), checkRemoteServer() - executor.ts: checkExtensionStatus() - utils.ts: getCdpUrl() (ws:// → wss:// for HTTPS hosts) Plain hostnames still work as before (appends :19988). Added docs/remote-access.md covering: - Architecture: playwriter serve + traforo tunnel through Cloudflare - Host and remote machine setup with env vars - Use cases: remote Mac mini, user support, multi-machine, VM/devcontainer - Security model: non-guessable URLs, token auth, localhost-only extension endpoint, no open ports, visible automation, instant revocation --- README.md | 16 ++- docs/remote-access.md | 204 +++++++++++++++++++++++++++++++++++++ playwriter/src/cli.ts | 5 +- playwriter/src/executor.ts | 9 +- playwriter/src/mcp.ts | 12 ++- playwriter/src/utils.ts | 25 ++++- 6 files changed, 249 insertions(+), 22 deletions(-) create mode 100644 docs/remote-access.md diff --git a/README.md b/README.md index 861e7ec..8b0b955 100644 --- a/README.md +++ b/README.md @@ -180,28 +180,24 @@ Color-coded: yellow=links, orange=buttons, coral=inputs, pink=checkboxes, peach= +---------------------+ (no extension click) +-----------------+ ``` -## Remote CLI +## Remote Access -Run CLI from a different machine (devcontainer, VM, SSH) while Chrome runs on your host. +Control Chrome on a remote machine over the internet using [traforo](https://traforo.dev) tunnels: **On host:** ```bash -playwriter serve --token +npx -y traforo -p 19988 -t my-machine -- npx -y playwriter serve --token ``` **From remote:** ```bash -playwriter --host 192.168.1.10 --token session new -playwriter --host 192.168.1.10 --token -s 1 -e "await page.goto('https://example.com')" -``` - -Or with env vars: -```bash -export PLAYWRITER_HOST=192.168.1.10 +export PLAYWRITER_HOST=https://my-machine-tunnel.traforo.dev export PLAYWRITER_TOKEN= playwriter -s 1 -e "await page.goto('https://example.com')" ``` +Also works on a LAN without traforo (`PLAYWRITER_HOST=192.168.1.10`). Full guide with use cases (remote Mac mini, user support, multi-machine control): [docs/remote-access.md](./docs/remote-access.md) + ## Security - **Local only**: WebSocket server on `localhost:19988` diff --git a/docs/remote-access.md b/docs/remote-access.md new file mode 100644 index 0000000..2cb6a80 --- /dev/null +++ b/docs/remote-access.md @@ -0,0 +1,204 @@ +--- +title: Remote Browser Control with Playwriter +description: | + How to control a Chrome browser on a remote machine over + the internet using playwriter serve and traforo tunnels. +prompt: | + Create a guide on how to use playwriter to remotely control + a Chrome instance on another machine. Cover the architecture + (playwriter serve + traforo tunnel), step-by-step setup for + both host and remote machines, MCP configuration, use cases + (remote Mac mini, user support, multi-machine control), and + security model. Source files: + @playwriter/src/cli.ts (serve command, getServerUrl) + @playwriter/src/utils.ts (parseRelayHost, getCdpUrl) + @playwriter/src/mcp.ts (remote config, PLAYWRITER_HOST) + @playwriter/src/executor.ts (checkExtensionStatus) + @opensrc/repos/github.com/remorses/traforo/src/client.ts + @opensrc/repos/github.com/remorses/traforo/src/tunnel.ts + @https://traforo.dev +--- + +# Remote Browser Control with Playwriter + +Control a Chrome browser on any machine from anywhere over the internet. No VPN, no firewall rules, no port forwarding. + +## How it works + +Playwriter's relay server runs on the host machine alongside Chrome. A [traforo](https://traforo.dev) tunnel exposes it to the internet through Cloudflare, giving you a secure public URL. The remote machine connects through this URL to control Chrome. + +``` +┌─────────────────────────────────────────────────────────────┐ +│ HOST MACHINE (has Chrome) │ +│ │ +│ Chrome + Extension ◄──── local WS ────► Relay Server │ +│ :19988 │ +│ ▲ │ +│ │ local │ +│ ▼ │ +│ Traforo Client │ +│ │ │ +└────────────────────────────────────────────┼────────────────┘ + │ outbound WS + ▼ + ┌─────────────────┐ + │ Cloudflare │ + │ Durable Object │ + │ │ + │ https://{id}- │ + │ tunnel. │ + │ traforo.dev │ + └────────┬────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────┐ +│ REMOTE MACHINE (CLI or MCP) │ +│ │ +│ playwriter -s 1 -e "await page.goto('https://...')" │ +│ │ +│ PLAYWRITER_HOST=https://{id}-tunnel.traforo.dev │ +│ PLAYWRITER_TOKEN= │ +└────────────────────────────────────────────────────────────┘ +``` + +Traforo proxies both HTTP and WebSocket connections, which is critical because playwriter uses WebSockets for real-time CDP communication. + +## 1. Host machine setup + +The host machine runs Chrome with the playwriter extension installed. + +1. Install [Playwriter from the Chrome Web Store](https://chromewebstore.google.com/detail/playwriter/jfeammnjpkecdekppnclgkkffahnhfhe) +2. Click the extension icon on any tab you want to make controllable +3. Start the relay server with a tunnel: + +```bash +npx -y traforo -p 19988 -t my-machine -- npx -y playwriter serve --token MY_SECRET_TOKEN +``` + +This starts `playwriter serve` on port 19988 with token auth, and creates a traforo tunnel at `https://my-machine-tunnel.traforo.dev`. Keep this terminal running, or use tmux for persistent operation: + +```bash +tmux new-session -d -s playwriter-remote +tmux send-keys -t playwriter-remote \ + "npx -y traforo -p 19988 -t my-machine -- npx -y playwriter serve --token MY_SECRET_TOKEN" Enter +``` + +**About the `-t` flag:** It sets the tunnel ID, which becomes the URL subdomain. If omitted, a random 8-char UUID is generated. Tunnel IDs are **not reserved** - if someone else connects with the same ID, they replace your connection (close code 4009). This is fine because the relay still requires the `--token`, but avoid predictable IDs like `test` or `demo`. + +## 2. Remote machine setup + +Set the two environment variables and use playwriter normally: + +```bash +export PLAYWRITER_HOST=https://my-machine-tunnel.traforo.dev +export PLAYWRITER_TOKEN=MY_SECRET_TOKEN +``` + +The **CLI with the skill** is the recommended approach. The skill file (`playwriter skill`) documents all available APIs. Use playwriter exactly as you would locally: + +```bash +playwriter session new # outputs: 1 +playwriter -s 1 -e "await page.goto('https://example.com')" +playwriter -s 1 -e "console.log(await accessibilitySnapshot({ page }))" +``` + +Alternatively, pass host and token as flags instead of env vars: + +```bash +playwriter --host https://my-machine-tunnel.traforo.dev --token MY_SECRET_TOKEN -s 1 -e "..." +``` + +### MCP configuration (optional) + +If you prefer using the MCP server over the CLI (e.g. for AI assistants that don't support the skill), set the env vars in your MCP client config: + +```json +{ + "mcpServers": { + "playwriter": { + "command": "npx", + "args": ["-y", "playwriter@latest"], + "env": { + "PLAYWRITER_HOST": "https://my-machine-tunnel.traforo.dev", + "PLAYWRITER_TOKEN": "MY_SECRET_TOKEN" + } + } + } +} +``` + +The env vars tell the MCP to skip starting a local relay and connect to the remote one instead. + +### Playwright API (programmatic) + +```typescript +import { chromium } from 'playwright-core' + +const browser = await chromium.connectOverCDP( + 'wss://my-machine-tunnel.traforo.dev/cdp/session1?token=MY_SECRET_TOKEN' +) +const page = browser.contexts()[0].pages()[0] +await page.goto('https://example.com') +// Don't call browser.close() - it would close the user's Chrome +``` + +## Use cases + +**Control a remote Mac mini** - Run Chrome on a headless machine and control it from your laptop. The Mac mini runs the tunnel persistently via tmux. Automate browser tasks, run tests against real Chrome, or manage web apps from anywhere. + +**Fix issues for a user remotely** - The user starts the tunnel, shares the URL + token with you, and you can see exactly what they see: navigate their tabs, inspect elements, take screenshots. The user sees Chrome's automation banner so they always know when their browser is being controlled, and can revoke access instantly by closing the terminal. + +**Control many machines at once** - Each machine runs its own tunnel with a unique `-t` ID and the same token. From a control machine, loop over the tunnel URLs to run commands across the fleet: + +```bash +for machine in machine-a machine-b machine-c; do + PLAYWRITER_HOST="https://${machine}-tunnel.traforo.dev" \ + PLAYWRITER_TOKEN=shared-secret \ + playwriter -s 1 -e "console.log(await page.title())" +done +``` + +**Development from a VM or devcontainer** - Your code runs in a VM or devcontainer but Chrome runs on the host. The tunnel bridges the gap without needing host networking or port forwarding. + +## Security + +**Traforo URLs are non-guessable.** Each tunnel gets a unique ID (random UUID by default). Nobody can discover your tunnel by scanning. + +**Token authentication is required.** When `playwriter serve` binds to `0.0.0.0`, it refuses to start without a `--token`. Every HTTP request needs `Authorization: Bearer ` and every WebSocket connection needs `?token=`. Without the correct token, the relay returns 401. + +**Extension endpoint is localhost-only.** The `/extension` WebSocket endpoint only accepts connections from `127.0.0.1` or `::1`. A remote attacker cannot impersonate the extension even with the token. + +**No open ports.** Traforo uses an outbound WebSocket to Cloudflare. The host machine needs no inbound ports open. Works behind NATs, firewalls, and corporate networks. + +**Visible automation.** Chrome shows an automation banner on controlled tabs. + +**Instant revocation.** Closing the terminal immediately disconnects the tunnel. + +### Environment variables + +| Variable | Description | +|---|---| +| `PLAYWRITER_HOST` | Remote relay URL (e.g. `https://x-tunnel.traforo.dev`) or IP (e.g. `192.168.1.10`) | +| `PLAYWRITER_TOKEN` | Authentication token for the relay server | +| `PLAYWRITER_PORT` | Override relay port (default: `19988`, not needed with traforo) | + +### Recommendations + +- Generate a strong random token: `openssl rand -hex 16` +- Omit `-t` in traforo to get a random tunnel ID for maximum security +- Don't share tunnel URLs in public channels +- Kill the tunnel when you're done + +## Without traforo (LAN only) + +If both machines are on the same network, skip traforo and connect directly: + +```bash +# Host +npx -y playwriter serve --token MY_SECRET_TOKEN + +# Remote (same LAN) +export PLAYWRITER_HOST=192.168.1.10 +export PLAYWRITER_TOKEN=MY_SECRET_TOKEN +playwriter session new +``` diff --git a/playwriter/src/cli.ts b/playwriter/src/cli.ts index 2089564..91d6fc5 100644 --- a/playwriter/src/cli.ts +++ b/playwriter/src/cli.ts @@ -5,7 +5,7 @@ import path from 'node:path' import { fileURLToPath } from 'node:url' import { cac } from '@xmorse/cac' import { killPortProcess } from './kill-port.js' -import { VERSION, LOG_FILE_PATH, LOG_CDP_FILE_PATH } from './utils.js' +import { VERSION, LOG_FILE_PATH, LOG_CDP_FILE_PATH, parseRelayHost } from './utils.js' import { ensureRelayServer, RELAY_PORT, waitForExtension } from './relay-client.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) @@ -52,7 +52,8 @@ cli async function getServerUrl(host?: string): Promise { const serverHost = host || process.env.PLAYWRITER_HOST || '127.0.0.1' - return `http://${serverHost}:${RELAY_PORT}` + const { httpBaseUrl } = parseRelayHost(serverHost, RELAY_PORT) + return httpBaseUrl } async function fetchExtensionsStatus(host?: string): Promise { diff --git a/playwriter/src/executor.ts b/playwriter/src/executor.ts index 8bf724e..5aacf1f 100644 --- a/playwriter/src/executor.ts +++ b/playwriter/src/executor.ts @@ -14,7 +14,7 @@ import { fileURLToPath } from 'node:url' import vm from 'node:vm' import * as acorn from 'acorn' import { createSmartDiff } from './diff-utils.js' -import { getCdpUrl } from './utils.js' +import { getCdpUrl, parseRelayHost } from './utils.js' import { waitForPageLoad, WaitForPageLoadOptions, WaitForPageLoadResult } from './wait-for-page-load.js' import { ICDPSession, getCDPSessionForPage } from './cdp-session.js' import { Debugger } from './debugger.js' @@ -332,13 +332,14 @@ export class PlaywrightExecutor { private async checkExtensionStatus(): Promise<{ connected: boolean; activeTargets: number }> { const { host = '127.0.0.1', port = 19988, extensionId } = this.cdpConfig + const { httpBaseUrl } = parseRelayHost(host, port) try { if (extensionId) { - const response = await fetch(`http://${host}:${port}/extensions/status`, { + const response = await fetch(`${httpBaseUrl}/extensions/status`, { signal: AbortSignal.timeout(2000), }) if (!response.ok) { - const fallback = await fetch(`http://${host}:${port}/extension/status`, { + const fallback = await fetch(`${httpBaseUrl}/extension/status`, { signal: AbortSignal.timeout(2000), }) if (!fallback.ok) { @@ -358,7 +359,7 @@ export class PlaywrightExecutor { return { connected: true, activeTargets: extension.activeTargets } } - const response = await fetch(`http://${host}:${port}/extension/status`, { + const response = await fetch(`${httpBaseUrl}/extension/status`, { signal: AbortSignal.timeout(2000), }) if (!response.ok) { diff --git a/playwriter/src/mcp.ts b/playwriter/src/mcp.ts index 74da94f..7a50d89 100644 --- a/playwriter/src/mcp.ts +++ b/playwriter/src/mcp.ts @@ -6,7 +6,7 @@ import path from 'node:path' import { fileURLToPath } from 'node:url' import { createRequire } from 'node:module' import dedent from 'string-dedent' -import { LOG_FILE_PATH, VERSION } from './utils.js' +import { LOG_FILE_PATH, VERSION, parseRelayHost } from './utils.js' import { ensureRelayServer, RELAY_PORT } from './relay-client.js' import { PlaywrightExecutor, CodeExecutionTimeoutError } from './executor.js' @@ -38,7 +38,8 @@ function getRemoteConfig(): RemoteConfig | null { function getLogServerUrl(): string { const remote = getRemoteConfig() if (remote) { - return `http://${remote.host}:${remote.port}/mcp-log` + const { httpBaseUrl } = parseRelayHost(remote.host, remote.port) + return `${httpBaseUrl}/mcp-log` } return `http://127.0.0.1:${RELAY_PORT}/mcp-log` } @@ -100,7 +101,8 @@ async function getOrCreateExecutor(): Promise { } async function checkRemoteServer({ host, port }: { host: string; port: number }): Promise { - const versionUrl = `http://${host}:${port}/version` + const { httpBaseUrl } = parseRelayHost(host, port) + const versionUrl = `${httpBaseUrl}/version` try { const response = await fetch(versionUrl, { signal: AbortSignal.timeout(3000) }) if (!response.ok) { @@ -110,7 +112,7 @@ async function checkRemoteServer({ host, port }: { host: string; port: number }) const isConnectionError = error.cause?.code === 'ECONNREFUSED' || error.name === 'TimeoutError' if (isConnectionError) { throw new Error( - `Cannot connect to remote relay server at ${host}:${port}. ` + + `Cannot connect to remote relay server at ${host}. ` + `Make sure 'npx -y playwriter serve' is running on the host machine.`, ) } @@ -276,7 +278,7 @@ export async function startMcp(options: { host?: string; token?: string } = {}) if (!remote) { await ensureRelayServerForMcp() } else { - mcpLog(`Using remote CDP relay server: ${remote.host}:${remote.port}`) + mcpLog(`Using remote CDP relay server: ${remote.host}`) await checkRemoteServer(remote) } diff --git a/playwriter/src/utils.ts b/playwriter/src/utils.ts index bcee752..9740a1d 100644 --- a/playwriter/src/utils.ts +++ b/playwriter/src/utils.ts @@ -9,6 +9,28 @@ export const EXTENSION_IDS = [ 'pebbngnfojnignonigcnkdilknapkgid', // Dev extension (stable ID from manifest key) ] +/** + * Parse a relay host string into HTTP and WebSocket base URLs. + * Supports both plain hostnames (appends port) and full URLs (uses as-is). + * + * Examples: + * "192.168.1.10" → http://192.168.1.10:19988, ws://192.168.1.10:19988 + * "https://my-machine-tunnel.traforo.dev" → https://my-machine-tunnel.traforo.dev, wss://my-machine-tunnel.traforo.dev + */ +export function parseRelayHost(host: string, port: number = 19988): { httpBaseUrl: string; wsBaseUrl: string } { + if (host.startsWith('https://') || host.startsWith('http://')) { + const url = new URL(host) + const httpBaseUrl = url.origin + const wsProtocol = url.protocol === 'https:' ? 'wss:' : 'ws:' + const wsBaseUrl = `${wsProtocol}//${url.host}` + return { httpBaseUrl, wsBaseUrl } + } + return { + httpBaseUrl: `http://${host}:${port}`, + wsBaseUrl: `ws://${host}:${port}`, + } +} + export function getCdpUrl({ port = 19988, host = '127.0.0.1', @@ -30,7 +52,8 @@ export function getCdpUrl({ } const queryString = params.toString() const suffix = queryString ? `?${queryString}` : '' - return `ws://${host}:${port}/cdp/${id}${suffix}` + const { wsBaseUrl } = parseRelayHost(host, port) + return `${wsBaseUrl}/cdp/${id}${suffix}` } // Use ~/.playwriter for logs so each OS user gets their own dir (avoids permission errors on shared machines, see #44)