Files
incredigo/internal/browserrot/sidecar.go
T
leetcrypt c2f08d1a21 browserrot: Node stealth sidecar + AI self-heal (M-B1..M-B3, LIVE-VM)
Extends the Phase A-tier-1 site-side rotation engine with an optional Node
browser-sidecar and a deterministic self-heal tier, all proven LIVE-VM against
the local lab change-password form. go-rod stays the default; the sidecar is a
swap-in Driver behind the same Session/Driver/Rotator interfaces — no spine
change, backup + verify-new-before-revoke-old + proof gates all apply unchanged.

M-B1 sidecar parity: internal/browserrot/sidecar/ (index.js JSON-RPC over stdio,
  patchright real-fingerprint Chromium, playwright-core fallback) + sidecar.go
  Driver/Session. A shared test harness runs go-rod and the sidecar through the
  identical live rotation proof.
M-B2 stealth: patchright removes navigator.webdriver; humanized input (Bezier
  mouse paths + per-key typing jitter, no ML trajectory generation per plan §5).
M-B3 self-heal: Healer interface + heal-retry fill/click/text wrappers; on a
  rotted selector, heal.js relocates the field from DOM structure ONLY (never a
  value) and the engine rewrites + persists the recipe (recipes.go RecipeBook)
  so the next run is Tier-1 again. Deterministic heuristic is the proven Tier-2;
  Stagehand LLM strategy is opt-in + UNPROVEN. Leak-check asserts no secret ever
  reaches a heal request (redacted IPC transcript).

Secret seam documented honestly: fill carries base64 secret bytes across exactly
one local stdio boundary, typed and wiped, never logged/persisted/sent to a
model. MFA/CAPTCHA still degrade to the human worklist (Hard Rule 5).

14 tests green, -race clean; lab/lab-provision-browserrot.sh proves both drivers.
M-B4 (first REAL self-owned site) deliberately NOT started — needs per-credential
authorization (safe-candidate ladder).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-20 08:51:28 -07:00

295 lines
9.0 KiB
Go

// 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)