Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 32 additions & 16 deletions server/cmd/api/api/computer.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ func (s *ApiService) doMoveMouseSmooth(ctx context.Context, log *slog.Logger, bo
// When duration_ms is specified, compute the number of trajectory points
// to achieve that duration at a ~10ms step delay (human-like event frequency).
// Otherwise let the library auto-compute from path length.
const defaultStepDelayMs = 10
const defaultStepDelayMs = 20
var opts *mousetrajectory.Options
if body.DurationMs != nil {
targetPoints := *body.DurationMs / defaultStepDelayMs
Expand Down Expand Up @@ -173,7 +173,10 @@ func (s *ApiService) doMoveMouseSmooth(ctx context.Context, log *slog.Logger, bo
}()
}

// Move along Bezier path: mousemove_relative for each step with delay
// Move along Bezier path: mousemove_relative for each step with delay.
// Use Gaussian-distributed delays so that inter-event timing has natural
// variance matching real human motor noise, rather than near-constant
// intervals that fingerprinting systems can distinguish from humans.
for i := 1; i < len(points); i++ {
select {
case <-ctx.Done():
Expand All @@ -190,14 +193,8 @@ func (s *ApiService) doMoveMouseSmooth(ctx context.Context, log *slog.Logger, bo
return &executionError{msg: "failed during smooth mouse movement"}
}
}
jitter := stepDelayMs
if stepDelayMs > 3 {
jitter = stepDelayMs + rand.Intn(5) - 2
if jitter < 3 {
jitter = 3
}
}
if err := sleepWithContext(ctx, time.Duration(jitter)*time.Millisecond); err != nil {
delay := gaussianDelay(stepDelayMs, 3)
if err := sleepWithContext(ctx, time.Duration(delay)*time.Millisecond); err != nil {
return &executionError{msg: "mouse movement cancelled"}
}
}
Expand Down Expand Up @@ -1233,12 +1230,9 @@ func (s *ApiService) doDragMouseSmooth(ctx context.Context, log *slog.Logger, bo
args = append(args, "mousemove_relative", "--", strconv.Itoa(dx), strconv.Itoa(dy))

if i < numSteps {
delay := smoothStepDelay(i, numSteps, baseDelayMs*2, baseDelayMs/2)
jitter := delay + rand.Intn(5) - 2
if jitter < 3 {
jitter = 3
}
args = append(args, "sleep", fmt.Sprintf("%.3f", float64(jitter)/1000.0))
baseDelay := smoothStepDelay(i, numSteps, baseDelayMs*2, baseDelayMs/2)
delay := gaussianDelay(baseDelay, 3)
args = append(args, "sleep", fmt.Sprintf("%.3f", float64(delay)/1000.0))
}
}

Expand All @@ -1254,6 +1248,28 @@ func (s *ApiService) doDragMouseSmooth(ctx context.Context, log *slog.Logger, bo
return nil
}

// gaussianDelay returns a Gaussian-distributed delay centered on meanMs with
// stddev of 40% of meanMs, clamped to [minMs, 3*meanMs]. This produces timing
// variance that matches real human motor noise rather than the near-zero
// variance of uniform jitter.
func gaussianDelay(meanMs int, minMs int) int {
stddev := float64(meanMs) * 0.4
u1 := rand.Float64()
u2 := rand.Float64()
if u1 <= 0 {
u1 = 1e-10
}
z := math.Sqrt(-2*math.Log(u1)) * math.Cos(2*math.Pi*u2)
delay := int(math.Round(float64(meanMs) + stddev*z))
if delay < minMs {
delay = minMs
}
if delay > meanMs*3 {
delay = meanMs * 3
}
Comment thread
ulziibay-kernel marked this conversation as resolved.
return delay
}

// smoothStepDelay maps position i/n through a smoothstep curve to produce
// a delay in [fastMs, slowMs]. Slow at start and end, fast in the middle.
// smoothstep(t) = 3t² - 2t³
Expand Down
126 changes: 126 additions & 0 deletions server/cmd/api/api/computer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package api
import (
"errors"
"fmt"
"math"
"math/rand"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -168,6 +170,130 @@ func TestIsValidationErr_Nil(t *testing.T) {
assert.False(t, isValidationErr(nil))
}

func TestGaussianDelay(t *testing.T) {
const n = 10000
meanMs := 10

var sum, sumSq float64
minVal, maxVal := math.MaxFloat64, -math.MaxFloat64

for i := 0; i < n; i++ {
d := float64(gaussianDelay(meanMs, 3))
sum += d
sumSq += d * d
if d < minVal {
minVal = d
}
if d > maxVal {
maxVal = d
}
}

avg := sum / n
variance := sumSq/n - avg*avg

assert.InDelta(t, float64(meanMs), avg, float64(meanMs)*0.15,
"average delay should be near %dms, got %.1fms", meanMs, avg)

assert.Greater(t, variance, 5.0,
"variance should be substantial for human-like timing, got %.1f", variance)

assert.GreaterOrEqual(t, minVal, 3.0, "delay must not go below floor")

assert.LessOrEqual(t, maxVal, float64(meanMs*3), "delay must not exceed 3x mean")
}

func TestGaussianDelay_VarianceMuchHigherThanUniform(t *testing.T) {
const n = 5000
meanMs := 10

var gSum, gSumSq float64
for i := 0; i < n; i++ {
d := float64(gaussianDelay(meanMs, 3))
gSum += d
gSumSq += d * d
}
gAvg := gSum / n
gVariance := gSumSq/n - gAvg*gAvg

// Old uniform: meanMs + rand.Intn(5) - 2, variance of {-2,-1,0,1,2} = 2.0
uniformVariance := 2.0

assert.Greater(t, gVariance, uniformVariance*3,
"Gaussian variance (%.1f) should be much larger than old uniform variance (%.1f)",
gVariance, uniformVariance)
}

// welford implements Welford's online algorithm for computing running variance.
// This is the same algorithm used by browser fingerprinting systems to evaluate
// whether mouse movement timing looks human or automated.
type welford struct {
n int
mean float64
m2 float64
}

func (w *welford) add(v float64) {
w.n++
delta := v - w.mean
w.mean += delta / float64(w.n)
w.m2 += delta * (v - w.mean)
}

func (w *welford) variance() float64 {
if w.n < 2 {
return 0
}
return w.m2 / float64(w.n-1)
}

func TestGaussianDelay_WelfordVelocityVariance(t *testing.T) {
// Simulate a mouse trajectory: 50 points with varying pixel distances
// (as produced by a Bezier curve), timed with gaussianDelay intervals.
// Compute velocity = distance / delay for each step and measure Welford
// variance. Human-like velocity variance should be well above 5.
const steps = 50
meanDelayMs := 10

// Pixel distances per step typical of a Bezier curve across ~400px.
// Real trajectories vary: small moves near endpoints, larger in the middle.
rng := rand.New(rand.NewSource(42))
distances := make([]float64, steps)
for i := range distances {
t_norm := float64(i) / float64(steps)
base := 5.0 + 15.0*math.Sin(t_norm*math.Pi) // 5-20px, peaked in middle
distances[i] = base + rng.Float64()*3.0 // small random variation
}

// Gaussian delays → velocity variance
var gaussianVelVar welford
for i := 0; i < steps; i++ {
delay := float64(gaussianDelay(meanDelayMs, 3))
velocity := distances[i] / delay
gaussianVelVar.add(velocity)
}

// Uniform delays (old approach) → velocity variance
var uniformVelVar welford
for i := 0; i < steps; i++ {
delay := float64(meanDelayMs + rand.Intn(5) - 2)
if delay < 3 {
delay = 3
}
velocity := distances[i] / delay
uniformVelVar.add(velocity)
}

t.Logf("Gaussian velocity variance: %.4f (n=%d)", gaussianVelVar.variance(), gaussianVelVar.n)
t.Logf("Uniform velocity variance: %.4f (n=%d)", uniformVelVar.variance(), uniformVelVar.n)

assert.Greater(t, gaussianVelVar.variance(), uniformVelVar.variance(),
"Gaussian timing should produce higher velocity variance than uniform")

assert.Greater(t, gaussianVelVar.variance(), 0.05,
"Gaussian velocity variance should be well above near-zero")
}

func TestClampPoints(t *testing.T) {
tests := []struct {
name string
Expand Down
2 changes: 1 addition & 1 deletion server/lib/mousetrajectory/mousetrajectory.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ type MultiSegmentResult struct {
StepDelayMs int
}

const defaultStepDelayMs = 10
const defaultStepDelayMs = 20

// GenerateMultiSegmentTrajectory creates a human-like Bezier trajectory through
// a sequence of waypoints. Each consecutive pair gets its own Bezier curve, with
Expand Down
Loading