import from local vendored copy (2026-05-06)
Some checks failed
CI / validate (push) Failing after 0s
Some checks failed
CI / validate (push) Failing after 0s
This commit is contained in:
commit
e554e0cace
5
.github/workflows/ci.yml
vendored
Normal file
5
.github/workflows/ci.yml
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
name: CI
|
||||
on: [push, pull_request]
|
||||
jobs:
|
||||
validate:
|
||||
uses: Molecule-AI/molecule-ci/.github/workflows/validate-plugin.yml@main
|
||||
21
.gitignore
vendored
Normal file
21
.gitignore
vendored
Normal file
@ -0,0 +1,21 @@
|
||||
# Credentials — never commit. Use .env.example as the template.
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.sample
|
||||
|
||||
# Private keys + certs
|
||||
*.pem
|
||||
*.key
|
||||
*.crt
|
||||
*.p12
|
||||
*.pfx
|
||||
|
||||
# Secret directories
|
||||
.secrets/
|
||||
|
||||
# Workspace auth tokens
|
||||
.auth-token
|
||||
.auth_token
|
||||
1
.molecule-ci/scripts/requirements.txt
Normal file
1
.molecule-ci/scripts/requirements.txt
Normal file
@ -0,0 +1 @@
|
||||
pyyaml>=6.0
|
||||
46
.molecule-ci/scripts/validate-plugin.py
Normal file
46
.molecule-ci/scripts/validate-plugin.py
Normal file
@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate a Molecule AI plugin repo."""
|
||||
import os, sys, yaml
|
||||
|
||||
errors = []
|
||||
|
||||
if not os.path.isfile("plugin.yaml"):
|
||||
print("::error::plugin.yaml not found at repo root")
|
||||
sys.exit(1)
|
||||
|
||||
with open("plugin.yaml") as f:
|
||||
plugin = yaml.safe_load(f)
|
||||
|
||||
for field in ["name", "version", "description"]:
|
||||
if not plugin.get(field):
|
||||
errors.append(f"Missing required field: {field}")
|
||||
|
||||
v = str(plugin.get("version", ""))
|
||||
if v and not all(c in "0123456789." for c in v):
|
||||
errors.append(f"Invalid version format: {v}")
|
||||
|
||||
runtimes = plugin.get("runtimes")
|
||||
if runtimes is not None and not isinstance(runtimes, list):
|
||||
errors.append(f"runtimes must be a list, got {type(runtimes).__name__}")
|
||||
|
||||
content_paths = ["SKILL.md", "hooks", "skills", "rules"]
|
||||
found = [p for p in content_paths if os.path.exists(p)]
|
||||
if not found:
|
||||
errors.append("Plugin must contain at least one of: SKILL.md, hooks/, skills/, rules/")
|
||||
|
||||
if os.path.isfile("SKILL.md"):
|
||||
with open("SKILL.md") as f:
|
||||
first_line = f.readline().strip()
|
||||
if first_line and not first_line.startswith("#"):
|
||||
print("::warning::SKILL.md should start with a markdown heading (e.g., # Plugin Name)")
|
||||
|
||||
if errors:
|
||||
for e in errors:
|
||||
print(f"::error::{e}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"✓ plugin.yaml valid: {plugin['name']} v{plugin['version']}")
|
||||
if found:
|
||||
print(f" Content: {', '.join(found)}")
|
||||
if runtimes:
|
||||
print(f" Runtimes: {', '.join(runtimes)}")
|
||||
83
CLAUDE.md
Normal file
83
CLAUDE.md
Normal file
@ -0,0 +1,83 @@
|
||||
# Browser Automation Plugin — CLAUDE.md
|
||||
|
||||
## What This Plugin Does
|
||||
|
||||
Connects Claude Code agents to the user's **host Chrome browser** via a local CDP (Chrome DevTools Protocol) proxy, enabling web automation: form filling, scraping, posting, and screenshots. Uses `puppeteer-core` — no bundled Chromium.
|
||||
|
||||
**Stack:** puppeteer-core + CDP proxy (Node.js) + Chrome on host.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
workspace container
|
||||
└── lib/connect.js (bundled helper)
|
||||
└── CDP proxy (host:9223) ← X-CDP-Proxy-Token auth
|
||||
└── Chrome on host (localhost:9222)
|
||||
```
|
||||
|
||||
### Two Pieces Must Both Run
|
||||
|
||||
1. **Chrome** on the host machine, launched with `--remote-debugging-port=9222`.
|
||||
2. **CDP proxy** (`cdp-proxy.cjs`) running on the host, bridging the container to Chrome. The `install-host-bridge.sh` script registers this as a launchd (macOS) or systemd (Linux) service so it survives reboots.
|
||||
|
||||
---
|
||||
|
||||
## Key Rules
|
||||
|
||||
### ALWAYS Use the Bundled Helper
|
||||
|
||||
**Never** call `puppeteer.connect()` or `puppeteer.launch()` directly.
|
||||
|
||||
```javascript
|
||||
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()
|
||||
```
|
||||
|
||||
### `defaultViewport: null` Is Non-Negotiable
|
||||
|
||||
`lib/connect.js` sets `defaultViewport: null`. Without it, puppeteer overrides `window.innerWidth/innerHeight` to 800×600 — but the browser still renders at the user's real size. Every coordinate (`getBoundingClientRect`, click offsets) becomes wrong. Symptoms: agent reports "session expired", "button not found", "text typed in wrong place."
|
||||
|
||||
### `browser.disconnect()`, Not `browser.close()`
|
||||
|
||||
`browser.close()` kills the shared Chrome process. Always use `browser.disconnect()` to release the CDP session without terminating Chrome.
|
||||
|
||||
### CDP Proxy Is Auth-Protected
|
||||
|
||||
Every CDP request (HTTP and WebSocket) requires `X-CDP-Proxy-Token: <token>` header. The token is generated at host-bridge install time and written to `~/.molecule-cdp-proxy-token`. The `lib/connect.js` helper reads it automatically when the token file is bind-mounted into the container.
|
||||
|
||||
### Shared Chrome Profile
|
||||
|
||||
All agents using the same Chrome profile (`~/.chrome-molecule/Default`) see the same logged-in sessions. Do not assume session isolation between agents on the same host.
|
||||
|
||||
### Available Accounts in Shared Profile
|
||||
|
||||
The Chrome profile includes active sessions for: YouTube, Instagram, Facebook, X/Twitter, LinkedIn, TikTok, Gmail, InvoiceSimple, Google Search Console, Manta, TrustedPros, Foursquare, Pinterest, Medium.
|
||||
|
||||
---
|
||||
|
||||
## Common Patterns
|
||||
|
||||
```javascript
|
||||
// List open tabs
|
||||
// curl -H "X-CDP-Proxy-Token: $(cat ~/.molecule-cdp-proxy-token)" http://127.0.0.1:9223/json
|
||||
|
||||
// Navigate and wait
|
||||
await page.goto(url, { waitUntil: 'networkidle2' });
|
||||
|
||||
// Take screenshot
|
||||
await page.screenshot({ path: '/tmp/screenshot.png' });
|
||||
|
||||
// Evaluate JS in page context
|
||||
const title = await page.evaluate(() => document.title);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dev Note
|
||||
|
||||
If `require('puppeteer-core')` fails, set `NODE_PATH=/usr/lib/node_modules`.
|
||||
19
README.md
Normal file
19
README.md
Normal file
@ -0,0 +1,19 @@
|
||||
# browser-automation
|
||||
|
||||
Molecule AI plugin. Install via the Molecule AI platform plugin system.
|
||||
|
||||
## Usage
|
||||
|
||||
### In org template (org.yaml)
|
||||
```yaml
|
||||
plugins:
|
||||
- browser-automation
|
||||
```
|
||||
|
||||
### From URL (community install)
|
||||
```
|
||||
github://Molecule-AI/molecule-ai-plugin-browser-automation
|
||||
```
|
||||
|
||||
## License
|
||||
Business Source License 1.1 — © Molecule AI.
|
||||
2
adapters/claude_code.py
Normal file
2
adapters/claude_code.py
Normal file
@ -0,0 +1,2 @@
|
||||
"""Claude Code adaptor — uses agentskills adaptor with setup.sh support."""
|
||||
from plugins_registry.builtins import AgentskillsAdaptor as Adaptor # noqa: F401
|
||||
159
host-bridge/cdp-proxy.cjs
Executable file
159
host-bridge/cdp-proxy.cjs
Executable file
@ -0,0 +1,159 @@
|
||||
#!/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 BIND_ADDR:PROXY_PORT, rewrites the Host
|
||||
* header, and forwards both HTTP (tab listing, screenshots) and WebSocket upgrades.
|
||||
*
|
||||
* SECURITY (#293):
|
||||
* CDP offers full control of Chrome: execute arbitrary JS in any tab, read
|
||||
* cookies/localStorage/session tokens, screenshot, navigate — effectively
|
||||
* account takeover for any site the user is logged into. The proxy must not
|
||||
* be reachable without authentication.
|
||||
*
|
||||
* We bind to 0.0.0.0 by default because Docker Desktop on macOS routes
|
||||
* `host.docker.internal` through the VM network, not loopback — binding to
|
||||
* 127.0.0.1 would break the primary use case. Instead of restricting the
|
||||
* binding, we require a bearer token on every request.
|
||||
*
|
||||
* The token is read from CDP_PROXY_TOKEN (env var) OR ~/.molecule-cdp-proxy-token
|
||||
* (a chmod 600 file written by install-host-bridge.sh at install time).
|
||||
* If neither is set, the proxy REFUSES TO START — there is no un-authed mode.
|
||||
*
|
||||
* Clients (the bundled `lib/connect.js` helper) send
|
||||
* `X-CDP-Proxy-Token: <token>` on every HTTP request and WebSocket upgrade.
|
||||
*
|
||||
* 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 (normally via install-host-bridge.sh into launchd/systemd):
|
||||
* CDP_PROXY_TOKEN=$(cat ~/.molecule-cdp-proxy-token) node cdp-proxy.cjs
|
||||
*
|
||||
* Env overrides:
|
||||
* CHROME_PORT (default 9222)
|
||||
* PROXY_PORT (default 9223)
|
||||
* BIND_ADDR (default 0.0.0.0 — safe because token auth is required)
|
||||
* CDP_PROXY_TOKEN (required — falls back to ~/.molecule-cdp-proxy-token)
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const http = require('http');
|
||||
const net = require('net');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
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 TOKEN_FILE = path.join(os.homedir(), '.molecule-cdp-proxy-token');
|
||||
|
||||
// Resolve the auth token. Priority: env var > token file. Fail loudly if
|
||||
// neither is present — there is NO unauth mode (#293).
|
||||
function loadToken() {
|
||||
if (process.env.CDP_PROXY_TOKEN && process.env.CDP_PROXY_TOKEN.length >= 16) {
|
||||
return process.env.CDP_PROXY_TOKEN;
|
||||
}
|
||||
try {
|
||||
const tok = fs.readFileSync(TOKEN_FILE, 'utf8').trim();
|
||||
if (tok.length >= 16) return tok;
|
||||
throw new Error(`token file ${TOKEN_FILE} is too short (<16 chars)`);
|
||||
} catch (e) {
|
||||
console.error('FATAL: CDP proxy auth token not found.');
|
||||
console.error('Set CDP_PROXY_TOKEN env var (>=16 chars) OR write a token to');
|
||||
console.error(` ${TOKEN_FILE} (chmod 600)`);
|
||||
console.error('See plugins/browser-automation/host-bridge/install-host-bridge.sh');
|
||||
console.error('for the canonical installer that generates + provisions the token.');
|
||||
console.error('Original error:', e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
const PROXY_TOKEN = loadToken();
|
||||
|
||||
// Constant-time compare to resist timing attacks. Node's crypto.timingSafeEqual
|
||||
// requires equal-length Buffers, so short-circuit mismatched lengths upfront.
|
||||
const crypto = require('crypto');
|
||||
function tokenMatches(header) {
|
||||
if (typeof header !== 'string') return false;
|
||||
const a = Buffer.from(header);
|
||||
const b = Buffer.from(PROXY_TOKEN);
|
||||
if (a.length !== b.length) return false;
|
||||
return crypto.timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
const proxy = http.createServer((req, res) => {
|
||||
if (!tokenMatches(req.headers['x-cdp-proxy-token'])) {
|
||||
res.writeHead(401, { 'Content-Type': 'text/plain' });
|
||||
res.end('unauthorized: missing or invalid X-CDP-Proxy-Token');
|
||||
return;
|
||||
}
|
||||
const options = {
|
||||
hostname: '127.0.0.1',
|
||||
port: CHROME_PORT,
|
||||
path: req.url,
|
||||
method: req.method,
|
||||
// Strip the auth token before forwarding — Chrome CDP doesn't need it
|
||||
// and leaking it into any upstream logs would weaken the defense.
|
||||
headers: stripAuthHeader({ ...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) => {
|
||||
// WebSocket upgrade requests go through the same auth check. If the client
|
||||
// didn't send the token header on the HTTP upgrade request, reject before
|
||||
// we touch the backing Chrome connection at all.
|
||||
if (!tokenMatches(req.headers['x-cdp-proxy-token'])) {
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
const conn = net.connect(CHROME_PORT, '127.0.0.1', () => {
|
||||
const sanitized = stripAuthHeader(req.headers);
|
||||
const upgradeReq =
|
||||
`${req.method} ${req.url} HTTP/1.1\r\n` +
|
||||
`Host: localhost:${CHROME_PORT}\r\n` +
|
||||
Object.entries(sanitized)
|
||||
.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());
|
||||
});
|
||||
|
||||
// stripAuthHeader removes the X-CDP-Proxy-Token before forwarding — defense
|
||||
// in depth so the token can't leak into Chrome's request log or any future
|
||||
// pass-through sink.
|
||||
function stripAuthHeader(headers) {
|
||||
const out = { ...headers };
|
||||
for (const k of Object.keys(out)) {
|
||||
if (k.toLowerCase() === 'x-cdp-proxy-token') delete out[k];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
proxy.listen(PROXY_PORT, BIND_ADDR, () => {
|
||||
console.log(`cdp-proxy listening on ${BIND_ADDR}:${PROXY_PORT} → 127.0.0.1:${CHROME_PORT}`);
|
||||
console.log(`auth required: send X-CDP-Proxy-Token header on every request`);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => proxy.close(() => process.exit(0)));
|
||||
process.on('SIGINT', () => proxy.close(() => process.exit(0)));
|
||||
155
host-bridge/install-host-bridge.sh
Executable file
155
host-bridge/install-host-bridge.sh
Executable file
@ -0,0 +1,155 @@
|
||||
#!/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)"
|
||||
TOKEN_FILE="${HOME}/.molecule-cdp-proxy-token"
|
||||
|
||||
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
|
||||
|
||||
# #293: generate a per-install auth token so the proxy isn't exposed to the
|
||||
# LAN without authentication. Written to ~/.molecule-cdp-proxy-token with
|
||||
# 0600 perms. The proxy reads it at startup; workspace containers read it
|
||||
# via the bundled connect() helper which mounts the token file over a bind.
|
||||
ensure_token() {
|
||||
if [[ -f "$TOKEN_FILE" ]] && [[ "$(wc -c < "$TOKEN_FILE")" -ge 17 ]]; then
|
||||
echo "token: reusing existing $TOKEN_FILE"
|
||||
return
|
||||
fi
|
||||
# 32 bytes of random, hex-encoded → 64 chars. openssl is available on
|
||||
# every macOS + most Linux installs; fall back to /dev/urandom if not.
|
||||
if command -v openssl >/dev/null 2>&1; then
|
||||
openssl rand -hex 32 > "$TOKEN_FILE"
|
||||
else
|
||||
head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n' > "$TOKEN_FILE"
|
||||
fi
|
||||
chmod 600 "$TOKEN_FILE"
|
||||
echo "token: generated new $TOKEN_FILE (0600)"
|
||||
}
|
||||
|
||||
install_macos() {
|
||||
local plist="$HOME/Library/LaunchAgents/${LABEL}.plist"
|
||||
local token_val
|
||||
token_val="$(cat "$TOKEN_FILE")"
|
||||
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>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>CDP_PROXY_TOKEN</key><string>${token_val}</string>
|
||||
</dict>
|
||||
<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
|
||||
# #296: the plist contains the CDP_PROXY_TOKEN in plaintext. Default
|
||||
# umask leaves it world-readable (~0644) which leaks the token to any
|
||||
# local user on a multi-account macOS host. Lock to owner-only. launchctl
|
||||
# loads user agents as the owning UID so 0600 is safe.
|
||||
chmod 600 "$plist"
|
||||
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"
|
||||
# Read token from the file at service start instead of embedding it in
|
||||
# the unit file — unit files are often world-readable, the token file
|
||||
# is 0600. systemd EnvironmentFile reads key=value lines so we write a
|
||||
# sidecar file containing CDP_PROXY_TOKEN=<value>.
|
||||
local env_file="${HOME}/.molecule-cdp-proxy.env"
|
||||
printf 'CDP_PROXY_TOKEN=%s\n' "$(cat "$TOKEN_FILE")" > "$env_file"
|
||||
chmod 600 "$env_file"
|
||||
cat > "$unit" <<EOF
|
||||
[Unit]
|
||||
Description=Molecule browser-automation CDP proxy (host → Chrome)
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
EnvironmentFile=${env_file}
|
||||
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)
|
||||
ensure_token
|
||||
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 -H \"X-CDP-Proxy-Token: \$(cat $TOKEN_FILE)\" http://127.0.0.1:9223/json/version"
|
||||
echo
|
||||
echo "container side: mount $TOKEN_FILE into each workspace and the bundled"
|
||||
echo "lib/connect.js helper will read it automatically. Bind:"
|
||||
echo " -v $TOKEN_FILE:/run/secrets/cdp-proxy-token:ro"
|
||||
;;
|
||||
uninstall)
|
||||
uninstall
|
||||
rm -f "${HOME}/.molecule-cdp-proxy.env" 2>/dev/null || true
|
||||
echo "note: ${TOKEN_FILE} preserved so a future reinstall keeps the same token."
|
||||
echo " delete manually if you want to rotate."
|
||||
;;
|
||||
*) echo "usage: $0 [install|uninstall]"; exit 1 ;;
|
||||
esac
|
||||
57
known-issues.md
Normal file
57
known-issues.md
Normal file
@ -0,0 +1,57 @@
|
||||
# Known Issues — browser-automation
|
||||
|
||||
## KI-001: defaultViewport null required — coords silently wrong without it
|
||||
|
||||
**Severity:** High (silent data corruption)
|
||||
|
||||
**Symptom:** Agent reports "session expired", "button not found", or "text typed in wrong place." The page visually renders correctly, but `window.innerWidth/innerHeight`, `getBoundingClientRect()`, and all click coordinates are based on puppeteer's 800×600 override, not the real viewport.
|
||||
|
||||
**Root cause:** When `defaultViewport` is unset or set to puppeteer's default `{ width: 800, height: 600 }`, Chrome is told to report those dimensions via the CDP `Emulation.setDeviceMetricsOverride` call. The browser's actual rendered size is unchanged, but JS in the page context gets wrong dimensions.
|
||||
|
||||
**Resolution:** Always use the bundled `lib/connect.js` helper, which sets `defaultViewport: null`. If you must use `puppeteer.connect()` directly, always pass `defaultViewport: null`.
|
||||
|
||||
**History:** Root cause of ~3h debug session on 2026-04-15 during social-media-poster runs.
|
||||
|
||||
---
|
||||
|
||||
## KI-002: Shared Chrome profile — no session isolation between agents
|
||||
|
||||
**Severity:** Medium (security/isolation)
|
||||
|
||||
**Symptom:** Agent A sees agent B's logged-in session, or agent actions overwrite each other's cookies/localStorage.
|
||||
|
||||
**Root cause:** The CDP proxy connects all agents to the same Chrome profile at `~/.chrome-molecule/Default`. This is intentional — it allows agents to use the user's existing logged-in sessions — but it means agents are not isolated.
|
||||
|
||||
**Workaround:** Use separate Chrome user data directories per agent when isolation is needed: `--user-data-dir="$HOME/.chrome-molecule-<agent-id>"`. Note: each profile requires its own login to sites.
|
||||
|
||||
---
|
||||
|
||||
## KI-003: CDP proxy FATAL exit if token is absent
|
||||
|
||||
**Severity:** Medium (availability)
|
||||
|
||||
**Symptom:** `cdp-proxy.cjs` exits immediately on startup with:
|
||||
```
|
||||
FATAL: CDP proxy auth token not found.
|
||||
Set CDP_PROXY_TOKEN env var (>=16 chars) OR write a token to ~/.molecule-cdp-proxy-token
|
||||
```
|
||||
|
||||
**Root cause:** The proxy has no unauthenticated mode. If neither `CDP_PROXY_TOKEN` nor `~/.molecule-cdp-proxy-token` exists, it refuses to start.
|
||||
|
||||
**Resolution:** Run `install-host-bridge.sh` once per host to generate the token and register the service. Alternatively, set `CDP_PROXY_TOKEN` in the service environment.
|
||||
|
||||
**History:** Was a deliberate security decision (#293) — there is no "disable auth" flag.
|
||||
|
||||
---
|
||||
|
||||
## KI-004: browser.close() kills the shared Chrome process
|
||||
|
||||
**Severity:** Medium (availability)
|
||||
|
||||
**Symptom:** After an agent run, subsequent agents cannot connect — Chrome is gone. `page.goto()` throws `Target closed`.
|
||||
|
||||
**Root cause:** `browser.close()` calls `Browser.close()` which terminates the Chrome process entirely. All other CDP sessions are killed.
|
||||
|
||||
**Resolution:** Always call `browser.disconnect()` instead, which releases the CDP connection without killing Chrome.
|
||||
|
||||
**Note:** If the agent needs to ensure Chrome stays alive for future runs, prefer `browser.disconnect()`. If Chrome truly needs to be restarted, launch a new Chrome instance with a separate `--user-data-dir`.
|
||||
12
plugin.yaml
Normal file
12
plugin.yaml
Normal file
@ -0,0 +1,12 @@
|
||||
name: browser-automation
|
||||
version: 1.1.0
|
||||
description: Browser automation + testing. Two skills — puppeteer-core/CDP for external sites, Playwright for testing our own apps.
|
||||
author: Molecule AI
|
||||
tags: [browser, puppeteer, playwright, cdp, automation, testing]
|
||||
|
||||
runtimes:
|
||||
- claude_code
|
||||
|
||||
skills:
|
||||
- browser-automation
|
||||
- browser-testing
|
||||
8
rules/cdp-connection.md
Normal file
8
rules/cdp-connection.md
Normal file
@ -0,0 +1,8 @@
|
||||
# Browser Automation Rules
|
||||
|
||||
- Chrome CDP is available at `host.docker.internal:9223` (proxy to host Chrome on port 9222)
|
||||
- Always use `browserWSEndpoint` with URL rewrite (`localhost:9222` → `host.docker.internal:9223`)
|
||||
- Never use `browserURL` — it resolves to an unreachable localhost address
|
||||
- Never call `browser.close()` — use `browser.disconnect()` to release without killing Chrome
|
||||
- Set `NODE_PATH=/usr/lib/node_modules` if `require('puppeteer-core')` fails
|
||||
- The Chrome profile is shared — all agents see the same logged-in sessions
|
||||
177
runbooks/local-dev-setup.md
Normal file
177
runbooks/local-dev-setup.md
Normal file
@ -0,0 +1,177 @@
|
||||
# Local Dev Setup — browser-automation
|
||||
|
||||
This runbook walks through setting up the CDP proxy and Chrome host so agents running inside Docker (or directly on the host) can automate the user's browser.
|
||||
|
||||
**Tested on:** macOS (Chrome + launchd), Linux/WSL (Chrome + systemd). Windows is not supported.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Node.js](https://nodejs.org/) ≥ 18 (for `cdp-proxy.cjs`)
|
||||
- Google Chrome installed on the host
|
||||
- Docker Desktop (macOS/Linux) or Docker Engine on Linux
|
||||
- `git` and the Molecule monorepo checked out locally
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Clone the plugin repo
|
||||
|
||||
```bash
|
||||
git clone git@github.com:Molecule-AI/molecule-ai-plugin-browser-automation.git
|
||||
cd molecule-ai-plugin-browser-automation
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Install the CDP proxy as a persistent host service
|
||||
|
||||
The CDP proxy must be running on your host machine before any agent can connect.
|
||||
|
||||
```bash
|
||||
# From this repo's root:
|
||||
bash host-bridge/install-host-bridge.sh
|
||||
```
|
||||
|
||||
This:
|
||||
1. Generates an auth token in `~/.molecule-cdp-proxy-token` (chmod 600)
|
||||
2. Registers a launchd agent (macOS) or systemd user unit (Linux) under `com.molecule.browser-automation.cdp-proxy`
|
||||
3. Starts the proxy immediately and on every reboot
|
||||
|
||||
**Logs:**
|
||||
- macOS: `~/Library/Logs/com.molecule.browser-automation.cdp-proxy.log` (also `~/.molecule-cdp-proxy.log`)
|
||||
- Linux: `journalctl --user -u com.molecule.browser-automation.cdp-proxy.service -f`
|
||||
|
||||
**Uninstall:**
|
||||
```bash
|
||||
bash host-bridge/install-host-bridge.sh uninstall
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Launch Chrome with the debug port
|
||||
|
||||
Do this once per host reboot (the CDP proxy stays alive across runs; Chrome needs to be started once).
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
open -na "Google Chrome" --args \
|
||||
--remote-debugging-port=9222 \
|
||||
--user-data-dir="$HOME/.chrome-molecule" \
|
||||
--profile-directory=Default
|
||||
|
||||
# Linux (standard Chrome)
|
||||
google-chrome \
|
||||
--remote-debugging-port=9222 \
|
||||
--user-data-dir="$HOME/.chrome-molecule" \
|
||||
--profile-directory=Default
|
||||
```
|
||||
|
||||
> **Note:** Use a dedicated profile directory (`~/.chrome-molecule`) to avoid interfering with your normal Chrome sessions.
|
||||
|
||||
To use existing logged-in sessions: close Chrome, copy your existing profile to `~/.chrome-molecule`, then restart Chrome with the debug flags above.
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Verify Chrome + proxy are reachable
|
||||
|
||||
```bash
|
||||
# Check Chrome debug port
|
||||
curl http://127.0.0.1:9222/json/version
|
||||
|
||||
# Check CDP proxy (requires auth token)
|
||||
curl -H "X-CDP-Proxy-Token: $(cat ~/.molecule-cdp-proxy-token)" \
|
||||
http://127.0.0.1:9223/json/version
|
||||
```
|
||||
|
||||
Expected: both return JSON with `"Browser": "Chrome/..."`.
|
||||
|
||||
If the proxy returns `401 unauthorized`: the token is missing or wrong. Re-run `install-host-bridge.sh`.
|
||||
|
||||
If Chrome returns empty: Chrome debug port isn't open — re-run Step 3.
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Mount the token into your Docker workspace
|
||||
|
||||
The `lib/connect.js` helper reads the CDP proxy token from `/run/secrets/cdp-proxy-token`. Bind-mount the token file when starting your workspace:
|
||||
|
||||
```bash
|
||||
docker run -v "$HOME/.molecule-cdp-proxy-token:/run/secrets/cdp-proxy-token:ro" \
|
||||
...
|
||||
```
|
||||
|
||||
In org templates using `workspaceTemplate`, add to the workspace volume mounts:
|
||||
|
||||
```yaml
|
||||
workspaceTemplate:
|
||||
volumes:
|
||||
- ~/.molecule-cdp-proxy-token:/run/secrets/cdp-proxy-token:ro
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Test end-to-end from inside a workspace
|
||||
|
||||
```bash
|
||||
# Inside a workspace with the plugin installed:
|
||||
node -e "
|
||||
const { connect } = require('/configs/plugins/browser-automation/skills/browser-automation/lib/connect');
|
||||
(async () => {
|
||||
const browser = await connect();
|
||||
const pages = await browser.pages();
|
||||
console.log('Open tabs:', pages.length);
|
||||
if (pages.length > 0) {
|
||||
console.log('URL of first tab:', pages[0].url());
|
||||
}
|
||||
await browser.disconnect();
|
||||
console.log('OK');
|
||||
})().catch(e => { console.error(e.message); process.exit(1); });
|
||||
"
|
||||
```
|
||||
|
||||
Expected output: `Open tabs: N` (N ≥ 0), then `OK`.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### `Error: Failed to connect to Chrome`
|
||||
|
||||
1. Is Chrome running with `--remote-debugging-port=9222`? → `curl localhost:9222/json/version`
|
||||
2. Is the CDP proxy running? → `curl -H "X-CDP-Proxy-Token: ..." localhost:9223/json/version`
|
||||
3. Is the token file mounted in the container at `/run/secrets/cdp-proxy-token`?
|
||||
4. Is Docker Desktop networking set to allow `host.docker.internal`? (default on Docker Desktop)
|
||||
|
||||
### `page.goto()` hangs forever
|
||||
|
||||
Try `{ waitUntil: 'domcontentloaded' }` or `{ waitUntil: 'networkidle0', timeout: 30000 }` — some sites never reach `networkidle2`.
|
||||
|
||||
### `require('puppeteer-core')` fails in workspace
|
||||
|
||||
Set `NODE_PATH=/usr/lib/node_modules` or use the full path:
|
||||
```javascript
|
||||
const puppeteer = require('/usr/lib/node_modules/puppeteer-core');
|
||||
```
|
||||
|
||||
### Chrome is reused across agents (wanted isolation)
|
||||
|
||||
Each Chrome profile is isolated. Launch Chrome with a unique `--user-data-dir` per agent:
|
||||
```bash
|
||||
google-chrome --remote-debugging-port=9223 \
|
||||
--user-data-dir="$HOME/.chrome-molecule-agent-a"
|
||||
```
|
||||
Note: each new profile requires re-logging into sites.
|
||||
|
||||
---
|
||||
|
||||
## CI / Validation
|
||||
|
||||
Run the local plugin validator (requires Python + PyYAML):
|
||||
|
||||
```bash
|
||||
pip install pyyaml
|
||||
python .molecule-ci/scripts/validate-plugin.py
|
||||
```
|
||||
|
||||
This checks `plugin.yaml` structure and verifies at least one content file (SKILL.md, hooks/, skills/, or rules/) exists.
|
||||
5
setup.sh
Executable file
5
setup.sh
Executable file
@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
# Install puppeteer-core (no bundled Chromium — connects to existing Chrome via CDP)
|
||||
set -e
|
||||
npm install -g puppeteer-core 2>/dev/null || true
|
||||
echo "browser-automation: puppeteer-core installed"
|
||||
78
skills/browser-automation/SKILL.md
Normal file
78
skills/browser-automation/SKILL.md
Normal file
@ -0,0 +1,78 @@
|
||||
---
|
||||
id: browser-automation
|
||||
name: browser-automation
|
||||
description: Connect to Chrome via CDP proxy to automate web interactions — posting, scraping, form filling. Uses puppeteer-core (no bundled Chromium).
|
||||
tags: [browser, puppeteer, cdp]
|
||||
---
|
||||
|
||||
# Browser Automation via Chrome CDP
|
||||
|
||||
Connect to the host Chrome browser via the CDP proxy to automate web interactions.
|
||||
|
||||
## Connection — ALWAYS use the helper
|
||||
|
||||
**DO NOT call `puppeteer.connect()` directly.** Use `./lib/connect.js`:
|
||||
|
||||
```javascript
|
||||
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)
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
- **Tab listing:** `http://host.docker.internal:9223/json`
|
||||
- **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:
|
||||
- YouTube, Instagram, Facebook, X/Twitter, LinkedIn, TikTok
|
||||
- Gmail, InvoiceSimple, Google Search Console
|
||||
- Manta, TrustedPros, Foursquare, Pinterest, Medium
|
||||
122
skills/browser-automation/lib/connect.js
Normal file
122
skills/browser-automation/lib/connect.js
Normal file
@ -0,0 +1,122 @@
|
||||
/**
|
||||
* 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)
|
||||
*
|
||||
* Authentication (#293):
|
||||
* The host-bridge cdp-proxy requires an X-CDP-Proxy-Token header on every
|
||||
* HTTP request + WebSocket upgrade. This helper reads the token from:
|
||||
* 1. CDP_PROXY_TOKEN env var (preferred — set by workspace-template
|
||||
* provisioner from a bind-mounted /run/secrets/cdp-proxy-token)
|
||||
* 2. /run/secrets/cdp-proxy-token (mount-time secret — default path)
|
||||
* 3. ~/.molecule-cdp-proxy-token (fallback when running directly on host)
|
||||
* If no token can be found, connect() throws — there is no unauth mode.
|
||||
*
|
||||
* 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 fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const HOST_DOCKER = 'host.docker.internal';
|
||||
const HOST_LOCAL = '127.0.0.1';
|
||||
const PROXY_PORT = 9223; // CDP proxy (rewrites Host header + requires token)
|
||||
const DIRECT_PORT = 9222; // Chrome's native CDP (host-direct fallback, NO auth)
|
||||
|
||||
// Token lookup order — first hit wins. See header comment for rationale.
|
||||
function loadProxyToken() {
|
||||
if (process.env.CDP_PROXY_TOKEN && process.env.CDP_PROXY_TOKEN.length >= 16) {
|
||||
return process.env.CDP_PROXY_TOKEN;
|
||||
}
|
||||
const candidates = [
|
||||
'/run/secrets/cdp-proxy-token',
|
||||
path.join(os.homedir(), '.molecule-cdp-proxy-token'),
|
||||
];
|
||||
for (const p of candidates) {
|
||||
try {
|
||||
const tok = fs.readFileSync(p, 'utf8').trim();
|
||||
if (tok.length >= 16) return tok;
|
||||
} catch {
|
||||
// try next
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function fetchVersion(url, token) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const headers = {};
|
||||
if (token) headers['X-CDP-Proxy-Token'] = token;
|
||||
const req = http.get(url, { headers }, r => {
|
||||
let d = '';
|
||||
r.on('data', c => d += c);
|
||||
r.on('end', () => {
|
||||
if (r.statusCode === 401) {
|
||||
reject(new Error(`CDP proxy unauthorized (401) — token missing or invalid`));
|
||||
return;
|
||||
}
|
||||
try { resolve(JSON.parse(d)); } catch (e) { reject(e); }
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.setTimeout(5000, () => { req.destroy(new Error('timeout')); });
|
||||
});
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
const token = loadProxyToken();
|
||||
|
||||
// Detect environment: are we inside a Docker container or on the host?
|
||||
// host.docker.internal resolves only inside containers.
|
||||
let host, port, usingProxy;
|
||||
try {
|
||||
// Proxy path — token REQUIRED. Throw on missing so the user fixes it
|
||||
// at install time rather than silently falling back to an unauth host
|
||||
// connection that only works on the host machine itself.
|
||||
if (!token) {
|
||||
throw new Error('no token — skip proxy path');
|
||||
}
|
||||
await fetchVersion(`http://${HOST_DOCKER}:${PROXY_PORT}/json/version`, token);
|
||||
host = HOST_DOCKER;
|
||||
port = PROXY_PORT;
|
||||
usingProxy = true;
|
||||
} catch {
|
||||
// Fallback to direct Chrome CDP (host script running ON the host,
|
||||
// no proxy involved). No token needed — Chrome's own port 9222 is
|
||||
// loopback-only and doesn't check Host headers from 127.0.0.1.
|
||||
host = HOST_LOCAL;
|
||||
port = DIRECT_PORT;
|
||||
usingProxy = false;
|
||||
}
|
||||
|
||||
const data = await fetchVersion(`http://${host}:${port}/json/version`, usingProxy ? token : null);
|
||||
// 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}`);
|
||||
|
||||
const connectOpts = {
|
||||
browserWSEndpoint: wsUrl,
|
||||
defaultViewport: null, // CRITICAL: use Chrome's actual window size
|
||||
};
|
||||
if (usingProxy) {
|
||||
// puppeteer-core v21+ supports connection headers. The proxy's WS
|
||||
// upgrade handler validates X-CDP-Proxy-Token before forwarding to
|
||||
// Chrome; without this header the upgrade returns 401.
|
||||
connectOpts.headers = { 'X-CDP-Proxy-Token': token };
|
||||
}
|
||||
|
||||
return puppeteer.connect(connectOpts);
|
||||
}
|
||||
|
||||
module.exports = { connect };
|
||||
89
skills/browser-testing/SKILL.md
Normal file
89
skills/browser-testing/SKILL.md
Normal file
@ -0,0 +1,89 @@
|
||||
---
|
||||
id: browser-testing
|
||||
name: browser-testing
|
||||
description: Real browser testing via Playwright — click, drag, type, screenshot, measure. For testing our own canvas and web apps, not external sites.
|
||||
tags: [browser, playwright, testing, qa, uiux]
|
||||
---
|
||||
|
||||
# Browser Testing via Playwright
|
||||
|
||||
Launch a headless Chromium browser, navigate to a target URL, and interact with the page like a real user — clicking buttons, filling forms, dragging elements, checking keyboard navigation, taking screenshots.
|
||||
|
||||
## When to use
|
||||
|
||||
Use `/browser-test` when you need to:
|
||||
- Verify a UI change actually works (not just code-review the diff)
|
||||
- Test drag-and-drop, form validation, modal behavior
|
||||
- Check responsive layout at different viewport sizes
|
||||
- Audit accessibility (focus order, aria labels, keyboard nav)
|
||||
- Take screenshots for issue reports
|
||||
|
||||
## Setup (auto-installs on first use)
|
||||
|
||||
The skill auto-installs Playwright + Chromium if not present:
|
||||
|
||||
```python
|
||||
import subprocess, sys
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except ImportError:
|
||||
subprocess.run([sys.executable, "-m", "pip", "install", "playwright"], check=True)
|
||||
subprocess.run(["playwright", "install", "chromium"], check=True)
|
||||
from playwright.sync_api import sync_playwright
|
||||
```
|
||||
|
||||
System deps (`libglib2.0-0`, `libnss3`, etc.) must be pre-installed in the container image. If missing, run:
|
||||
```bash
|
||||
apt-get update && apt-get install -y libglib2.0-0 libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libdbus-1-3 libxkbcommon0 libatspi2.0-0 libx11-6 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxrandr2 libgbm1 libpango-1.0-0 libcairo2 libasound2
|
||||
```
|
||||
|
||||
## Usage Pattern
|
||||
|
||||
```python
|
||||
from playwright.sync_api import sync_playwright
|
||||
import os
|
||||
|
||||
TARGET = os.getenv("CANVAS_URL", "http://host.docker.internal:3000")
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={"width": 1280, "height": 720})
|
||||
page.goto(TARGET, timeout=15000)
|
||||
|
||||
# Interact like a human
|
||||
page.click("button:has-text('Create Workspace')")
|
||||
page.fill("input[name='name']", "Test Agent")
|
||||
page.screenshot(path="/tmp/ux-audit/create-form.png")
|
||||
|
||||
# Drag and drop
|
||||
card = page.locator(".workspace-card").first
|
||||
card.drag_to(page.locator(".canvas-area"), target_position={"x": 500, "y": 300})
|
||||
|
||||
# Keyboard navigation
|
||||
page.keyboard.press("Tab")
|
||||
page.keyboard.press("Tab")
|
||||
page.keyboard.press("Enter")
|
||||
|
||||
# Responsive check
|
||||
page.set_viewport_size({"width": 768, "height": 1024})
|
||||
page.screenshot(path="/tmp/ux-audit/tablet.png")
|
||||
|
||||
browser.close()
|
||||
```
|
||||
|
||||
## Screenshot Directory
|
||||
|
||||
Save all screenshots to `/tmp/ux-audit/`. Create the dir first:
|
||||
```python
|
||||
os.makedirs("/tmp/ux-audit", exist_ok=True)
|
||||
```
|
||||
|
||||
## Key Differences from browser-automation
|
||||
|
||||
| | browser-automation | browser-testing |
|
||||
|---|---|---|
|
||||
| Backend | Puppeteer (JS) + host CDP | Playwright (Python) + bundled Chromium |
|
||||
| Target | External sites (social media) | Our own canvas/apps |
|
||||
| Browser | User's Chrome (shared sessions) | Headless Chromium (isolated) |
|
||||
| Auth | Relies on host Chrome cookies | No auth needed (canvas is local) |
|
||||
| Close | `browser.disconnect()` (keep host Chrome) | `browser.close()` (kill headless) |
|
||||
Loading…
Reference in New Issue
Block a user