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:
leetcrypt
2026-07-20 08:51:28 -07:00
parent 8dbb2f21fd
commit c2f08d1a21
16 changed files with 1642 additions and 22 deletions
+222
View File
@@ -0,0 +1,222 @@
package browserrot
import (
"context"
"fmt"
"path/filepath"
"strings"
"sync"
"testing"
"incredigo/internal/pwgen"
"incredigo/internal/rotate"
"incredigo/internal/vault"
)
// healingFake is a browser-free Session+Healer: it FAILS any fill/click/text whose
// selector is not in `good`, and its Heal maps a field description to the correct
// selector — modeling a recipe whose selectors have rotted and a Tier-2 heal fixing them.
// It records every Heal description so a test can prove no secret is ever passed to heal.
type healingFake struct {
mu sync.Mutex
good map[string]bool // selectors that "exist" on the page
healMap map[string]string
healSeen []string // descriptions passed to Heal (never a secret, by contract)
healCalls int
secrets [][]byte // secret bytes handed to Fill (for leak assertions)
success string
}
func (h *healingFake) Name() string { return "healing-fake" }
func (h *healingFake) Open(context.Context) (Session, error) {
return h, nil
}
func (h *healingFake) Goto(context.Context, string) (string, error) { return "about:blank", nil }
func (h *healingFake) Fill(_ context.Context, sel string, secret []byte) error {
h.mu.Lock()
defer h.mu.Unlock()
if !h.good[sel] {
return fmt.Errorf("healing-fake: no element for %q", sel)
}
h.secrets = append(h.secrets, append([]byte(nil), secret...))
return nil
}
func (h *healingFake) Click(_ context.Context, sel string) error {
h.mu.Lock()
defer h.mu.Unlock()
if !h.good[sel] {
return fmt.Errorf("healing-fake: no element for %q", sel)
}
return nil
}
func (h *healingFake) Text(_ context.Context, sel string) (string, error) {
h.mu.Lock()
defer h.mu.Unlock()
if !h.good[sel] {
return "", fmt.Errorf("healing-fake: no element for %q", sel)
}
return h.success, nil
}
func (h *healingFake) Close() error { return nil }
func (h *healingFake) Heal(_ context.Context, description string) (string, error) {
h.mu.Lock()
defer h.mu.Unlock()
h.healCalls++
h.healSeen = append(h.healSeen, description)
for kw, sel := range h.healMap {
if strings.Contains(description, kw) {
return sel, nil
}
}
return "", fmt.Errorf("healing-fake: cannot heal %q", description)
}
var _ Healer = (*healingFake)(nil)
// The engine must: (1) heal a missed selector via Tier-2, (2) rewrite the recipe in
// memory, (3) never pass a secret to Heal. Then the healed recipe must PERSIST so a
// second run needs NO heal (Tier-1 again) — the whole "learn once" loop.
func TestHeal_engineRetriesRewritesAndPersists(t *testing.T) {
v := vault.New()
defer v.Purge()
const oldPW = "OLD-corr3ct-horse"
c := gitHubPAT(t, v, oldPW)
// Recipe with a ROTTED current-password selector; every other selector is correct.
site := Site{
Host: "github.com",
Form: Form{
CurrentSel: "#stale-current", NewSel: "#new", ConfirmSel: "#confirm",
SubmitSel: "#submit", SuccessSel: "#result", SuccessText: "SUCCESS",
},
Proof: rotate.ProofMockOnly,
}
fake := &healingFake{
good: map[string]bool{
"#current": true, "#new": true, "#confirm": true, "#submit": true, "#result": true,
},
healMap: map[string]string{"current password": "#current"},
success: "SUCCESS",
}
r := New(site, fake, pwgen.DefaultPolicy())
newH, err := r.Rotate(context.Background(), c, v)
if err != nil {
t.Fatalf("Rotate with heal: %v", err)
}
if !r.Healed() {
t.Fatal("expected Healed() == true after a selector miss was healed")
}
if got := r.HealedSite().Form.CurrentSel; got != "#current" {
t.Fatalf("healed recipe CurrentSel = %q, want #current", got)
}
if fake.healCalls != 1 {
t.Fatalf("expected exactly 1 heal call, got %d", fake.healCalls)
}
// Leak check: no description handed to Heal may contain either secret.
newPW := mustReveal(t, v, newH)
for _, d := range fake.healSeen {
if strings.Contains(d, oldPW) || strings.Contains(d, newPW) {
t.Fatalf("LEAK: a secret reached a heal description: %q", d)
}
}
v.Forget(newH)
// Persist the healed recipe, reload it, and run again with a FRESH healing fake whose
// heal would fail — proving the second run is Tier-1 (no heal needed).
path := filepath.Join(t.TempDir(), "recipes.json")
rb := &RecipeBook{Sites: map[string]Site{}}
rb.Put(r.HealedSite())
if err := rb.Save(path); err != nil {
t.Fatalf("Save recipes: %v", err)
}
rb2, err := LoadRecipes(path)
if err != nil {
t.Fatalf("Load recipes: %v", err)
}
learned, ok := rb2.Get("github.com")
if !ok {
t.Fatal("healed recipe not found after reload")
}
if learned.Form.CurrentSel != "#current" {
t.Fatalf("persisted CurrentSel = %q, want #current", learned.Form.CurrentSel)
}
fake2 := &healingFake{
good: map[string]bool{
"#current": true, "#new": true, "#confirm": true, "#submit": true, "#result": true,
},
healMap: map[string]string{}, // heal would FAIL if called
success: "SUCCESS",
}
c2 := gitHubPAT(t, v, oldPW)
r2 := New(learned, fake2, pwgen.DefaultPolicy())
newH2, err := r2.Rotate(context.Background(), c2, v)
if err != nil {
t.Fatalf("second run (should be Tier-1) failed: %v", err)
}
if r2.Healed() {
t.Fatal("second run should NOT heal — the recipe was already learned")
}
if fake2.healCalls != 0 {
t.Fatalf("second run must need 0 heals, got %d", fake2.healCalls)
}
v.Forget(newH2)
}
// If a selector misses and the Session cannot heal (no Healer), the engine fails closed.
func TestHeal_failsClosedWithoutHealer(t *testing.T) {
v := vault.New()
defer v.Purge()
c := gitHubPAT(t, v, "OLD")
site := testSite()
site.Form.CurrentSel = "#missing"
// plain fakeDriver does NOT implement Healer, and its Fill never errors — so to force
// the miss we use a driver whose Fill fails on the missing selector.
drv := &fakeDriver{success: "SUCCESS", failFill: "#missing"}
r := New(site, drv, pwgen.DefaultPolicy())
if _, err := r.Rotate(context.Background(), c, v); err == nil {
t.Fatal("expected fail-closed when a selector misses and no healer exists")
} else if !strings.Contains(err.Error(), "cannot self-heal") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestRecipes_roundTripAndMissingFile(t *testing.T) {
// Missing file -> empty book, no error.
rb, err := LoadRecipes(filepath.Join(t.TempDir(), "nope.json"))
if err != nil {
t.Fatalf("missing file should not error: %v", err)
}
if len(rb.Sites) != 0 {
t.Fatalf("missing file should yield empty book, got %d", len(rb.Sites))
}
path := filepath.Join(t.TempDir(), "r.json")
rb.Put(Site{Host: "a.com", Form: Form{CurrentSel: "#c"}, Proof: rotate.ProofLiveVM})
rb.Put(Site{Host: "b.com", Form: Form{NewSel: "#n"}})
if err := rb.Save(path); err != nil {
t.Fatalf("Save: %v", err)
}
got, err := LoadRecipes(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(got.Sites) != 2 {
t.Fatalf("want 2 recipes, got %d", len(got.Sites))
}
if s, _ := got.Get("a.com"); s.Form.CurrentSel != "#c" || s.Proof != rotate.ProofLiveVM {
t.Fatalf("round-trip lost data for a.com: %+v", s)
}
}
// mustReveal reads a handle's plaintext for a test-only leak assertion.
func mustReveal(t *testing.T, v *vault.Vault, h *vault.Handle) string {
t.Helper()
buf, err := v.Open(h)
if err != nil {
t.Fatalf("open handle: %v", err)
}
return string(buf.Bytes())
}