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: + +
+ +browserrot driving a real headless Chromium through a change-password flow + +
+ +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"