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:
@@ -62,6 +62,17 @@ type Driver interface {
|
||||
Open(ctx context.Context) (Session, error)
|
||||
}
|
||||
|
||||
// Healer is the OPTIONAL Tier-2 capability a Session may implement: relocate a field the
|
||||
// deterministic recipe could not find, by NATURAL-LANGUAGE description, and return a fresh
|
||||
// selector. Note the signature — Heal takes only a description and returns a selector, so
|
||||
// a secret CANNOT be passed to the healer (and thus never reaches a model). The engine
|
||||
// calls Heal only when a Tier-1 selector misses, then re-persists the healed selector so
|
||||
// the next run is deterministic again ("learn once"). A Session that does not implement
|
||||
// Healer simply fails closed on a selector miss (Tier-1 only).
|
||||
type Healer interface {
|
||||
Heal(ctx context.Context, description string) (selector string, err error)
|
||||
}
|
||||
|
||||
// Form describes a site's change-password form: the CSS selectors to fill and the
|
||||
// element whose text signals the outcome.
|
||||
type Form struct {
|
||||
@@ -111,6 +122,7 @@ type Rotator struct {
|
||||
policy pwgen.Policy
|
||||
|
||||
username string // captured in Rotate, used by Verify's login (non-secret)
|
||||
healed bool // set true if a Tier-2 heal rewrote any selector this run
|
||||
}
|
||||
|
||||
// New builds a Rotator for a Site backed by drv, minting new passwords with policy.
|
||||
@@ -167,7 +179,7 @@ func (r *Rotator) Rotate(ctx context.Context, c discover.Credential, v *vault.Va
|
||||
return nil, err
|
||||
}
|
||||
|
||||
txt, err := sess.Text(ctx, r.site.Form.SuccessSel)
|
||||
txt, err := r.text(ctx, sess, &r.site.Form.SuccessSel, "the success or error status message")
|
||||
if err != nil {
|
||||
v.Forget(newH)
|
||||
return nil, fmt.Errorf("browserrot: read result: %w", err)
|
||||
@@ -183,33 +195,115 @@ func (r *Rotator) Rotate(ctx context.Context, c discover.Credential, v *vault.Va
|
||||
|
||||
// fillForm injects the old + new secrets into the form fields. Both secrets are read
|
||||
// straight from the vault as bytes and handed to the Driver; they are never copied
|
||||
// into a Go string in this package.
|
||||
// into a Go string in this package. Each field goes through fill/click, which self-heal
|
||||
// on a selector miss (Tier-2) and rewrite r.site.Form so the healed recipe can persist.
|
||||
func (r *Rotator) fillForm(ctx context.Context, sess Session, oldH, newH *vault.Handle, v *vault.Vault) error {
|
||||
oldBuf, err := v.Open(oldH)
|
||||
if err != nil {
|
||||
return fmt.Errorf("browserrot: open old secret: %w", err)
|
||||
}
|
||||
if err := sess.Fill(ctx, r.site.Form.CurrentSel, oldBuf.Bytes()); err != nil {
|
||||
return fmt.Errorf("browserrot: fill current: %w", err)
|
||||
if err := r.fill(ctx, sess, &r.site.Form.CurrentSel, "the current password input field", oldBuf.Bytes()); err != nil {
|
||||
return err
|
||||
}
|
||||
newBuf, err := v.Open(newH)
|
||||
if err != nil {
|
||||
return fmt.Errorf("browserrot: open new secret: %w", err)
|
||||
}
|
||||
if err := sess.Fill(ctx, r.site.Form.NewSel, newBuf.Bytes()); err != nil {
|
||||
return fmt.Errorf("browserrot: fill new: %w", err)
|
||||
if err := r.fill(ctx, sess, &r.site.Form.NewSel, "the new password input field", newBuf.Bytes()); err != nil {
|
||||
return err
|
||||
}
|
||||
if r.site.Form.ConfirmSel != "" {
|
||||
if err := sess.Fill(ctx, r.site.Form.ConfirmSel, newBuf.Bytes()); err != nil {
|
||||
return fmt.Errorf("browserrot: fill confirm: %w", err)
|
||||
if err := r.fill(ctx, sess, &r.site.Form.ConfirmSel, "the confirm-new-password input field", newBuf.Bytes()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := sess.Click(ctx, r.site.Form.SubmitSel); err != nil {
|
||||
return fmt.Errorf("browserrot: submit: %w", err)
|
||||
if err := r.click(ctx, sess, &r.site.Form.SubmitSel, "the submit / save-changes button"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fill sets *selPtr's field to secret; on a selector miss it asks the session's Healer
|
||||
// (Tier-2) to relocate the field by desc, retries, and rewrites *selPtr in place so the
|
||||
// healed recipe persists. The secret is never handed to the healer (see Healer).
|
||||
func (r *Rotator) fill(ctx context.Context, sess Session, selPtr *string, desc string, secret []byte) error {
|
||||
if err := sess.Fill(ctx, *selPtr, secret); err == nil {
|
||||
return nil
|
||||
} else {
|
||||
newSel, herr := r.heal(ctx, sess, desc, err)
|
||||
if herr != nil {
|
||||
return herr
|
||||
}
|
||||
if err2 := sess.Fill(ctx, newSel, secret); err2 != nil {
|
||||
return fmt.Errorf("browserrot: fill %q after heal to %q: %w", desc, newSel, err2)
|
||||
}
|
||||
*selPtr = newSel
|
||||
r.healed = true
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// click activates *selPtr; heals + rewrites on a miss, same contract as fill.
|
||||
func (r *Rotator) click(ctx context.Context, sess Session, selPtr *string, desc string) error {
|
||||
if err := sess.Click(ctx, *selPtr); err == nil {
|
||||
return nil
|
||||
} else {
|
||||
newSel, herr := r.heal(ctx, sess, desc, err)
|
||||
if herr != nil {
|
||||
return herr
|
||||
}
|
||||
if err2 := sess.Click(ctx, newSel); err2 != nil {
|
||||
return fmt.Errorf("browserrot: click %q after heal to %q: %w", desc, newSel, err2)
|
||||
}
|
||||
*selPtr = newSel
|
||||
r.healed = true
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// text reads *selPtr's visible text; heals + rewrites on a miss, same contract as fill.
|
||||
func (r *Rotator) text(ctx context.Context, sess Session, selPtr *string, desc string) (string, error) {
|
||||
if txt, err := sess.Text(ctx, *selPtr); err == nil {
|
||||
return txt, nil
|
||||
} else {
|
||||
newSel, herr := r.heal(ctx, sess, desc, err)
|
||||
if herr != nil {
|
||||
return "", herr
|
||||
}
|
||||
txt2, err2 := sess.Text(ctx, newSel)
|
||||
if err2 != nil {
|
||||
return "", fmt.Errorf("browserrot: read %q after heal to %q: %w", desc, newSel, err2)
|
||||
}
|
||||
*selPtr = newSel
|
||||
r.healed = true
|
||||
return txt2, nil
|
||||
}
|
||||
}
|
||||
|
||||
// heal asks the session's Healer (if any) to relocate a field by natural-language desc.
|
||||
// If the session cannot heal, the original miss is returned unchanged (fail closed).
|
||||
func (r *Rotator) heal(ctx context.Context, sess Session, desc string, cause error) (string, error) {
|
||||
h, ok := sess.(Healer)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("browserrot: selector for %s missed and driver cannot self-heal: %w", desc, cause)
|
||||
}
|
||||
newSel, err := h.Heal(ctx, desc)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("browserrot: heal %s failed: %v (original miss: %w)", desc, err, cause)
|
||||
}
|
||||
if newSel == "" {
|
||||
return "", fmt.Errorf("browserrot: heal %s returned no selector (original miss: %w)", desc, cause)
|
||||
}
|
||||
return newSel, nil
|
||||
}
|
||||
|
||||
// Healed reports whether a Tier-2 heal rewrote any selector during the last Rotate/Verify.
|
||||
// When true, the caller should persist HealedSite() so the next run is deterministic.
|
||||
func (r *Rotator) Healed() bool { return r.healed }
|
||||
|
||||
// HealedSite returns the Site with any healed selectors folded in — the recipe to persist.
|
||||
func (r *Rotator) HealedSite() Site { return r.site }
|
||||
|
||||
// Verify proves the new secret authenticates by re-logging-in through the Site's
|
||||
// Login flow (hard rule 2: verify-new-before-revoke-old). If the Site has no Login
|
||||
// flow, Verify REFUSES — the spine then keeps the old secret and flags the human,
|
||||
@@ -228,21 +322,21 @@ func (r *Rotator) Verify(ctx context.Context, newSecret *vault.Handle, v *vault.
|
||||
return fmt.Errorf("browserrot: goto login: %w", err)
|
||||
}
|
||||
if r.site.Login.UserSel != "" {
|
||||
if err := sess.Fill(ctx, r.site.Login.UserSel, []byte(r.username)); err != nil {
|
||||
return fmt.Errorf("browserrot: fill username: %w", err)
|
||||
if err := r.fill(ctx, sess, &r.site.Login.UserSel, "the username or email input field", []byte(r.username)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
buf, err := v.Open(newSecret)
|
||||
if err != nil {
|
||||
return fmt.Errorf("browserrot: open new secret (verify): %w", err)
|
||||
}
|
||||
if err := sess.Fill(ctx, r.site.Login.PassSel, buf.Bytes()); err != nil {
|
||||
return fmt.Errorf("browserrot: fill password (verify): %w", err)
|
||||
if err := r.fill(ctx, sess, &r.site.Login.PassSel, "the password input field", buf.Bytes()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := sess.Click(ctx, r.site.Login.SubmitSel); err != nil {
|
||||
return fmt.Errorf("browserrot: submit login: %w", err)
|
||||
if err := r.click(ctx, sess, &r.site.Login.SubmitSel, "the log-in button"); err != nil {
|
||||
return err
|
||||
}
|
||||
txt, err := sess.Text(ctx, r.site.Login.SuccessSel)
|
||||
txt, err := r.text(ctx, sess, &r.site.Login.SuccessSel, "the post-login status message")
|
||||
if err != nil {
|
||||
return fmt.Errorf("browserrot: read login result: %w", err)
|
||||
}
|
||||
|
||||
@@ -217,6 +217,17 @@ func TestIntegration_realBrowserRotation(t *testing.T) {
|
||||
if bin == "" {
|
||||
t.Skip("set CHROME_BIN to a Chromium executable to run the LIVE-VM browser proof")
|
||||
}
|
||||
runLiveRotationProof(t, &RodDriver{Bin: bin, Headless: true, Timeout: 30 * time.Second})
|
||||
}
|
||||
|
||||
// runLiveRotationProof drives one Driver through the full LIVE-VM proof: it stands up a
|
||||
// throwaway change-password + login website in-process, then asserts
|
||||
// discover→inject→submit→verify(new)→old-rejected. Both the go-rod and node-sidecar
|
||||
// drivers run this identical harness, so "parity" is a literal shared test. Optional
|
||||
// mutators tweak the Site before the run (e.g. to rot a selector for a self-heal proof).
|
||||
// It returns the Rotator so callers can assert post-run state (Healed(), HealedSite()).
|
||||
func runLiveRotationProof(t *testing.T, drv Driver, mutators ...func(*Site)) *Rotator {
|
||||
t.Helper()
|
||||
|
||||
const oldPW = "OLD-corr3ct-horse-battery"
|
||||
var mu sync.Mutex
|
||||
@@ -282,6 +293,9 @@ func TestIntegration_realBrowserRotation(t *testing.T) {
|
||||
},
|
||||
Proof: rotate.ProofLiveVM,
|
||||
}
|
||||
for _, m := range mutators {
|
||||
m(&site)
|
||||
}
|
||||
|
||||
v := vault.New()
|
||||
defer v.Purge()
|
||||
@@ -294,7 +308,6 @@ func TestIntegration_realBrowserRotation(t *testing.T) {
|
||||
// Point HostFor at our ephemeral host via the git-style "user @ host" identity.
|
||||
c.Identity = "acct @ " + host
|
||||
|
||||
drv := &RodDriver{Bin: bin, Headless: true, Timeout: 30 * time.Second}
|
||||
r := New(site, drv, pwgen.DefaultPolicy())
|
||||
|
||||
if !r.Detect(c) {
|
||||
@@ -320,6 +333,7 @@ func TestIntegration_realBrowserRotation(t *testing.T) {
|
||||
t.Fatalf("RevokeOld: %v", err)
|
||||
}
|
||||
t.Log("LIVE-VM browser rotation proof passed: discover→inject→submit→verify(new)→old-rejected")
|
||||
return r
|
||||
}
|
||||
|
||||
func hostForDebug(c discover.Credential) string { return links.HostFor(c) }
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
// Node-sidecar implementation of the browserrot Driver — the swap-in the architecture
|
||||
// always anticipated (browserrot.go: "playwright-go and a Tier-2 AI driver are swap-ins
|
||||
// that satisfy the same interface"). It spawns a small Node program (sidecar/index.js)
|
||||
// that drives a real-fingerprint Chromium (patchright) and speaks newline-delimited
|
||||
// JSON-RPC over stdio. go-rod stays the default; this Driver is selected explicitly
|
||||
// (NewSidecarDriver / a flag) until it reaches parity.
|
||||
//
|
||||
// SECRET SEAM (documented honestly, like the CDP seam in rod.go): Fill base64-encodes
|
||||
// the vault bytes and hands them to the sidecar over the local stdio pipe, which types
|
||||
// them into the field. The secret crosses ONE local process boundary, is never logged,
|
||||
// never written to disk, and never sent to a model. Heal requests (Tier-2) carry only a
|
||||
// field description + sanitized DOM — never a value.
|
||||
package browserrot
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SidecarDriver launches one Node sidecar process per session. Each Open is a fresh
|
||||
// process + fresh browser context, so no cookies/state leak between accounts (parity
|
||||
// with RodDriver).
|
||||
type SidecarDriver struct {
|
||||
NodeBin string // node executable; "" -> "node" from PATH
|
||||
ScriptDir string // dir containing index.js; "" -> resolved next to this source
|
||||
ChromeBin string // Chromium executablePath handed to the sidecar; "" lets it decide
|
||||
Headless bool // default true; false to watch it locally
|
||||
Stealth bool // patchright real-fingerprint + human-paced typing (M-B2)
|
||||
Timeout time.Duration // per-operation timeout; defaults to 30s
|
||||
}
|
||||
|
||||
// Name identifies the backend in logs/plans.
|
||||
func (d *SidecarDriver) Name() string { return "node-sidecar" }
|
||||
|
||||
// scriptDir resolves the sidecar directory: explicit field, else $INCREDIGO_SIDECAR_DIR,
|
||||
// else the sidecar/ folder next to this source file.
|
||||
func (d *SidecarDriver) scriptDir() string {
|
||||
if d.ScriptDir != "" {
|
||||
return d.ScriptDir
|
||||
}
|
||||
if env := os.Getenv("INCREDIGO_SIDECAR_DIR"); env != "" {
|
||||
return env
|
||||
}
|
||||
_, self, _, ok := runtime.Caller(0)
|
||||
if ok {
|
||||
return filepath.Join(filepath.Dir(self), "sidecar")
|
||||
}
|
||||
return "sidecar"
|
||||
}
|
||||
|
||||
// Open spawns the Node sidecar, sends the `open` RPC to launch Chromium, and returns a
|
||||
// Session bound to that process. The process is killed on Session.Close().
|
||||
func (d *SidecarDriver) Open(ctx context.Context) (Session, error) {
|
||||
node := d.NodeBin
|
||||
if node == "" {
|
||||
node = "node"
|
||||
}
|
||||
to := d.Timeout
|
||||
if to <= 0 {
|
||||
to = 30 * time.Second
|
||||
}
|
||||
|
||||
// The sidecar owns its own process lifetime (killed in Close); do NOT tie it to the
|
||||
// per-Open ctx, which may be short-lived.
|
||||
cmd := exec.Command(node, "index.js")
|
||||
cmd.Dir = d.scriptDir()
|
||||
cmd.Stderr = os.Stderr // [sidecar] diagnostics only — never a secret
|
||||
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("node-sidecar: stdin pipe: %w", err)
|
||||
}
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("node-sidecar: stdout pipe: %w", err)
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("node-sidecar: start node (dir=%s): %w", cmd.Dir, err)
|
||||
}
|
||||
|
||||
s := &sidecarSession{
|
||||
cmd: cmd,
|
||||
stdin: stdin,
|
||||
frames: make(chan []byte, 8),
|
||||
readEr: make(chan error, 1),
|
||||
to: to,
|
||||
}
|
||||
go s.readLoop(stdout)
|
||||
|
||||
if _, err := s.call(ctx, "open", map[string]any{
|
||||
"headless": d.Headless,
|
||||
"executablePath": d.ChromeBin,
|
||||
"stealth": d.Stealth,
|
||||
"timeoutMs": to.Milliseconds(),
|
||||
}); err != nil {
|
||||
s.Close()
|
||||
return nil, fmt.Errorf("node-sidecar: open browser: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
type sidecarSession struct {
|
||||
cmd *exec.Cmd
|
||||
stdin io.WriteCloser
|
||||
frames chan []byte
|
||||
readEr chan error
|
||||
to time.Duration
|
||||
|
||||
mu sync.Mutex // serializes RPCs (the engine calls sequentially anyway)
|
||||
nextID int
|
||||
}
|
||||
|
||||
// readLoop scans stdout for newline-delimited JSON frames and forwards them. stdout is
|
||||
// reserved for JSON-RPC; the sidecar sends all diagnostics to stderr.
|
||||
func (s *sidecarSession) readLoop(stdout io.Reader) {
|
||||
sc := bufio.NewScanner(stdout)
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
||||
for sc.Scan() {
|
||||
line := append([]byte(nil), sc.Bytes()...)
|
||||
s.frames <- line
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
s.readEr <- err
|
||||
}
|
||||
close(s.frames)
|
||||
}
|
||||
|
||||
type rpcResp struct {
|
||||
ID int `json:"id"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// call sends one JSON-RPC request and waits for its matching response, bounded by the
|
||||
// per-op timeout and ctx. Requests are serialized, so responses arrive in order.
|
||||
func (s *sidecarSession) call(ctx context.Context, method string, params map[string]any) (json.RawMessage, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.nextID++
|
||||
id := s.nextID
|
||||
req, err := json.Marshal(map[string]any{"id": id, "method": method, "params": params})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := s.stdin.Write(append(req, '\n')); err != nil {
|
||||
return nil, fmt.Errorf("write %s: %w", method, err)
|
||||
}
|
||||
|
||||
deadline := time.NewTimer(s.to)
|
||||
defer deadline.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-deadline.C:
|
||||
return nil, fmt.Errorf("%s: timed out after %s", method, s.to)
|
||||
case err := <-s.readEr:
|
||||
return nil, fmt.Errorf("%s: sidecar read error: %w", method, err)
|
||||
case line, ok := <-s.frames:
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s: sidecar closed stdout", method)
|
||||
}
|
||||
var r rpcResp
|
||||
if err := json.Unmarshal(line, &r); err != nil {
|
||||
// Not a JSON-RPC frame; ignore (defensive — sidecar should only emit frames).
|
||||
continue
|
||||
}
|
||||
if r.ID != id {
|
||||
continue
|
||||
}
|
||||
if r.Error != "" {
|
||||
return nil, fmt.Errorf("%s", r.Error)
|
||||
}
|
||||
return r.Result, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *sidecarSession) Goto(ctx context.Context, url string) (string, error) {
|
||||
raw, err := s.call(ctx, "goto", map[string]any{"url": url})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var out struct {
|
||||
FinalURL string `json:"finalURL"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return out.FinalURL, nil
|
||||
}
|
||||
|
||||
func (s *sidecarSession) Fill(ctx context.Context, selector string, secret []byte) error {
|
||||
// base64 keeps the secret intact across JSON transport and marks it as sensitive.
|
||||
// It never appears in a log line or an error; the sidecar wipes its decoded copy.
|
||||
_, err := s.call(ctx, "fill", map[string]any{
|
||||
"selector": selector,
|
||||
"secret": base64.StdEncoding.EncodeToString(secret),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *sidecarSession) Click(ctx context.Context, selector string) error {
|
||||
_, err := s.call(ctx, "click", map[string]any{"selector": selector})
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *sidecarSession) Text(ctx context.Context, selector string) (string, error) {
|
||||
raw, err := s.call(ctx, "text", map[string]any{"selector": selector})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var out struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return out.Text, nil
|
||||
}
|
||||
|
||||
// StealthSignals is a snapshot of common automation-detection signals, used by the M-B2
|
||||
// lab proof to assert the stealth posture (navigator.webdriver absent under patchright).
|
||||
type StealthSignals struct {
|
||||
Driver string `json:"driver"`
|
||||
Signals struct {
|
||||
Webdriver bool `json:"webdriver"`
|
||||
HasChrome bool `json:"hasChrome"`
|
||||
Plugins int `json:"plugins"`
|
||||
Languages string `json:"languages"`
|
||||
HeadlessUA bool `json:"headlessUA"`
|
||||
} `json:"signals"`
|
||||
}
|
||||
|
||||
// Probe reports the current page's automation-detection signals. It is sidecar-specific
|
||||
// (not part of the Driver-agnostic Session contract) and reads only page globals — no
|
||||
// secret is involved.
|
||||
func (s *sidecarSession) Probe(ctx context.Context) (StealthSignals, error) {
|
||||
var out StealthSignals
|
||||
raw, err := s.call(ctx, "probe", nil)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
err = json.Unmarshal(raw, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// Heal implements the Tier-2 Healer: it asks the sidecar to relocate a field by
|
||||
// natural-language description and returns a fresh selector. Only the description crosses
|
||||
// the wire — never a secret (the RPC has no secret parameter, by construction).
|
||||
func (s *sidecarSession) Heal(ctx context.Context, description string) (string, error) {
|
||||
raw, err := s.call(ctx, "heal", map[string]any{"description": description})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var out struct {
|
||||
Selector string `json:"selector"`
|
||||
Strategy string `json:"strategy"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return out.Selector, nil
|
||||
}
|
||||
|
||||
func (s *sidecarSession) Close() error {
|
||||
if s.stdin != nil {
|
||||
// Best-effort graceful close; the sidecar exits after the `close` reply.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
_, _ = s.call(ctx, "close", nil)
|
||||
cancel()
|
||||
_ = s.stdin.Close()
|
||||
}
|
||||
if s.cmd != nil && s.cmd.Process != nil {
|
||||
_ = s.cmd.Process.Kill()
|
||||
_ = s.cmd.Wait()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// compile-time checks: SidecarDriver is a Driver and its Session can self-heal (Tier-2).
|
||||
var _ Driver = (*SidecarDriver)(nil)
|
||||
var _ Healer = (*sidecarSession)(nil)
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package browserrot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// nodeAvailable reports whether a `node` runtime is on PATH; the sidecar needs it.
|
||||
func nodeAvailable(t *testing.T) bool {
|
||||
t.Helper()
|
||||
_, err := exec.LookPath("node")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// stubSidecar writes a tiny JSON-RPC stub that mimics the real sidecar's contract but
|
||||
// runs NO browser: it replies to every method and appends each raw incoming frame to a
|
||||
// transcript file. This lets us assert the secret seam (base64, never plaintext) with no
|
||||
// Chromium in the loop, so it runs in ordinary CI.
|
||||
func stubSidecar(t *testing.T, transcript string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
js := `'use strict';
|
||||
const fs = require('fs');
|
||||
const T = process.env.SIDECAR_TRANSCRIPT;
|
||||
let buf = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', c => {
|
||||
buf += c; let nl;
|
||||
while ((nl = buf.indexOf('\n')) >= 0) {
|
||||
const line = buf.slice(0, nl); buf = buf.slice(nl+1);
|
||||
if (!line.trim()) continue;
|
||||
if (T) fs.appendFileSync(T, line + '\n');
|
||||
const m = JSON.parse(line);
|
||||
let result = {ok:true};
|
||||
if (m.method === 'goto') result = {finalURL: (m.params && m.params.url) || ''};
|
||||
if (m.method === 'text') result = {text: 'SUCCESS'};
|
||||
process.stdout.write(JSON.stringify({id:m.id, result}) + '\n');
|
||||
if (m.method === 'close') process.exit(0);
|
||||
}
|
||||
});
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(dir, "index.js"), []byte(js), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("SIDECAR_TRANSCRIPT", transcript)
|
||||
return dir
|
||||
}
|
||||
|
||||
// The secret must cross the stdio boundary base64-encoded and NEVER as plaintext — this
|
||||
// is the sidecar analog of rod.go's "one plaintext moment, localized" seam.
|
||||
func TestSidecar_secretSeamBase64(t *testing.T) {
|
||||
if !nodeAvailable(t) {
|
||||
t.Skip("node not on PATH; sidecar needs a Node runtime")
|
||||
}
|
||||
transcript := filepath.Join(t.TempDir(), "rpc.log")
|
||||
dir := stubSidecar(t, transcript)
|
||||
|
||||
drv := &SidecarDriver{ScriptDir: dir, Headless: true, Timeout: 10 * time.Second}
|
||||
ctx := context.Background()
|
||||
sess, err := drv.Open(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Open sidecar: %v", err)
|
||||
}
|
||||
|
||||
const secret = "SUPER-SECRET-do-not-log-42"
|
||||
if err := sess.Fill(ctx, "#pw", []byte(secret)); err != nil {
|
||||
t.Fatalf("Fill: %v", err)
|
||||
}
|
||||
if _, err := sess.Goto(ctx, "https://example.test/x"); err != nil {
|
||||
t.Fatalf("Goto: %v", err)
|
||||
}
|
||||
txt, err := sess.Text(ctx, "#result")
|
||||
if err != nil {
|
||||
t.Fatalf("Text: %v", err)
|
||||
}
|
||||
if txt != "SUCCESS" {
|
||||
t.Fatalf("Text got %q, want SUCCESS", txt)
|
||||
}
|
||||
_ = sess.Close()
|
||||
|
||||
raw, err := os.ReadFile(transcript)
|
||||
if err != nil {
|
||||
t.Fatalf("read transcript: %v", err)
|
||||
}
|
||||
tr := string(raw)
|
||||
if strings.Contains(tr, secret) {
|
||||
t.Fatalf("LEAK: plaintext secret found in the JSON-RPC transcript:\n%s", tr)
|
||||
}
|
||||
// The base64 of the secret MUST be what crossed the wire.
|
||||
if !strings.Contains(tr, "U1VQRVItU0VDUkVULWRvLW5vdC1sb2ctNDI=") {
|
||||
t.Fatalf("expected base64-encoded secret in transcript, got:\n%s", tr)
|
||||
}
|
||||
}
|
||||
|
||||
// LIVE-VM parity: the node sidecar drives a REAL Chromium through the identical proof the
|
||||
// go-rod driver passes (runLiveRotationProof). Skipped unless CHROME_BIN + node are set,
|
||||
// exactly like the go-rod integration test.
|
||||
func TestIntegration_sidecarBrowserRotation(t *testing.T) {
|
||||
bin := os.Getenv("CHROME_BIN")
|
||||
if bin == "" {
|
||||
t.Skip("set CHROME_BIN to a Chromium executable to run the LIVE-VM sidecar proof")
|
||||
}
|
||||
if !nodeAvailable(t) {
|
||||
t.Skip("node not on PATH; sidecar needs a Node runtime")
|
||||
}
|
||||
// The sidecar spawns a browser per Open (Rotate + Verify), so give it headroom.
|
||||
drv := &SidecarDriver{ChromeBin: bin, Headless: true, Timeout: 60 * time.Second}
|
||||
runLiveRotationProof(t, drv)
|
||||
}
|
||||
|
||||
// M-B3 LIVE-VM self-heal: seed a ROTTED current-password selector into the recipe, then
|
||||
// prove the sidecar's Tier-2 heuristic heal relocates the real field, the rotation still
|
||||
// lands, the recipe is rewritten, AND no secret ever appears in a heal request. The
|
||||
// redacted IPC transcript is the leak evidence: it records every RPC (secret fields
|
||||
// replaced by a byte-count) so we can assert heal frames carry no secret and no raw
|
||||
// secret/base64 was written to disk.
|
||||
func TestIntegration_sidecarSelfHeal(t *testing.T) {
|
||||
bin := os.Getenv("CHROME_BIN")
|
||||
if bin == "" {
|
||||
t.Skip("set CHROME_BIN to a Chromium executable to run the LIVE-VM self-heal proof")
|
||||
}
|
||||
if !nodeAvailable(t) {
|
||||
t.Skip("node not on PATH; sidecar needs a Node runtime")
|
||||
}
|
||||
transcript := filepath.Join(t.TempDir(), "rpc.log")
|
||||
t.Setenv("INCREDIGO_SIDECAR_TRANSCRIPT", transcript)
|
||||
|
||||
drv := &SidecarDriver{ChromeBin: bin, Headless: true, Timeout: 60 * time.Second}
|
||||
r := runLiveRotationProof(t, drv, func(s *Site) {
|
||||
s.Form.CurrentSel = "#rotted-selector-does-not-exist"
|
||||
})
|
||||
|
||||
if !r.Healed() {
|
||||
t.Fatal("expected the rotted current-password selector to be healed")
|
||||
}
|
||||
if got := r.HealedSite().Form.CurrentSel; got != "#current" {
|
||||
t.Fatalf("healed recipe CurrentSel = %q, want #current (heuristic heal)", got)
|
||||
}
|
||||
|
||||
// Leak evidence from the redacted transcript.
|
||||
raw, err := os.ReadFile(transcript)
|
||||
if err != nil {
|
||||
t.Fatalf("read transcript: %v", err)
|
||||
}
|
||||
tr := string(raw)
|
||||
const oldPW = "OLD-corr3ct-horse-battery"
|
||||
oldB64 := base64.StdEncoding.EncodeToString([]byte(oldPW))
|
||||
if strings.Contains(tr, oldPW) || strings.Contains(tr, oldB64) {
|
||||
t.Fatalf("LEAK: a raw secret reached the on-disk transcript")
|
||||
}
|
||||
// A heal request must have occurred, and no heal frame may carry a secret field.
|
||||
var sawHeal bool
|
||||
for _, line := range strings.Split(tr, "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var m struct {
|
||||
Method string `json:"method"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(line), &m); err != nil {
|
||||
continue
|
||||
}
|
||||
if m.Method == "heal" {
|
||||
sawHeal = true
|
||||
if _, has := m.Params["secret"]; has {
|
||||
t.Fatalf("LEAK: heal request carried a secret field: %s", line)
|
||||
}
|
||||
if _, has := m.Params["description"]; !has {
|
||||
t.Fatalf("heal request missing description: %s", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !sawHeal {
|
||||
t.Fatal("expected at least one heal RPC in the transcript")
|
||||
}
|
||||
}
|
||||
|
||||
// M-B2: the humanized input path (Bézier mouse + paced per-key typing) must still land a
|
||||
// real rotation against the local form — proving stealth pacing doesn't break correctness.
|
||||
func TestIntegration_sidecarStealthRotation(t *testing.T) {
|
||||
bin := os.Getenv("CHROME_BIN")
|
||||
if bin == "" {
|
||||
t.Skip("set CHROME_BIN to a Chromium executable to run the stealth rotation proof")
|
||||
}
|
||||
if !nodeAvailable(t) {
|
||||
t.Skip("node not on PATH; sidecar needs a Node runtime")
|
||||
}
|
||||
runLiveRotationProof(t, &SidecarDriver{ChromeBin: bin, Headless: true, Stealth: true, Timeout: 60 * time.Second})
|
||||
}
|
||||
|
||||
// M-B2 stealth posture: with Stealth on, the patchright real-fingerprint Chromium must
|
||||
// NOT expose navigator.webdriver — the single most common headless/automation tell. This
|
||||
// is the "detection-signal probe" the plan calls for. (The HeadlessChrome UA is inherent
|
||||
// to headless mode against chrome-for-testing and is asserted only informationally.)
|
||||
func TestIntegration_sidecarStealthSignals(t *testing.T) {
|
||||
bin := os.Getenv("CHROME_BIN")
|
||||
if bin == "" {
|
||||
t.Skip("set CHROME_BIN to a Chromium executable to run the stealth-signal probe")
|
||||
}
|
||||
if !nodeAvailable(t) {
|
||||
t.Skip("node not on PATH; sidecar needs a Node runtime")
|
||||
}
|
||||
drv := &SidecarDriver{ChromeBin: bin, Headless: true, Stealth: true, Timeout: 60 * time.Second}
|
||||
ctx := context.Background()
|
||||
sess, err := drv.Open(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Open stealth sidecar: %v", err)
|
||||
}
|
||||
defer sess.Close()
|
||||
if _, err := sess.Goto(ctx, "data:text/html,<h1>probe</h1>"); err != nil {
|
||||
t.Fatalf("Goto: %v", err)
|
||||
}
|
||||
ss, ok := sess.(*sidecarSession)
|
||||
if !ok {
|
||||
t.Fatalf("expected *sidecarSession, got %T", sess)
|
||||
}
|
||||
sig, err := ss.Probe(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Probe: %v", err)
|
||||
}
|
||||
t.Logf("stealth signals via %s: %+v", sig.Driver, sig.Signals)
|
||||
if sig.Driver != "patchright" {
|
||||
t.Fatalf("stealth requires the patchright driver; got %q — run npm install patchright", sig.Driver)
|
||||
}
|
||||
if sig.Signals.Webdriver {
|
||||
t.Fatal("navigator.webdriver is TRUE — stealth posture failed (bot tell present)")
|
||||
}
|
||||
}
|
||||
|
||||
// A sidecar Session must satisfy the Session contract at compile time.
|
||||
var _ Session = (*sidecarSession)(nil)
|
||||
Reference in New Issue
Block a user