Add `homelab browser run|open` so agents can drive the cluster's headful Chrome (chrome-service) over CDP from the devvm. The headless playwright/mcp browser can load anti-bot sites and fill their forms, but the gated submit silently fails — e.g. the Stirling Ackroyd Fixflo tenant portal returned net::ERR_FILE_NOT_FOUND on its pre-submit check and hung, creating nothing. Driving the real headful Chrome submits first try. That capability already existed but was undiscoverable, so it cost ~40 min + redundant form re-runs to find; now it is one command, versioned, test-covered, and `browser --help` carries the when-to-use signature + an error-code cheat-sheet so the right tool is reached at the right moment (the failure was judgment, not setup). - port-forward svc/chrome-service:9222 (tunnels API-server->pod, so it bypasses the :9222 NetworkPolicy), assert non-headless via /json/version, connect_over_cdp, inject the same vendored stealth.js the in-cluster callers use; the port-forward is always torn down, on success and on error. - node CDP client pinned to playwright-core@1.48.2 to match the v1.48.0-noble image (Chromium 130); self-provisioned lazily into ~/.cache/homelab, no per-user setup. - default is a fresh incognito context (safe for the shared browser + concurrent callers); --shared-context reuses the warmed persistent profile. - TDD: cmd_browser_test.go covers arg parsing, headless detection, the version pin, the help cheat-sheet, and a stealth.js drift guard. Verified end-to-end against bot.sannysoft.com (real Chrome UA, webdriver hidden, plugins/WebGL spoofed) and `browser open`. - docs: README v0.8 section, ADR-0013, and a chrome-service.md "driving from outside the cluster" section. Closes: code-nepg Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
106 lines
3.8 KiB
JavaScript
106 lines
3.8 KiB
JavaScript
// homelab browser — node CDP runner (auto-managed; regenerated each run from the
|
|
// homelab binary — DO NOT EDIT here). Connects to the port-forwarded
|
|
// chrome-service CDP endpoint, installs the stealth init script, then runs the
|
|
// user's Playwright script (run mode) or opens a URL (open mode). All inputs
|
|
// arrive via HOMELAB_* env vars set by the Go CLI.
|
|
'use strict';
|
|
const fs = require('fs');
|
|
const { chromium } = require('playwright-core');
|
|
|
|
async function main() {
|
|
const cdpURL = process.env.HOMELAB_CDP_URL;
|
|
if (!cdpURL) throw new Error('HOMELAB_CDP_URL not set');
|
|
const mode = process.env.HOMELAB_BROWSER_MODE || 'run';
|
|
const stealthPath = process.env.HOMELAB_STEALTH_PATH || '';
|
|
const initURL = process.env.HOMELAB_BROWSER_URL || '';
|
|
const scriptPath = process.env.HOMELAB_BROWSER_SCRIPT || '';
|
|
const shared = process.env.HOMELAB_BROWSER_SHARED === '1';
|
|
const keepOpen = process.env.HOMELAB_BROWSER_KEEP_OPEN === '1';
|
|
const screenshotPath = process.env.HOMELAB_BROWSER_SCREENSHOT || '';
|
|
|
|
const browser = await chromium.connectOverCDP(cdpURL);
|
|
|
|
// Fresh isolated context by default (safe for the shared browser + concurrent
|
|
// callers); --shared-context reuses the warmed persistent profile.
|
|
let context;
|
|
let createdContext = false;
|
|
if (shared) {
|
|
const existing = browser.contexts();
|
|
if (existing.length) {
|
|
context = existing[0];
|
|
} else {
|
|
context = await browser.newContext();
|
|
createdContext = true;
|
|
}
|
|
} else {
|
|
context = await browser.newContext();
|
|
createdContext = true;
|
|
}
|
|
|
|
if (stealthPath) {
|
|
const stealth = fs.readFileSync(stealthPath, 'utf8');
|
|
if (stealth.trim()) await context.addInitScript(stealth);
|
|
}
|
|
|
|
const page = await context.newPage();
|
|
const log = (...a) => console.error('[browser]', ...a);
|
|
|
|
let exitCode = 0;
|
|
try {
|
|
if (initURL) {
|
|
await page.goto(initURL, { waitUntil: 'domcontentloaded' });
|
|
}
|
|
if (mode === 'open') {
|
|
console.log('url: ' + page.url());
|
|
console.log('title: ' + (await page.title()));
|
|
const text = (await page.evaluate(() => (document.body ? document.body.innerText : ''))).trim();
|
|
console.log('--- visible text (truncated to 4000 chars) ---');
|
|
console.log(text.slice(0, 4000));
|
|
if (screenshotPath) {
|
|
await page.screenshot({ path: screenshotPath, fullPage: true });
|
|
console.log('screenshot: ' + screenshotPath);
|
|
}
|
|
} else {
|
|
if (!scriptPath) throw new Error('run mode requires HOMELAB_BROWSER_SCRIPT');
|
|
const src = fs.readFileSync(scriptPath, 'utf8');
|
|
// Run the user's source with page/context/browser/log in lexical scope.
|
|
// AsyncFunction body permits top-level await.
|
|
const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor;
|
|
const fn = new AsyncFunction('page', 'context', 'browser', 'log', src);
|
|
const result = await fn(page, context, browser, log);
|
|
if (result !== undefined) {
|
|
let out;
|
|
try {
|
|
out = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
|
|
} catch (_) {
|
|
out = String(result);
|
|
}
|
|
console.log(out);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('homelab browser: script error:', e && e.stack ? e.stack : e);
|
|
exitCode = 1;
|
|
} finally {
|
|
if (!keepOpen) {
|
|
try {
|
|
// Close only what we created; never tear down the shared persistent context.
|
|
if (createdContext) {
|
|
await context.close();
|
|
} else {
|
|
await page.close();
|
|
}
|
|
} catch (_) { /* ignore */ }
|
|
}
|
|
// Disconnect from the CDP endpoint; this does NOT kill the remote browser.
|
|
try {
|
|
await browser.close();
|
|
} catch (_) { /* ignore */ }
|
|
}
|
|
process.exit(exitCode);
|
|
}
|
|
|
|
main().catch((e) => {
|
|
console.error('homelab browser: fatal:', e && e.stack ? e.stack : e);
|
|
process.exit(1);
|
|
});
|