feat(browser-automation): bundle host-bridge CDP proxy + connect helper
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) <noreply@anthropic.com>
This commit is contained in:
parent
60bc2dba2e
commit
ff19c2ce26
78
plugins/browser-automation/host-bridge/cdp-proxy.cjs
Executable file
78
plugins/browser-automation/host-bridge/cdp-proxy.cjs
Executable file
@ -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)));
|
||||
105
plugins/browser-automation/host-bridge/install-host-bridge.sh
Executable file
105
plugins/browser-automation/host-bridge/install-host-bridge.sh
Executable file
@ -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" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0"><dict>
|
||||
<key>Label</key><string>${LABEL}</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>${NODE_BIN}</string>
|
||||
<string>${PROXY_SCRIPT}</string>
|
||||
</array>
|
||||
<key>KeepAlive</key><true/>
|
||||
<key>RunAtLoad</key><true/>
|
||||
<key>StandardOutPath</key><string>${HOME}/.molecule-cdp-proxy.log</string>
|
||||
<key>StandardErrorPath</key><string>${HOME}/.molecule-cdp-proxy.log</string>
|
||||
</dict></plist>
|
||||
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" <<EOF
|
||||
[Unit]
|
||||
Description=Molecule browser-automation CDP proxy (host → Chrome)
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=${NODE_BIN} ${PROXY_SCRIPT}
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
EOF
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now "${LABEL}.service"
|
||||
echo "installed systemd user unit: $unit"
|
||||
echo "logs: journalctl --user -u ${LABEL}.service -f"
|
||||
}
|
||||
|
||||
uninstall() {
|
||||
case "$(uname -s)" in
|
||||
Darwin)
|
||||
launchctl bootout "gui/$(id -u)/${LABEL}" 2>/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
|
||||
@ -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:
|
||||
|
||||
@ -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 };
|
||||
Loading…
Reference in New Issue
Block a user