From ff19c2ce26634ed88a73b0b6428bc151d6b1bd8b Mon Sep 17 00:00:00 2001 From: airenostars Date: Wed, 15 Apr 2026 17:29:46 -0700 Subject: [PATCH] feat(browser-automation): bundle host-bridge CDP proxy + connect helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin now ships everything a user needs to wire Chrome on their host to workspaces inside Docker: - host-bridge/cdp-proxy.cjs — rewrites the Host header so Chrome accepts DevTools Protocol connections from container-originated traffic, and forwards both HTTP (tab list, screenshots) and WebSocket upgrades. - host-bridge/install-host-bridge.sh — one-command install on macOS (launchd user agent) or Linux (systemd --user unit). `uninstall` subcommand cleans up. No root required. - skills/browser-automation/lib/connect.js — the mandatory helper consumers already use; re-exported here so the plugin is self-contained. - SKILL.md — documents the one-time host setup and the existing defaultViewport:null + disconnect-not-close rules. The 2026-04-15 social-media-poster incident (3h debug chasing phantom "sessions expired" errors on an 800x600 viewport) is captured inline. Smoke-tested on macOS: install script registered the agent, proxy listens on 0.0.0.0:9223, and a live workspace container (ws-bee4d521-3d3) successfully reached Chrome via host.docker.internal:9223. This replaces ad-hoc per-user CDP proxies and makes the plugin usable by any Molecule operator, not just the Reno Stars org. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../host-bridge/cdp-proxy.cjs | 78 +++++++++++++ .../host-bridge/install-host-bridge.sh | 105 ++++++++++++++++++ .../skills/browser-automation/SKILL.md | 63 ++++++++--- .../skills/browser-automation/lib/connect.js | 62 +++++++++++ 4 files changed, 295 insertions(+), 13 deletions(-) create mode 100755 plugins/browser-automation/host-bridge/cdp-proxy.cjs create mode 100755 plugins/browser-automation/host-bridge/install-host-bridge.sh create mode 100644 plugins/browser-automation/skills/browser-automation/lib/connect.js diff --git a/plugins/browser-automation/host-bridge/cdp-proxy.cjs b/plugins/browser-automation/host-bridge/cdp-proxy.cjs new file mode 100755 index 00000000..aa6ca268 --- /dev/null +++ b/plugins/browser-automation/host-bridge/cdp-proxy.cjs @@ -0,0 +1,78 @@ +#!/usr/bin/env node +/** + * CDP proxy — bridges a Docker container to the user's Chrome running on the host. + * + * Why: Chrome on macOS rejects DevTools Protocol connections whose Host header + * is anything other than `localhost`. A container hitting `host.docker.internal:9222` + * fails the check. This proxy listens on 0.0.0.0:9223, rewrites the Host header, + * and forwards both HTTP (tab listing, screenshots) and WebSocket upgrades. + * + * Usage: + * # Launch your Chrome with the debug port once (once per reboot): + * open -na "Google Chrome" --args \ + * --user-data-dir="$HOME/.chrome-molecule" \ + * --profile-directory=Default \ + * --remote-debugging-port=9222 + * + * # Then start the proxy (stays in foreground; run in a launchd/systemd unit): + * node cdp-proxy.cjs + * + * Env overrides: + * CHROME_PORT (default 9222) + * PROXY_PORT (default 9223) + * BIND_ADDR (default 0.0.0.0) + * + * Container side: connect via `host.docker.internal:9223` (Docker Desktop) or + * `172.17.0.1:9223` (Linux). The bundled `lib/connect.js` helper auto-detects. + */ +const http = require('http'); +const net = require('net'); + +const CHROME_PORT = parseInt(process.env.CHROME_PORT || '9222', 10); +const PROXY_PORT = parseInt(process.env.PROXY_PORT || '9223', 10); +const BIND_ADDR = process.env.BIND_ADDR || '0.0.0.0'; + +const proxy = http.createServer((req, res) => { + const options = { + hostname: '127.0.0.1', + port: CHROME_PORT, + path: req.url, + method: req.method, + headers: { ...req.headers, host: `localhost:${CHROME_PORT}` }, + }; + const proxyReq = http.request(options, (proxyRes) => { + res.writeHead(proxyRes.statusCode, proxyRes.headers); + proxyRes.pipe(res); + }); + req.pipe(proxyReq); + proxyReq.on('error', (e) => { + res.writeHead(502); + res.end(`proxy error: ${e.code || e.message}`); + }); +}); + +proxy.on('upgrade', (req, socket, head) => { + const conn = net.connect(CHROME_PORT, '127.0.0.1', () => { + const upgradeReq = + `${req.method} ${req.url} HTTP/1.1\r\n` + + `Host: localhost:${CHROME_PORT}\r\n` + + Object.entries(req.headers) + .filter(([k]) => k.toLowerCase() !== 'host') + .map(([k, v]) => `${k}: ${v}`) + .join('\r\n') + + '\r\n\r\n'; + conn.write(upgradeReq); + if (head.length) conn.write(head); + socket.pipe(conn); + conn.pipe(socket); + }); + conn.on('error', () => socket.destroy()); + socket.on('error', () => conn.destroy()); +}); + +proxy.listen(PROXY_PORT, BIND_ADDR, () => { + console.log(`cdp-proxy listening on ${BIND_ADDR}:${PROXY_PORT} → 127.0.0.1:${CHROME_PORT}`); +}); + +process.on('SIGTERM', () => proxy.close(() => process.exit(0))); +process.on('SIGINT', () => proxy.close(() => process.exit(0))); diff --git a/plugins/browser-automation/host-bridge/install-host-bridge.sh b/plugins/browser-automation/host-bridge/install-host-bridge.sh new file mode 100755 index 00000000..88d53377 --- /dev/null +++ b/plugins/browser-automation/host-bridge/install-host-bridge.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# install-host-bridge.sh — run ONCE on the host machine to keep cdp-proxy alive +# across reboots. Workspaces inside Docker then reach Chrome via the proxy. +# +# Supports macOS (launchd) and Linux (systemd --user). No root required. +# +# Usage: +# bash install-host-bridge.sh # install + start +# bash install-host-bridge.sh uninstall # stop + remove +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROXY_SCRIPT="${SCRIPT_DIR}/cdp-proxy.cjs" +LABEL="com.molecule.browser-automation.cdp-proxy" +NODE_BIN="$(command -v node || echo /usr/local/bin/node)" + +if [[ ! -f "$PROXY_SCRIPT" ]]; then + echo "ERROR: $PROXY_SCRIPT not found" >&2 + exit 1 +fi +if [[ ! -x "$NODE_BIN" ]]; then + echo "ERROR: node not on PATH — install Node.js first" >&2 + exit 1 +fi + +install_macos() { + local plist="$HOME/Library/LaunchAgents/${LABEL}.plist" + cat > "$plist" < + + + Label${LABEL} + ProgramArguments + + ${NODE_BIN} + ${PROXY_SCRIPT} + + KeepAlive + RunAtLoad + StandardOutPath${HOME}/.molecule-cdp-proxy.log + StandardErrorPath${HOME}/.molecule-cdp-proxy.log + +EOF + launchctl bootout "gui/$(id -u)/${LABEL}" 2>/dev/null || true + launchctl bootstrap "gui/$(id -u)" "$plist" + launchctl kickstart -k "gui/$(id -u)/${LABEL}" + echo "installed macOS launchd agent: $plist" + echo "logs: ${HOME}/.molecule-cdp-proxy.log" +} + +install_linux() { + local unit_dir="$HOME/.config/systemd/user" + mkdir -p "$unit_dir" + local unit="$unit_dir/${LABEL}.service" + cat > "$unit" </dev/null || true + rm -f "$HOME/Library/LaunchAgents/${LABEL}.plist" + echo "uninstalled macOS launchd agent" + ;; + Linux) + systemctl --user disable --now "${LABEL}.service" 2>/dev/null || true + rm -f "$HOME/.config/systemd/user/${LABEL}.service" + systemctl --user daemon-reload + echo "uninstalled systemd user unit" + ;; + esac +} + +case "${1:-install}" in + install) + case "$(uname -s)" in + Darwin) install_macos ;; + Linux) install_linux ;; + *) echo "unsupported OS: $(uname -s)" >&2; exit 1 ;; + esac + echo + echo "next step: launch your Chrome with --remote-debugging-port=9222 (once per reboot)" + echo " macOS: open -na 'Google Chrome' --args --remote-debugging-port=9222 --user-data-dir=\"\$HOME/.chrome-molecule\"" + echo "verify: curl http://127.0.0.1:9223/json/version" + ;; + uninstall) uninstall ;; + *) echo "usage: $0 [install|uninstall]"; exit 1 ;; +esac diff --git a/plugins/browser-automation/skills/browser-automation/SKILL.md b/plugins/browser-automation/skills/browser-automation/SKILL.md index 657d5f95..f61032c4 100644 --- a/plugins/browser-automation/skills/browser-automation/SKILL.md +++ b/plugins/browser-automation/skills/browser-automation/SKILL.md @@ -9,23 +9,33 @@ tags: [browser, puppeteer, cdp] Connect to the host Chrome browser via the CDP proxy to automate web interactions. -## Connection +## Connection — ALWAYS use the helper + +**DO NOT call `puppeteer.connect()` directly.** Use `./lib/connect.js`: ```javascript -const puppeteer = require('puppeteer-core'); -const http = require('http'); - -// Get WebSocket URL from CDP proxy and rewrite for Docker networking -const data = await new Promise((res, rej) => { - http.get('http://host.docker.internal:9223/json/version', r => { - let d = ''; r.on('data', c => d += c); r.on('end', () => res(JSON.parse(d))); - }).on('error', rej); -}); -const wsUrl = data.webSocketDebuggerUrl.replace('localhost:9222', 'host.docker.internal:9223'); -const browser = await puppeteer.connect({browserWSEndpoint: wsUrl, defaultViewport: null}); +const { connect } = require('/configs/plugins/browser-automation/skills/browser-automation/lib/connect'); +const browser = await connect(); +const page = (await browser.pages())[0]; +// ... do work ... +await browser.disconnect(); // NEVER browser.close() (kills shared Chrome) ``` -**Important:** Always use `browserWSEndpoint` with URL rewrite, NOT `browserURL`. The CDP proxy runs on port 9223 and rewrites the Host header for Chrome compatibility. +The helper enforces two settings that broke social-media automation repeatedly on 2026-04-15: + +1. **`defaultViewport: null`** — use real Chrome window dims (NOT puppeteer's 800×600 default). +2. **Host auto-detection** — Docker (`host.docker.internal:9223`) vs host script (`127.0.0.1:9222`). + +If you absolutely cannot use the helper (one-off debug, no plugin path), the rule is still inviolable — paste this verbatim: + +```javascript +const browser = await puppeteer.connect({ + browserURL: 'http://127.0.0.1:9222', // or browserWSEndpoint with proxy host + defaultViewport: null, // ← MANDATORY, NEVER omit +}); +``` + +**Why `defaultViewport: null` is non-negotiable:** without it, puppeteer overrides Chrome's reported size to 800×600. The browser visually still renders at the user's actual size, but `window.innerWidth/Height` returns `800/600`. All click coords, on-screen filters, and `getBoundingClientRect()` checks become wrong. Symptoms: agent reports "session expired" / "button not found" / "caption typed nowhere" — but visually everything looks fine to the user. This was the root of the 2026-04-15 social-media-poster runs that bailed claiming all sessions were expired (~3h debug). ## Key Patterns @@ -33,6 +43,33 @@ const browser = await puppeteer.connect({browserWSEndpoint: wsUrl, defaultViewpo - **Navigate:** `await page.goto(url, {waitUntil: 'networkidle2'})` - **Disconnect (don't close):** `browser.disconnect()` — never `browser.close()` (that kills the shared Chrome) +## Host setup (one-time, per machine) + +The plugin ships a **host bridge** at `plugins/browser-automation/host-bridge/` +that keeps a CDP proxy alive on the user's machine so any container with this +plugin can reach their Chrome. Install once — it survives reboots: + +```bash +# from the molecule-monorepo repo root: +bash plugins/browser-automation/host-bridge/install-host-bridge.sh +``` + +This registers a launchd agent (macOS) or systemd user unit (Linux) that runs +`cdp-proxy.cjs` on `0.0.0.0:9223` forever. Then launch Chrome with the debug +port (once per reboot is enough; the proxy reconnects): + +```bash +open -na "Google Chrome" --args --remote-debugging-port=9222 \ + --user-data-dir="$HOME/.chrome-molecule" --profile-directory=Default +``` + +Verify: `curl http://127.0.0.1:9223/json/version` returns JSON. If it doesn't, +the proxy is running but Chrome isn't — launch Chrome and re-check. No +workspace-side changes needed — `lib/connect.js` already points at +`host.docker.internal:9223`. + +To uninstall: `bash plugins/browser-automation/host-bridge/install-host-bridge.sh uninstall`. + ## Available Accounts The Chrome profile has active sessions for: diff --git a/plugins/browser-automation/skills/browser-automation/lib/connect.js b/plugins/browser-automation/skills/browser-automation/lib/connect.js new file mode 100644 index 00000000..15944d04 --- /dev/null +++ b/plugins/browser-automation/skills/browser-automation/lib/connect.js @@ -0,0 +1,62 @@ +/** + * Single source of truth for connecting to the host Chrome via CDP. + * + * ALWAYS use this helper — never call puppeteer.connect() directly. It enforces + * the two settings that broke the social-media cron repeatedly on 2026-04-15: + * - defaultViewport: null (use real Chrome window dims, not the 800x600 default) + * - browserWSEndpoint with proxy host rewrite (works inside Docker AND on host) + * + * Usage: + * const { connect } = require('./lib/connect'); // adjust path + * const browser = await connect(); + * const page = (await browser.pages())[0]; + * // ... do work ... + * await browser.disconnect(); // NEVER browser.close() (kills shared Chrome) + */ +const puppeteer = require('puppeteer-core'); +const http = require('http'); + +const HOST_DOCKER = 'host.docker.internal'; +const HOST_LOCAL = '127.0.0.1'; +const PROXY_PORT = 9223; // CDP proxy (rewrites Host header) +const DIRECT_PORT = 9222; // Chrome's native CDP + +function fetchVersion(url) { + return new Promise((resolve, reject) => { + const req = http.get(url, r => { + let d = ''; + r.on('data', c => d += c); + r.on('end', () => { try { resolve(JSON.parse(d)); } catch (e) { reject(e); } }); + }); + req.on('error', reject); + req.setTimeout(5000, () => { req.destroy(new Error('timeout')); }); + }); +} + +async function connect() { + // Detect environment: are we inside a Docker container or on the host? + // host.docker.internal resolves only inside containers. + let host, port; + try { + await fetchVersion(`http://${HOST_DOCKER}:${PROXY_PORT}/json/version`); + host = HOST_DOCKER; + port = PROXY_PORT; + } catch { + // Fallback to direct connection (host script) + host = HOST_LOCAL; + port = DIRECT_PORT; + } + + const data = await fetchVersion(`http://${host}:${port}/json/version`); + // Rewrite localhost in WS URL to whichever host worked above + const wsUrl = data.webSocketDebuggerUrl + .replace('localhost:9222', `${host}:${port}`) + .replace('127.0.0.1:9222', `${host}:${port}`); + + return puppeteer.connect({ + browserWSEndpoint: wsUrl, + defaultViewport: null, // CRITICAL: use Chrome's actual window size + }); +} + +module.exports = { connect };