c2f08d1a21
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>
130 lines
6.4 KiB
JavaScript
130 lines
6.4 KiB
JavaScript
'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 };
|