diff --git a/.gitignore b/.gitignore
index 8bb79b2..228d858 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,6 +24,11 @@ lab/gitea
lab/*.age
lab/rung1-*
+# browserrot Node sidecar: ship the source (index.js/heal.js/package.json), never the
+# installed dependency tree or lockfile churn (npm install regenerates them)
+internal/browserrot/sidecar/node_modules/
+internal/browserrot/sidecar/package-lock.json
+
# editor / OS
*.swp
.DS_Store
diff --git a/README.md b/README.md
index a1963ef..c896905 100644
--- a/README.md
+++ b/README.md
@@ -138,6 +138,25 @@ The 8 `LIVE-VM` claims are reproducible from a clean machine — see
rotate **fake** credentials in an **isolated** gopass store. Nothing there can
touch a real store or provider.
+## Browser-based rotation (Phase A engine)
+
+The site-side rotation engine ([`internal/browserrot`](internal/browserrot)) drives a
+**real** headless Chromium end-to-end — no language model in the loop:
+
+
+
+

+
+
+
+RFC 8615 discovery (`/.well-known/change-password`) → inject the old + a freshly-minted
+new password into the form over CDP → submit and read the success marker → **re-login to
+prove the new secret authenticates** before the old one is trusted as revoked. Recorded
+above against a **fake** credential on a **local throwaway** site — this is the engine's
+`LIVE-VM` proof (reproduce with [`lab/lab-provision-browserrot.sh`](lab/lab-provision-browserrot.sh)).
+A real-site selector table stays `UNPROVEN` until run against that site, and the engine
+is deliberately **not** auto-wired into production `rotate`.
+
## Safety rules (this tool holds real credentials)
1. **Backup before rotate, always.** No rotation runs without a verified sealed
diff --git a/docs/BROWSER-ROTATION.md b/docs/BROWSER-ROTATION.md
index 5209ead..26edaa8 100644
--- a/docs/BROWSER-ROTATION.md
+++ b/docs/BROWSER-ROTATION.md
@@ -322,6 +322,29 @@ adapter is pure-exec Go, so it is unit-validated cross-platform via an injected
wiring auto-enables it yet (still gated behind per-credential authorization). Still to do:
build out the curated selector table for real high-frequency no-MFA sites and measure the real
success rate before promising anything.
-- **Phase A-tier-2:** Computer-Use / AI-in-browser vision fallback for DOM variance, with
- **secrets kept out of the model loop** (the model decides navigation; the Go harness injects
- the actual password via CDP, redacted from the screenshot stream). Opt-in, measured.
+- **Phase A-tier-1.5 — Node stealth sidecar (built + LIVE-VM):** an optional swap-in
+ `Driver` (`internal/browserrot/sidecar.go` + `internal/browserrot/sidecar/`) that speaks
+ newline-delimited JSON-RPC over stdio to a small Node program driving a **patchright**
+ real-fingerprint Chromium. go-rod stays the default; the sidecar reaches **parity** through
+ the *identical* LIVE-VM harness (`runLiveRotationProof` runs both drivers —
+ `TestIntegration_sidecarBrowserRotation`). Stealth posture is **LIVE-VM**: patchright
+ removes `navigator.webdriver` (`TestIntegration_sidecarStealthSignals`), and humanized input
+ (cubic-Bézier mouse paths + randomized per-key typing, **no ML trajectory generation**) still
+ lands a real rotation (`TestIntegration_sidecarStealthRotation`). The secret seam is honest:
+ `fill` carries the vault bytes **base64** over ONE local stdio boundary, never logged, never
+ on disk, never to a model (`TestSidecar_secretSeamBase64` + a redacted IPC transcript).
+- **Phase A-tier-2 — self-heal + recipe persistence (built + LIVE-VM for the heuristic
+ strategy):** on a Tier-1 selector miss the engine calls an optional `Healer` (a Session
+ capability) to relocate the field by **natural-language description**, retries, and rewrites
+ the recipe; `RecipeBook` (`recipes.go`) persists the healed `Site` so the next run is Tier-1
+ again ("learn once"). The `Heal` signature takes only a description — a secret **cannot**
+ reach the healer by construction (`TestHeal_engineRetriesRewritesAndPersists`,
+ `TestIntegration_sidecarSelfHeal` with a redacted-transcript leak-check). The sidecar's
+ **deterministic DOM-heuristic** heal (attributes/labels/aria only, never field values) is the
+ **LIVE-VM-proven** Tier-2; the **Stagehand `observe` (LLM) strategy** is real but **UNPROVEN**
+ and strictly opt-in (`INCREDIGO_HEAL_STRATEGY=stagehand`) — it needs Stagehand to own the
+ browser, still to be wired. A specific real-site recipe stays **UNPROVEN** until run against
+ that site; no production `rotate` wiring auto-enables it (per-credential authorization).
+- **Phase A-tier-3:** Computer-Use / AI-in-browser vision fallback for hostile DOM variance,
+ with **secrets kept out of the model loop** (the model decides navigation; the Go harness
+ injects the actual password via CDP, redacted from the screenshot stream). Opt-in, measured.
diff --git a/docs/BROWSERROT-PLAN.md b/docs/BROWSERROT-PLAN.md
new file mode 100644
index 0000000..32a27f3
--- /dev/null
+++ b/docs/BROWSERROT-PLAN.md
@@ -0,0 +1,184 @@
+# browserrot buildout plan — Node stealth sidecar + AI self-heal (Phase A tiers 1-3)
+
+**Status (2026-07-19):** M-B1 ✅ M-B2 ✅ M-B3 ✅ (all LIVE-VM proven against the local lab
+form, `internal/browserrot/` suite green + `-race` clean). **M-B4 (first REAL self-owned
+site) deliberately NOT started** — it rotates a real credential and needs explicit
+per-credential authorization (safe-candidate ladder). M-B5 (vision) later. The Stagehand
+(LLM) heal strategy is wired but UNPROVEN/opt-in; the deterministic DOM-heuristic heal is
+the LIVE-VM Tier-2. See `docs/BROWSER-ROTATION.md` §9 for the proof-as-data record.
+**Extends, does not replace:** [`docs/BROWSER-ROTATION.md`](BROWSER-ROTATION.md) (the design
+spec) and the existing engine in [`internal/browserrot`](../internal/browserrot).
+**Goal (user's words):** reset passwords for the **most sites without getting blocked**,
+"do it once → reproduces forever, until the site ups bot detection."
+
+---
+
+## 0. Decision: Option (b) — a Node browser-sidecar
+
+Chosen over "stay pure-Go with `playwright-go`" because the whole ecosystem that maximizes
+*site coverage* and *avoids false-positive bot blocks* is Node-centric and has no Go equivalent:
+
+- **`patchright`** (drop-in Playwright, real-fingerprint Chromium) — beats the fingerprint
+ checks `playwright-go` cannot touch; more robust than JS-injected stealth plugins.
+- **Stagehand** (`act`/`observe`/`extract` + **auto-caching self-heal**) — covers the long
+ tail of site layouts without hand-coding every selector; this IS the "record once, replay
+ forever, re-learn on break" paradigm.
+- **Playwright codegen** — records a real human run into a starter recipe.
+
+`playwright-go` would give a driver but none of the above. The runtime cost of (b) is one
+Node dependency, isolated behind an interface and a local socket.
+
+> **The architecture already anticipated this.** `internal/browserrot/browserrot.go`:
+> *"go-rod is the default; playwright-go and a Tier-2 AI driver are swap-ins that satisfy
+> the same interface."* The sidecar is just a new `Driver`. **No spine change, no new hard-rule
+> surface** — `rotate.Snapshot` (backup), verify-new-before-revoke-old, and the proof gate all
+> apply unchanged because a browser `Rotator` already implements `rotate.Rotator`.
+
+---
+
+## 1. Non-negotiable reframe (carry into every task)
+
+incredigo is **not** a bot-bypass/scraping tool. Per CLAUDE.md Hard Rule 5:
+- **Self-owned accounts only, with the user's authorization.**
+- **Never bypass MFA / CAPTCHA / email-confirm** — when one appears, the success marker
+ simply doesn't match, `Rotate` fails cleanly, the OLD secret is kept, and the human is
+ handed the change-URL by the existing worklist. (Already how `Rotate` behaves,
+ browserrot.go:175-180.)
+- "Stealth" here = **do not get false-flagged as a bot while doing a legitimate self-login**,
+ NOT defeating a challenge. For a user logging into their own account from their own machine,
+ most of the anti-bot arms race is already moot (real IP, real device, real user).
+- **Hard Rule 6:** the self-heal LLM may see **page structure only** to locate fields; it
+ **never receives a secret**. incredigo still fills fields itself, over CDP, with vault bytes.
+
+---
+
+## 2. Target architecture — three tiers, graceful degrade (mirrors rotate → worklist)
+
+```
+Tier 1 deterministic replay of the recorded recipe (Site/Form selectors) ← no LLM, no secret to model
+ engine driver = go-rod (exists) OR node-sidecar (new)
+ │ selector miss / Text() or Fill() fails
+ ▼
+Tier 2 AI self-heal: Stagehand observe/act re-locates the field by NL ← LLM sees DOM structure only
+ description → new selector → incredigo fills with vault bytes → (never the secret)
+ PERSIST the healed recipe so the next run is Tier-1 again
+ │ hostile/opaque markup, canvas, etc.
+ ▼
+Tier 3 vision fallback (Anthropic Computer Use) — secrets injected locally ← later; keep out of default path
+ │ MFA / CAPTCHA / email-confirm at ANY tier
+ ▼
+HUMAN worklist handoff (change-URL + guide TUI) — existing internal/worklist
+```
+
+The **recipe** = the existing `Site{Host, ChangeURL, Form, Login, Proof}`. Tier-2 healing
+*rewrites the recipe's selectors* and re-persists it; that is the entire "learn once" loop.
+
+---
+
+## 3. The Node sidecar (new component)
+
+- **Process:** a small Node program (`browserrot/sidecar/`), Playwright + `patchright` +
+ `@browserbasehq/stagehand`. Launches a real headed-ish Chromium with real fingerprint.
+- **IPC:** newline-delimited **JSON-RPC over stdio** (Go spawns it as a child; simplest, no
+ port, dies with the parent). Methods map 1:1 to the Go `Session` interface:
+ `open`, `goto`, `fill`, `click`, `text`, `close`, plus Tier-2: `heal` (NL field description
+ → selector) and (Tier-3 later) `act`.
+- **Secret seam (document honestly, like the CDP seam in rod.go):** `fill` carries the secret
+ bytes over the local stdio pipe to the sidecar, which types them into the field. The secret
+ crosses ONE local process boundary, is **never logged, never written to disk, never sent to
+ an LLM**. Heal/act requests carry **only** a field description + sanitized DOM, never a value.
+- **Go side:** `internal/browserrot/sidecar.go` = a new `Driver`+`Session` impl that speaks the
+ JSON-RPC. Selected via a driver flag/env; go-rod stays the default until the sidecar reaches
+ parity.
+- **Lifecycle:** Go owns spawn/teardown; `Session.Close()` → `close` RPC → kill child. Timeouts
+ and a hard context deadline on every RPC.
+
+---
+
+## 4. Recording a recipe once ("manual once")
+
+- `incredigo browser record --host ` → drives Playwright **codegen** in the sidecar; the
+ human performs the change-password + re-login flow once; incredigo captures the selectors into
+ a `Site` recipe (Form + Login), **redacting any typed secret** (store selectors, never values).
+- Persist recipes as a curated table in-repo for public sites (no secrets) and/or a local
+ user file for private ones. This is the browser analog of the rotate driver's ref-map.
+
+---
+
+## 5. Stealth posture (deliberately minimal)
+
+- Use **`patchright`'s real-fingerprint Chromium**; avoid JS-injected stealth plugins (more
+ fragile, more detectable).
+- **Human-paced input:** simple Bézier mouse paths + randomized typing delays. Research shows
+ GAN/diffusion mouse-trajectory models are often *more* detectable than plain Bézier+jitter
+ (DMTG discriminators ~80-90%) — so **do NOT build ML trajectory generation**; it is cost with
+ negative return for a self-owned login.
+- No proxies, no residential-IP rotation — the user's own IP is correct and legitimate.
+
+---
+
+## 6. Proof ladder (DATA, mirrors rotate/proofs.go & BROWSER-ROTATION.md §8)
+
+Per-`Site` `Proof`:
+- Local lab HTTP form → **LIVE-VM** (real browser + real form proved the mechanism) — exists.
+- Each **real self-owned site** stays **UNPROVEN** until actually run against it, then promotes
+ to **LIVE-REAL** for that host (same discipline as the gitea rung). No blanket "works on all
+ sites" claim ever.
+
+---
+
+## 7. Milestones (execute in order; each ends green + a lab proof)
+
+- **M-B1 — sidecar parity.** Node sidecar + `sidecar.go` Driver passing the EXISTING
+ `browserrot_test.go` against the local lab form (`lab/lab-provision-browserrot.sh`), at
+ parity with go-rod. Deliverable: `go test ./internal/browserrot` green with driver=sidecar.
+- **M-B2 — stealth + human input.** patchright fingerprint + Bézier/typing pacing. Re-run lab;
+ add a detection-signal probe (navigator.webdriver etc. absent).
+- **M-B3 — self-heal + persistence.** Tier-2 `heal` RPC (Stagehand observe) triggered on
+ selector miss → rewrite + persist the recipe → next run is Tier-1. Test: seed a *broken*
+ selector, assert one run heals it and the second run needs no LLM. Assert **no secret** in any
+ heal request (leak-check the IPC transcript).
+- **M-B4 — record command + first REAL site.** `incredigo browser record`; then point the
+ engine at ONE real self-owned account (low-stakes, no MFA), rotate + re-login-verify, promote
+ that host to LIVE-REAL. New `lab/`/runbook entry, per-cred authorization (safe-candidate
+ ladder discipline).
+- **M-B5 (optional/later) — vision fallback.** Tier-3 Computer Use for hostile markup; secrets
+ injected locally, never to the model.
+
+---
+
+## 8. Files to add / change
+
+- **add** `internal/browserrot/sidecar/` — Node sidecar (package.json, index.js, stealth+heal).
+- **add** `internal/browserrot/sidecar.go` — Go Driver/Session over JSON-RPC (+ `sidecar_test.go`).
+- **add** recipe persistence (`recipes.go` or extend the curated `Site` table) + record command
+ in `cmd/incredigo`.
+- **add** `lab/lab-provision-browserrot-real.sh` (M-B4) — real-site LIVE target harness.
+- **edit** `docs/BROWSER-ROTATION.md` §8 validation table + §9 roadmap as tiers land.
+- **edit** driver selection (flag/env) so go-rod stays default until sidecar parity.
+- **unchanged:** the rotate spine, hard-rule gates, `Session`/`Driver`/`Rotator` interfaces.
+
+---
+
+## 9. Open decisions / risks to confirm before/while building
+
+1. **Node runtime dependency** — acceptable for the browser layer only? (Go core stays pure;
+ sidecar is optional, spawned on demand.) → assume yes; keep go-rod as the no-Node fallback.
+2. **Stagehand LLM provider + key** — which model, and where the key lives (env, never in a
+ recipe). LLM sees DOM structure only.
+3. **Recipe distribution** — curated public-site table in-repo (no secrets) vs. per-user local
+ file for private hosts. Likely both.
+4. **ToS/legal** — automating even your own-account login can breach a site's ToS; the
+ self-owned + human-handoff-on-challenge framing is the mitigation, state it in the docs.
+5. **Per-site fragility** — accept that recipes rot; the self-heal tier + record command are the
+ maintenance story, not a promise of permanence ("until the site ups bot detection").
+
+---
+
+## 10. What this plan explicitly does NOT do
+
+- Does not put an LLM in the deterministic destructive path (Tier-1 is pure).
+- Does not send any secret to any model, ever.
+- Does not bypass MFA/CAPTCHA — those degrade to the human worklist.
+- Does not build ML mouse-trajectory generation (negative ROI, see §5).
+- Does not change the rotate spine, the backup gate, or any hard rule.
diff --git a/docs/assets/demo.gif b/docs/assets/demo.gif
new file mode 100644
index 0000000..5321eba
Binary files /dev/null and b/docs/assets/demo.gif differ
diff --git a/docs/assets/demo.mp4 b/docs/assets/demo.mp4
new file mode 100644
index 0000000..b0e8bd4
Binary files /dev/null and b/docs/assets/demo.mp4 differ
diff --git a/internal/browserrot/browserrot.go b/internal/browserrot/browserrot.go
index 88a827a..59c147a 100644
--- a/internal/browserrot/browserrot.go
+++ b/internal/browserrot/browserrot.go
@@ -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)
}
diff --git a/internal/browserrot/browserrot_test.go b/internal/browserrot/browserrot_test.go
index d2a147a..7961865 100644
--- a/internal/browserrot/browserrot_test.go
+++ b/internal/browserrot/browserrot_test.go
@@ -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) }
diff --git a/internal/browserrot/heal_test.go b/internal/browserrot/heal_test.go
new file mode 100644
index 0000000..12e352b
--- /dev/null
+++ b/internal/browserrot/heal_test.go
@@ -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())
+}
diff --git a/internal/browserrot/recipes.go b/internal/browserrot/recipes.go
new file mode 100644
index 0000000..8acbf0c
--- /dev/null
+++ b/internal/browserrot/recipes.go
@@ -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
+}
diff --git a/internal/browserrot/sidecar.go b/internal/browserrot/sidecar.go
new file mode 100644
index 0000000..3083290
--- /dev/null
+++ b/internal/browserrot/sidecar.go
@@ -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)
diff --git a/internal/browserrot/sidecar/heal.js b/internal/browserrot/sidecar/heal.js
new file mode 100644
index 0000000..40ae28f
--- /dev/null
+++ b/internal/browserrot/sidecar/heal.js
@@ -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 };
diff --git a/internal/browserrot/sidecar/index.js b/internal/browserrot/sidecar/index.js
new file mode 100644
index 0000000..f86eee9
--- /dev/null
+++ b/internal/browserrot/sidecar/index.js
@@ -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')
+ ? ''
+ : 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');
diff --git a/internal/browserrot/sidecar/package.json b/internal/browserrot/sidecar/package.json
new file mode 100644
index 0000000..1bacd0e
--- /dev/null
+++ b/internal/browserrot/sidecar/package.json
@@ -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"
+ }
+}
diff --git a/internal/browserrot/sidecar_test.go b/internal/browserrot/sidecar_test.go
new file mode 100644
index 0000000..5dcc86e
--- /dev/null
+++ b/internal/browserrot/sidecar_test.go
@@ -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,probe
"); 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)
diff --git a/lab/lab-provision-browserrot.sh b/lab/lab-provision-browserrot.sh
index a85fce3..2bd7829 100755
--- a/lab/lab-provision-browserrot.sh
+++ b/lab/lab-provision-browserrot.sh
@@ -48,8 +48,45 @@ echo "== using Chromium: $CHROME_BIN"
# Run from the repo root (the dir containing go.mod).
cd "$(git -C "$(dirname "$0")" rev-parse --show-toplevel 2>/dev/null || echo "$(dirname "$0")/..")"
-echo "== running the LIVE-VM browser-rotation proof =="
+# The Node sidecar (patchright real-fingerprint Chromium) is an OPTIONAL swap-in Driver
+# that must reach parity with go-rod. If a `node` runtime + the sidecar deps are present,
+# prove BOTH drivers through the identical harness; otherwise prove go-rod only.
+SIDECAR_DIR="internal/browserrot/sidecar"
+have_sidecar() {
+ command -v node >/dev/null 2>&1 && [ -d "$SIDECAR_DIR/node_modules/playwright-core" ]
+}
+
+echo "== running the LIVE-VM browser-rotation proof (go-rod) =="
CHROME_BIN="$CHROME_BIN" go test -run TestIntegration_realBrowserRotation -v ./internal/browserrot/
+if have_sidecar; then
+ echo
+ echo "== running the LIVE-VM browser-rotation proof (node-sidecar, parity) =="
+ node --version | sed 's/^/ node /'
+ CHROME_BIN="$CHROME_BIN" go test -run TestIntegration_sidecarBrowserRotation -v ./internal/browserrot/
+
+ if [ -d "$SIDECAR_DIR/node_modules/patchright" ]; then
+ echo
+ echo "== M-B2 stealth: patchright fingerprint + humanized input =="
+ CHROME_BIN="$CHROME_BIN" go test -run 'TestIntegration_sidecarStealth' -v ./internal/browserrot/
+ else
+ echo
+ echo "== M-B2 stealth SKIPPED (patchright not installed) =="
+ echo " to enable: (cd $SIDECAR_DIR && npm install patchright)"
+ fi
+
+ echo
+ echo "== M-B3 self-heal: rotted selector -> Tier-2 heal -> rewrite recipe (no secret to heal) =="
+ CHROME_BIN="$CHROME_BIN" go test -run 'TestIntegration_sidecarSelfHeal' -v ./internal/browserrot/
+else
+ echo
+ echo "== node-sidecar SKIPPED (no node runtime or deps not installed) =="
+ echo " to enable: (cd $SIDECAR_DIR && PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm install)"
+fi
+
+echo
+echo "== secret-seam leak guard (browser-free) =="
+go test -run TestSidecar_secretSeamBase64 -v ./internal/browserrot/
+
echo
echo "LIVE-VM BROWSER-ROTATION ENGINE PROOF PASSED"