browserrot: Node stealth sidecar + AI self-heal (M-B1..M-B3, LIVE-VM)
Extends the Phase A-tier-1 site-side rotation engine with an optional Node browser-sidecar and a deterministic self-heal tier, all proven LIVE-VM against the local lab change-password form. go-rod stays the default; the sidecar is a swap-in Driver behind the same Session/Driver/Rotator interfaces — no spine change, backup + verify-new-before-revoke-old + proof gates all apply unchanged. M-B1 sidecar parity: internal/browserrot/sidecar/ (index.js JSON-RPC over stdio, patchright real-fingerprint Chromium, playwright-core fallback) + sidecar.go Driver/Session. A shared test harness runs go-rod and the sidecar through the identical live rotation proof. M-B2 stealth: patchright removes navigator.webdriver; humanized input (Bezier mouse paths + per-key typing jitter, no ML trajectory generation per plan §5). M-B3 self-heal: Healer interface + heal-retry fill/click/text wrappers; on a rotted selector, heal.js relocates the field from DOM structure ONLY (never a value) and the engine rewrites + persists the recipe (recipes.go RecipeBook) so the next run is Tier-1 again. Deterministic heuristic is the proven Tier-2; Stagehand LLM strategy is opt-in + UNPROVEN. Leak-check asserts no secret ever reaches a heal request (redacted IPC transcript). Secret seam documented honestly: fill carries base64 secret bytes across exactly one local stdio boundary, typed and wiped, never logged/persisted/sent to a model. MFA/CAPTCHA still degrade to the human worklist (Hard Rule 5). 14 tests green, -race clean; lab/lab-provision-browserrot.sh proves both drivers. M-B4 (first REAL self-owned site) deliberately NOT started — needs per-credential authorization (safe-candidate ladder). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
'use strict';
|
||||
// Tier-2 self-heal for the browserrot sidecar. Given a natural-language field description
|
||||
// and the LIVE page, return a fresh CSS selector for that field. The Go engine calls this
|
||||
// only when a Tier-1 recipe selector misses, then re-persists the healed selector.
|
||||
//
|
||||
// HARD RULE (CLAUDE.md 6 / BROWSERROT-PLAN §1): heal receives ONLY a description + the
|
||||
// page's structure. It is NEVER handed a secret, and it MUST NOT read field VALUES — it
|
||||
// derives selectors from tag/type/name/id/placeholder/label/aria text only.
|
||||
//
|
||||
// Two strategies, in order:
|
||||
// 1. Stagehand `observe` (LLM) — used when @browserbasehq/stagehand is installed AND an
|
||||
// LLM key is present. The model sees sanitized DOM structure, never a value.
|
||||
// 2. Deterministic DOM heuristic (no model) — a keyword/attribute match over form
|
||||
// controls. Hermetic and offline; this is what the lab proof exercises.
|
||||
|
||||
// --- keyword buckets derived from the field descriptions the engine sends. ------------
|
||||
const BUCKETS = [
|
||||
{ key: 'current', words: ['current', 'old', 'existing'], type: 'password' },
|
||||
{ key: 'confirm', words: ['confirm', 'again', 're-enter', 'reenter', 'repeat', 'verify'], type: 'password' },
|
||||
{ key: 'new', words: ['new'], type: 'password' },
|
||||
{ key: 'username',words: ['username', 'user name', 'email', 'e-mail', 'login', 'account'], type: 'text' },
|
||||
{ key: 'password',words: ['password', 'pass'], type: 'password' },
|
||||
{ key: 'submit', words: ['submit', 'save', 'change', 'update', 'apply', 'continue'], type: 'button' },
|
||||
{ key: 'login', words: ['log in', 'log-in', 'login', 'sign in', 'sign-in'], type: 'button' },
|
||||
{ key: 'status', words: ['success', 'error', 'status', 'message', 'result', 'alert'], type: 'text' },
|
||||
];
|
||||
|
||||
function bucketFor(desc) {
|
||||
const d = (desc || '').toLowerCase();
|
||||
// Most specific buckets first (current/confirm before generic password).
|
||||
for (const b of BUCKETS) {
|
||||
if (b.words.some((w) => d.includes(w))) return b;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// heuristicHeal walks the page's form controls and picks the best selector for the bucket.
|
||||
// It reads structure/attributes only — never element.value.
|
||||
async function heuristicHeal(page, desc) {
|
||||
const bucket = bucketFor(desc);
|
||||
const kind = bucket ? bucket.type : 'text';
|
||||
const words = bucket ? bucket.words : (desc || '').toLowerCase().split(/\s+/);
|
||||
|
||||
// Collect candidate elements + their non-value descriptive text, in the browser.
|
||||
const cands = await page.evaluate((kind) => {
|
||||
function labelText(el) {
|
||||
let t = '';
|
||||
if (el.id) {
|
||||
const l = document.querySelector('label[for="' + CSS.escape(el.id) + '"]');
|
||||
if (l) t += ' ' + l.textContent;
|
||||
}
|
||||
const p = el.closest('label');
|
||||
if (p) t += ' ' + p.textContent;
|
||||
return t;
|
||||
}
|
||||
let sel;
|
||||
if (kind === 'password') sel = 'input[type=password]';
|
||||
else if (kind === 'button') sel = 'button, input[type=submit], [role=button]';
|
||||
else if (kind === 'text') sel = 'input, [role=alert], .message, .result, .status, h1, h2, p, span, div';
|
||||
else sel = 'input';
|
||||
const out = [];
|
||||
let i = 0;
|
||||
for (const el of document.querySelectorAll(sel)) {
|
||||
// descriptive attributes ONLY — never el.value
|
||||
const desc = [
|
||||
el.getAttribute('name'), el.id, el.getAttribute('placeholder'),
|
||||
el.getAttribute('aria-label'), el.getAttribute('autocomplete'),
|
||||
el.getAttribute('title'), labelText(el),
|
||||
(el.tagName === 'BUTTON' || el.tagName === 'H1' || el.tagName === 'H2') ? el.textContent : '',
|
||||
].filter(Boolean).join(' ').toLowerCase();
|
||||
// stable selector: prefer #id, else name=, else nth of the candidate set
|
||||
let css;
|
||||
if (el.id) css = '#' + CSS.escape(el.id);
|
||||
else if (el.getAttribute('name')) css = el.tagName.toLowerCase() + '[name="' + el.getAttribute('name') + '"]';
|
||||
else { el.setAttribute('data-incredigo-heal', String(i)); css = '[data-incredigo-heal="' + i + '"]'; }
|
||||
out.push({ css, desc });
|
||||
i++;
|
||||
}
|
||||
return out;
|
||||
}, kind);
|
||||
|
||||
// Score by keyword hits; the ordering of BUCKETS already disambiguates current/new/confirm.
|
||||
let best = null, bestScore = -1;
|
||||
for (const c of cands) {
|
||||
let score = 0;
|
||||
for (const w of words) if (c.desc.includes(w)) score += 2;
|
||||
if (score > bestScore) { bestScore = score; best = c; }
|
||||
}
|
||||
// For password fields with no keyword hit, fall back to positional convention
|
||||
// (current, new, confirm) handled by the caller ordering; here just return first.
|
||||
if (!best && cands.length) best = cands[0];
|
||||
if (!best) throw new Error('heal: no candidate element for "' + desc + '"');
|
||||
return best.css;
|
||||
}
|
||||
|
||||
// stagehandHeal uses the Stagehand `observe` API to locate the field with an LLM. It is
|
||||
// STRICTLY OPT-IN (INCREDIGO_HEAL_STRATEGY=stagehand) and UNPROVEN: Stagehand owns its own
|
||||
// browser in LOCAL mode, so wiring it to drive the sidecar's existing page needs more work
|
||||
// (§9). Until then the deterministic heuristic below is the proven Tier-2. The model would
|
||||
// see DOM structure only, never a value. Returns null to signal "use heuristic".
|
||||
async function stagehandHeal(page, desc) {
|
||||
if (process.env.INCREDIGO_HEAL_STRATEGY !== 'stagehand') return null; // not opted in
|
||||
let Stagehand;
|
||||
try { ({ Stagehand } = require('@browserbasehq/stagehand')); }
|
||||
catch (_) { return null; } // not installed
|
||||
const key = process.env.INCREDIGO_HEAL_LLM_KEY || process.env.ANTHROPIC_API_KEY || process.env.OPENAI_API_KEY;
|
||||
if (!key) return null; // no key
|
||||
const sh = new Stagehand({ env: 'LOCAL', modelName: process.env.INCREDIGO_HEAL_MODEL || 'claude-3-5-sonnet-latest' });
|
||||
await sh.init({ page });
|
||||
const results = await sh.page.observe({ instruction: 'find ' + desc });
|
||||
if (Array.isArray(results) && results.length && results[0].selector) return results[0].selector;
|
||||
return null;
|
||||
}
|
||||
|
||||
async function healField(page, params) {
|
||||
const desc = (params && params.description) || '';
|
||||
// The deterministic heuristic is the default, proven Tier-2. The LLM strategy is opt-in
|
||||
// and experimental; if it is enabled and errors, we still fall back to the heuristic.
|
||||
try {
|
||||
const viaLLM = await stagehandHeal(page, desc);
|
||||
if (viaLLM) return { selector: viaLLM, strategy: 'stagehand' };
|
||||
} catch (e) {
|
||||
process.stderr.write('[sidecar] stagehand heal failed, falling back: ' + e.message + '\n');
|
||||
}
|
||||
const selector = await heuristicHeal(page, desc);
|
||||
return { selector, strategy: 'heuristic' };
|
||||
}
|
||||
|
||||
module.exports = { healField };
|
||||
@@ -0,0 +1,246 @@
|
||||
#!/usr/bin/env node
|
||||
// incredigo browserrot Node sidecar.
|
||||
//
|
||||
// A single sidecar process == a single browser Session (one browser, one page),
|
||||
// spawned and torn down by the Go side (internal/browserrot/sidecar.go). It speaks
|
||||
// newline-delimited JSON-RPC over stdio:
|
||||
//
|
||||
// stdin <- {"id":N,"method":"open|goto|fill|click|text|heal|close","params":{...}}
|
||||
// stdout -> {"id":N,"result":{...}} | {"id":N,"error":"message"}
|
||||
//
|
||||
// HARD RULES honored here (mirror the Go engine + CLAUDE.md):
|
||||
// * Secrets arrive base64-encoded in `fill.params.secret` and cross exactly ONE local
|
||||
// process boundary. They are decoded, typed into a field, and dropped. They are
|
||||
// NEVER logged, NEVER written to disk, NEVER put in a heal/act request to a model.
|
||||
// * stdout carries ONLY JSON-RPC frames. All diagnostics go to stderr. A secret value
|
||||
// is never emitted on either stream.
|
||||
// * MFA/CAPTCHA are not bypassed here: on a challenge the success marker simply won't
|
||||
// match and the Go engine keeps the old secret (see browserrot.go Rotate).
|
||||
'use strict';
|
||||
|
||||
// ---- driver loading: patchright (real fingerprint) preferred, playwright-core fallback.
|
||||
function loadChromium() {
|
||||
try {
|
||||
const pw = require('patchright');
|
||||
return { chromium: pw.chromium, driver: 'patchright' };
|
||||
} catch (_) {
|
||||
const pw = require('playwright-core');
|
||||
return { chromium: pw.chromium, driver: 'playwright-core' };
|
||||
}
|
||||
}
|
||||
|
||||
function log(...a) { process.stderr.write('[sidecar] ' + a.join(' ') + '\n'); }
|
||||
|
||||
// ---- humanized input (M-B2). Deliberately simple: randomized per-key delay, a short
|
||||
// pre-action settle, and a plain cubic-Bézier mouse path to the target before a click.
|
||||
// NO ML trajectory generation (BROWSERROT-PLAN §5 — DMTG discriminators make learned
|
||||
// trajectories *more* detectable; plain Bézier+jitter is the right call for a self-login).
|
||||
function jitter(min, max) { return min + Math.floor(Math.random() * (max - min)); }
|
||||
async function settle(page) { await page.waitForTimeout(jitter(40, 140)); }
|
||||
|
||||
// bezierMove walks the mouse from its last position to (x,y) along a cubic Bézier with
|
||||
// two randomized control points, in a handful of jittered steps.
|
||||
async function bezierMove(page, x, y) {
|
||||
const start = bezierMove._last || { x: jitter(0, 60), y: jitter(0, 60) };
|
||||
const c1 = { x: start.x + (x - start.x) * 0.3 + jitter(-40, 40), y: start.y + (y - start.y) * 0.3 + jitter(-40, 40) };
|
||||
const c2 = { x: start.x + (x - start.x) * 0.7 + jitter(-40, 40), y: start.y + (y - start.y) * 0.7 + jitter(-40, 40) };
|
||||
const steps = jitter(14, 26);
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
const t = i / steps, mt = 1 - t;
|
||||
const px = mt*mt*mt*start.x + 3*mt*mt*t*c1.x + 3*mt*t*t*c2.x + t*t*t*x;
|
||||
const py = mt*mt*mt*start.y + 3*mt*mt*t*c1.y + 3*mt*t*t*c2.y + t*t*t*y;
|
||||
await page.mouse.move(px, py);
|
||||
await page.waitForTimeout(jitter(4, 16));
|
||||
}
|
||||
bezierMove._last = { x, y };
|
||||
}
|
||||
|
||||
// humanClick moves along a Bézier path into a random point inside the element, then clicks.
|
||||
async function humanClick(page, loc) {
|
||||
const box = await loc.boundingBox();
|
||||
if (box) {
|
||||
await bezierMove(page, box.x + box.width * (0.3 + Math.random() * 0.4),
|
||||
box.y + box.height * (0.3 + Math.random() * 0.4));
|
||||
}
|
||||
await loc.click();
|
||||
}
|
||||
|
||||
class SidecarSession {
|
||||
constructor() {
|
||||
this.browser = null;
|
||||
this.context = null;
|
||||
this.page = null;
|
||||
this.driver = null;
|
||||
this.stealth = false;
|
||||
this.timeoutMs = 30000;
|
||||
}
|
||||
|
||||
async open(params) {
|
||||
const { chromium, driver } = loadChromium();
|
||||
this.driver = driver;
|
||||
this.stealth = !!params.stealth;
|
||||
if (params.timeoutMs) this.timeoutMs = params.timeoutMs;
|
||||
|
||||
const launchOpts = {
|
||||
headless: params.headless !== false,
|
||||
args: ['--no-sandbox', '--disable-dev-shm-usage'],
|
||||
};
|
||||
if (params.executablePath) launchOpts.executablePath = params.executablePath;
|
||||
|
||||
this.browser = await chromium.launch(launchOpts);
|
||||
// A fresh context per session == fresh profile, so no cookies/state leak between
|
||||
// accounts (parity with go-rod's fresh-browser-per-Open).
|
||||
this.context = await this.browser.newContext();
|
||||
this.page = await this.context.newPage();
|
||||
// Navigation gets the full budget; element LOCATION gets a shorter one so a rotted
|
||||
// selector fails fast and Tier-2 heal kicks in promptly (rather than blocking the whole
|
||||
// per-op timeout). Configurable for slow SPAs.
|
||||
this.locateMs = params.locateTimeoutMs || Math.min(this.timeoutMs, 8000);
|
||||
this.page.setDefaultNavigationTimeout(this.timeoutMs);
|
||||
this.page.setDefaultTimeout(this.locateMs);
|
||||
return { ok: true, driver: this.driver, stealth: this.stealth };
|
||||
}
|
||||
|
||||
async goto(params) {
|
||||
await this.page.goto(params.url, { waitUntil: 'domcontentloaded' });
|
||||
await this.page.waitForLoadState('networkidle').catch(() => {});
|
||||
return { finalURL: this.page.url() };
|
||||
}
|
||||
|
||||
async fill(params) {
|
||||
// secret is base64 to survive JSON transport intact and to mark it as sensitive.
|
||||
const secret = Buffer.from(params.secret || '', 'base64');
|
||||
const loc = this.page.locator(params.selector).first();
|
||||
await loc.waitFor({ state: 'visible' });
|
||||
await settle(this.page);
|
||||
if (this.stealth) {
|
||||
// paced, per-key typing — looks human and satisfies JS-validated forms.
|
||||
await humanClick(this.page, loc);
|
||||
await loc.fill(''); // clear
|
||||
await loc.pressSequentially(secret.toString('utf8'), { delay: jitter(35, 110) });
|
||||
} else {
|
||||
await loc.fill(secret.toString('utf8'));
|
||||
}
|
||||
secret.fill(0); // best-effort wipe of the decoded buffer
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async click(params) {
|
||||
const loc = this.page.locator(params.selector).first();
|
||||
await loc.waitFor({ state: 'visible' });
|
||||
await settle(this.page);
|
||||
if (this.stealth) { await humanClick(this.page, loc); } else { await loc.click(); }
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// probe returns a snapshot of common automation-detection signals so a test can assert
|
||||
// the stealth posture (e.g. navigator.webdriver absent under patchright). It reads only
|
||||
// page globals — no secret is involved.
|
||||
async probe() {
|
||||
const sig = await this.page.evaluate(() => ({
|
||||
webdriver: navigator.webdriver === true,
|
||||
hasChrome: !!window.chrome,
|
||||
plugins: navigator.plugins.length,
|
||||
languages: (navigator.languages || []).join(','),
|
||||
headlessUA: /Headless/.test(navigator.userAgent),
|
||||
}));
|
||||
return { driver: this.driver, signals: sig };
|
||||
}
|
||||
|
||||
async text(params) {
|
||||
const loc = this.page.locator(params.selector).first();
|
||||
await loc.waitFor({ state: 'visible' });
|
||||
return { text: (await loc.textContent()) || '' };
|
||||
}
|
||||
|
||||
// heal (Tier-2, M-B3): relocate a field by natural-language description. Receives ONLY
|
||||
// a description + sanitized DOM structure — never a secret. Wired to Stagehand when
|
||||
// available; otherwise reports unavailable so the Go engine fails closed.
|
||||
async heal(params) {
|
||||
return healField(this.page, params);
|
||||
}
|
||||
|
||||
async close() {
|
||||
try { if (this.context) await this.context.close(); } catch (_) {}
|
||||
try { if (this.browser) await this.browser.close(); } catch (_) {}
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
|
||||
// healField is defined in heal.js so Stagehand stays an optional dependency.
|
||||
let healField;
|
||||
try {
|
||||
healField = require('./heal.js').healField;
|
||||
} catch (_) {
|
||||
healField = async () => { throw new Error('heal unavailable: install @browserbasehq/stagehand and set an LLM key'); };
|
||||
}
|
||||
|
||||
// ---- JSON-RPC dispatch loop over stdio -------------------------------------------
|
||||
const session = new SidecarSession();
|
||||
|
||||
async function dispatch(msg) {
|
||||
const { id, method, params } = msg;
|
||||
try {
|
||||
let result;
|
||||
switch (method) {
|
||||
case 'open': result = await session.open(params || {}); break;
|
||||
case 'goto': result = await session.goto(params || {}); break;
|
||||
case 'fill': result = await session.fill(params || {}); break;
|
||||
case 'click': result = await session.click(params || {}); break;
|
||||
case 'text': result = await session.text(params || {}); break;
|
||||
case 'probe': result = await session.probe(params || {}); break;
|
||||
case 'heal': result = await session.heal(params || {}); break;
|
||||
case 'close': result = await session.close(params || {}); break;
|
||||
default: throw new Error('unknown method: ' + method);
|
||||
}
|
||||
reply({ id, result });
|
||||
} catch (err) {
|
||||
// err.message must never contain a secret: fill()/click() only ever surface the
|
||||
// selector, and secret bytes live only in the local `secret` buffer above.
|
||||
reply({ id, error: String((err && err.message) || err) });
|
||||
if (method === 'close') process.exit(0);
|
||||
}
|
||||
if (method === 'close') process.exit(0);
|
||||
}
|
||||
|
||||
function reply(obj) { process.stdout.write(JSON.stringify(obj) + '\n'); }
|
||||
|
||||
// Optional REDACTED transcript (tests/audit only; off unless INCREDIGO_SIDECAR_TRANSCRIPT
|
||||
// is set). It records method + params with any `secret` field replaced by a byte-count
|
||||
// placeholder, so a leak test can prove heal requests carry no secret WITHOUT ever writing
|
||||
// a real secret to disk (hard rule 3).
|
||||
const fs = require('fs');
|
||||
const TRANSCRIPT = process.env.INCREDIGO_SIDECAR_TRANSCRIPT || '';
|
||||
function record(msg) {
|
||||
if (!TRANSCRIPT) return;
|
||||
const redacted = { id: msg.id, method: msg.method, params: {} };
|
||||
const p = msg.params || {};
|
||||
for (const k of Object.keys(p)) {
|
||||
redacted.params[k] = (k === 'secret')
|
||||
? '<redacted ' + Buffer.from(String(p[k]), 'base64').length + 'b>'
|
||||
: p[k];
|
||||
}
|
||||
try { fs.appendFileSync(TRANSCRIPT, JSON.stringify(redacted) + '\n'); } catch (_) {}
|
||||
}
|
||||
|
||||
let buf = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', (chunk) => {
|
||||
buf += chunk;
|
||||
let nl;
|
||||
while ((nl = buf.indexOf('\n')) >= 0) {
|
||||
const line = buf.slice(0, nl).trim();
|
||||
buf = buf.slice(nl + 1);
|
||||
if (!line) continue;
|
||||
let msg;
|
||||
try { msg = JSON.parse(line); } catch (e) { log('bad JSON frame:', e.message); continue; }
|
||||
record(msg);
|
||||
dispatch(msg);
|
||||
}
|
||||
});
|
||||
process.stdin.on('end', async () => { await session.close().catch(() => {}); process.exit(0); });
|
||||
|
||||
process.on('uncaughtException', (e) => { log('uncaught:', e.message); });
|
||||
process.on('unhandledRejection', (e) => { log('unhandled:', (e && e.message) || e); });
|
||||
|
||||
log('ready');
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "incredigo-browserrot-sidecar",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "incredigo browserrot Node sidecar — real-fingerprint Chromium driver behind JSON-RPC/stdio. Speaks the same Session contract as the Go go-rod driver. NEVER logs a secret, NEVER writes one to disk, NEVER sends one to a model.",
|
||||
"license": "UNLICENSED",
|
||||
"main": "index.js",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"dependencies": {
|
||||
"playwright-core": "^1.61.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@browserbasehq/stagehand": "^1.9.0",
|
||||
"patchright": "^1.61.1"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user