Files
incredigo/internal/browserrot/recipes.go
T
leetcrypt c2f08d1a21 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>
2026-07-20 08:51:28 -07:00

97 lines
3.0 KiB
Go

// Recipe persistence for the browser rotation engine. A "recipe" is exactly a Site
// (host + change-password Form + optional verify Login + Proof). Recipes carry NO
// secrets — only selectors and URLs — so unlike vault material they are safe to write
// to an ordinary on-disk JSON file (BROWSERROT-PLAN §4: "store selectors, never values").
//
// This is the "learn once, replay forever" store: Tier-2 healing rewrites a Site's
// selectors in memory (Rotator.Healed / HealedSite); Save persists the rewritten recipe
// so the NEXT run loads it and stays Tier-1 (no LLM). It is the browser analog of the
// rotate driver's ref-map.
package browserrot
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
)
// RecipeBook is a host-keyed collection of Site recipes persisted as JSON.
type RecipeBook struct {
Sites map[string]Site `json:"sites"`
}
// LoadRecipes reads a recipe book from path. A missing file yields an empty book (the
// first run has nothing learned yet), which is not an error.
func LoadRecipes(path string) (*RecipeBook, error) {
rb := &RecipeBook{Sites: map[string]Site{}}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return rb, nil
}
return nil, fmt.Errorf("browserrot: read recipes %s: %w", path, err)
}
if len(data) == 0 {
return rb, nil
}
if err := json.Unmarshal(data, rb); err != nil {
return nil, fmt.Errorf("browserrot: parse recipes %s: %w", path, err)
}
if rb.Sites == nil {
rb.Sites = map[string]Site{}
}
return rb, nil
}
// Get returns the recipe for host and whether one was found.
func (rb *RecipeBook) Get(host string) (Site, bool) {
s, ok := rb.Sites[host]
return s, ok
}
// Put stores/overwrites the recipe for its host (Site.Host is the key).
func (rb *RecipeBook) Put(s Site) {
if rb.Sites == nil {
rb.Sites = map[string]Site{}
}
rb.Sites[s.Host] = s
}
// Save writes the recipe book to path atomically (temp file + rename), 0600 since a
// selector table is not sensitive but is user-owned config. Keys are sorted for a stable
// on-disk diff.
func (rb *RecipeBook) Save(path string) error {
// Marshal with sorted keys for deterministic output (encoding/json already sorts map
// keys, but we guard explicitly in case the shape changes).
ordered := struct {
Sites map[string]Site `json:"sites"`
}{Sites: map[string]Site{}}
hosts := make([]string, 0, len(rb.Sites))
for h := range rb.Sites {
hosts = append(hosts, h)
}
sort.Strings(hosts)
for _, h := range hosts {
ordered.Sites[h] = rb.Sites[h]
}
data, err := json.MarshalIndent(ordered, "", " ")
if err != nil {
return fmt.Errorf("browserrot: encode recipes: %w", err)
}
if dir := filepath.Dir(path); dir != "" {
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("browserrot: mkdir %s: %w", dir, err)
}
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return fmt.Errorf("browserrot: write recipes tmp: %w", err)
}
if err := os.Rename(tmp, path); err != nil {
return fmt.Errorf("browserrot: commit recipes: %w", err)
}
return nil
}