Skip to content

Commit ab8a845

Browse files
feat: add ambient mouse API endpoint for anti-bot event diversity
Add POST /computer/ambient_mouse to toggle a background loop of diverse input events (mouse drift, scroll, micro-drag, click, key tap). The loop acquires inputMu per action so it cooperates with explicit computer-use API calls instead of contending with them. - Configurable intervals (min/max_interval_ms) and per-action weights - Display geometry cached for 30s to minimize lock hold time - Mouseup uses background context to prevent stuck mouse button on cancel - Clean shutdown via Shutdown() cancels the ambient context Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 6397a13 commit ab8a845

4 files changed

Lines changed: 807 additions & 141 deletions

File tree

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
package api
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"math/rand"
7+
"strconv"
8+
"strings"
9+
"time"
10+
11+
"github.com/onkernel/kernel-images/server/lib/logger"
12+
oapi "github.com/onkernel/kernel-images/server/lib/oapi"
13+
)
14+
15+
// ambientAction identifies the type of ambient event to emit.
16+
type ambientAction int
17+
18+
const (
19+
ambientMouseDrift ambientAction = iota
20+
ambientScroll
21+
ambientMicroDrag
22+
ambientClick
23+
ambientKeyTap
24+
)
25+
26+
// ambientConfig holds the resolved configuration for the ambient mouse loop.
27+
type ambientConfig struct {
28+
minIntervalMs int
29+
maxIntervalMs int
30+
weights []struct {
31+
action ambientAction
32+
weight int
33+
}
34+
totalWeight int
35+
}
36+
37+
func (s *ApiService) SetAmbientMouse(ctx context.Context, request oapi.SetAmbientMouseRequestObject) (oapi.SetAmbientMouseResponseObject, error) {
38+
log := logger.FromContext(ctx)
39+
40+
if request.Body == nil {
41+
return oapi.SetAmbientMouse400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{
42+
Message: "request body is required"},
43+
}, nil
44+
}
45+
body := *request.Body
46+
47+
s.inputMu.Lock()
48+
49+
// Stop any running ambient loop first.
50+
if s.ambientCancel != nil {
51+
s.ambientCancel()
52+
s.ambientCancel = nil
53+
}
54+
55+
if !body.Enabled {
56+
s.inputMu.Unlock()
57+
log.Info("ambient mouse disabled")
58+
return oapi.SetAmbientMouse200JSONResponse(oapi.AmbientMouseResponse{Enabled: false}), nil
59+
}
60+
61+
// Resolve configuration with defaults.
62+
cfg, err := resolveAmbientConfig(body)
63+
if err != nil {
64+
s.inputMu.Unlock()
65+
return oapi.SetAmbientMouse400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{
66+
Message: err.Error()},
67+
}, nil
68+
}
69+
70+
ambientCtx, cancel := context.WithCancel(context.Background())
71+
s.ambientCancel = cancel
72+
s.inputMu.Unlock()
73+
74+
go s.runAmbientLoop(ambientCtx, cfg)
75+
76+
log.Info("ambient mouse enabled",
77+
"min_interval_ms", cfg.minIntervalMs,
78+
"max_interval_ms", cfg.maxIntervalMs,
79+
)
80+
return oapi.SetAmbientMouse200JSONResponse(oapi.AmbientMouseResponse{Enabled: true}), nil
81+
}
82+
83+
// resolveAmbientConfig builds an ambientConfig from the request body, applying defaults.
84+
func resolveAmbientConfig(body oapi.AmbientMouseRequest) (ambientConfig, error) {
85+
cfg := ambientConfig{
86+
minIntervalMs: 200,
87+
maxIntervalMs: 600,
88+
}
89+
if body.MinIntervalMs != nil {
90+
cfg.minIntervalMs = *body.MinIntervalMs
91+
}
92+
if body.MaxIntervalMs != nil {
93+
cfg.maxIntervalMs = *body.MaxIntervalMs
94+
}
95+
if cfg.minIntervalMs > cfg.maxIntervalMs {
96+
return cfg, fmt.Errorf("min_interval_ms must be <= max_interval_ms")
97+
}
98+
99+
driftW := 55
100+
scrollW := 20
101+
microDragW := 12
102+
clickW := 10
103+
keyTapW := 3
104+
if body.MouseDriftWeight != nil {
105+
driftW = *body.MouseDriftWeight
106+
}
107+
if body.ScrollWeight != nil {
108+
scrollW = *body.ScrollWeight
109+
}
110+
if body.MicroDragWeight != nil {
111+
microDragW = *body.MicroDragWeight
112+
}
113+
if body.ClickWeight != nil {
114+
clickW = *body.ClickWeight
115+
}
116+
if body.KeyTapWeight != nil {
117+
keyTapW = *body.KeyTapWeight
118+
}
119+
120+
cfg.weights = []struct {
121+
action ambientAction
122+
weight int
123+
}{
124+
{ambientMouseDrift, driftW},
125+
{ambientScroll, scrollW},
126+
{ambientMicroDrag, microDragW},
127+
{ambientClick, clickW},
128+
{ambientKeyTap, keyTapW},
129+
}
130+
for _, w := range cfg.weights {
131+
cfg.totalWeight += w.weight
132+
}
133+
if cfg.totalWeight == 0 {
134+
return cfg, fmt.Errorf("at least one action weight must be > 0")
135+
}
136+
return cfg, nil
137+
}
138+
139+
// runAmbientLoop is the background goroutine that emits diverse input events.
140+
// It acquires inputMu for each action, so it cooperates with explicit API calls.
141+
func (s *ApiService) runAmbientLoop(ctx context.Context, cfg ambientConfig) {
142+
r := rand.New(rand.NewSource(time.Now().UnixNano()))
143+
144+
for {
145+
select {
146+
case <-ctx.Done():
147+
return
148+
default:
149+
}
150+
151+
action := pickAmbientAction(r, cfg)
152+
s.inputMu.Lock()
153+
s.execAmbientAction(ctx, r, action)
154+
s.inputMu.Unlock()
155+
156+
// Random delay between events.
157+
delayMs := cfg.minIntervalMs + r.Intn(cfg.maxIntervalMs-cfg.minIntervalMs+1)
158+
select {
159+
case <-ctx.Done():
160+
return
161+
case <-time.After(time.Duration(delayMs) * time.Millisecond):
162+
}
163+
}
164+
}
165+
166+
func pickAmbientAction(r *rand.Rand, cfg ambientConfig) ambientAction {
167+
n := r.Intn(cfg.totalWeight)
168+
for _, w := range cfg.weights {
169+
if n < w.weight {
170+
return w.action
171+
}
172+
n -= w.weight
173+
}
174+
return ambientMouseDrift
175+
}
176+
177+
// execAmbientAction performs a single ambient event via xdotool. Must be called
178+
// with inputMu held.
179+
func (s *ApiService) execAmbientAction(ctx context.Context, r *rand.Rand, action ambientAction) {
180+
switch action {
181+
case ambientMouseDrift:
182+
dx := r.Intn(8) - 4
183+
dy := r.Intn(8) - 4
184+
if dx == 0 && dy == 0 {
185+
dx = 1
186+
}
187+
defaultXdoTool.Run(ctx, "mousemove_relative", "--", fmt.Sprintf("%d", dx), fmt.Sprintf("%d", dy))
188+
189+
case ambientScroll:
190+
w, h := s.getDisplayGeometry(ctx)
191+
if w > 0 && h > 0 {
192+
x := w/2 + r.Intn(80) - 40
193+
y := h/2 + r.Intn(80) - 40
194+
defaultXdoTool.Run(ctx, "mousemove", strconv.Itoa(x), strconv.Itoa(y))
195+
btn := "4"
196+
if r.Intn(2) == 0 {
197+
btn = "5"
198+
}
199+
defaultXdoTool.Run(ctx, "click", btn)
200+
}
201+
202+
case ambientMicroDrag:
203+
dx := 3 + r.Intn(6)
204+
dy := 3 + r.Intn(6)
205+
if r.Intn(2) == 0 {
206+
dx = -dx
207+
}
208+
if r.Intn(2) == 0 {
209+
dy = -dy
210+
}
211+
defaultXdoTool.Run(ctx, "mousedown", "1")
212+
defaultXdoTool.Run(ctx, "mousemove_relative", "--", fmt.Sprintf("%d", dx), fmt.Sprintf("%d", dy))
213+
// Use background context so mouseup always fires even if ctx is cancelled,
214+
// preventing a stuck mouse button.
215+
defaultXdoTool.Run(context.Background(), "mouseup", "1")
216+
217+
case ambientClick:
218+
w, h := s.getDisplayGeometry(ctx)
219+
if w > 200 && h > 200 {
220+
pad := 100
221+
if w < 400 {
222+
pad = w / 4
223+
}
224+
x := pad + r.Intn(max(1, w-2*pad))
225+
y := pad + r.Intn(max(1, h-2*pad))
226+
defaultXdoTool.Run(ctx, "mousemove", strconv.Itoa(x), strconv.Itoa(y))
227+
defaultXdoTool.Run(ctx, "click", "1")
228+
}
229+
230+
case ambientKeyTap:
231+
// Modifier tap; least likely to trigger page behavior.
232+
defaultXdoTool.Run(ctx, "key", "shift")
233+
}
234+
}
235+
236+
// getDisplayGeometry returns the current display dimensions via xdotool.
237+
// The result is cached for 30 seconds to avoid shelling out on every ambient event.
238+
func (s *ApiService) getDisplayGeometry(ctx context.Context) (int, int) {
239+
s.displayGeomMu.Lock()
240+
defer s.displayGeomMu.Unlock()
241+
242+
if time.Since(s.displayGeomAt) < 30*time.Second && s.displayGeomW > 0 {
243+
return s.displayGeomW, s.displayGeomH
244+
}
245+
246+
out, err := defaultXdoTool.Run(ctx, "getdisplaygeometry")
247+
if err != nil {
248+
return s.displayGeomW, s.displayGeomH
249+
}
250+
parts := strings.Fields(strings.TrimSpace(string(out)))
251+
if len(parts) >= 2 {
252+
w, _ := strconv.Atoi(parts[0])
253+
h, _ := strconv.Atoi(parts[1])
254+
if w > 0 && h > 0 {
255+
s.displayGeomW = w
256+
s.displayGeomH = h
257+
s.displayGeomAt = time.Now()
258+
return w, h
259+
}
260+
}
261+
return s.displayGeomW, s.displayGeomH
262+
}

server/cmd/api/api/api.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,15 @@ type ApiService struct {
5353

5454
// policy management
5555
policy *policy.Policy
56+
57+
// ambientCancel stops the ambient mouse loop (protected by inputMu)
58+
ambientCancel context.CancelFunc
59+
60+
// displayGeom caches xdotool getdisplaygeometry to avoid shelling out per ambient event
61+
displayGeomMu sync.Mutex
62+
displayGeomW int
63+
displayGeomH int
64+
displayGeomAt time.Time
5665
}
5766

5867
var _ oapi.StrictServerInterface = (*ApiService)(nil)
@@ -298,5 +307,13 @@ func (s *ApiService) ListRecorders(ctx context.Context, _ oapi.ListRecordersRequ
298307
}
299308

300309
func (s *ApiService) Shutdown(ctx context.Context) error {
310+
// Stop ambient mouse loop if running.
311+
s.inputMu.Lock()
312+
if s.ambientCancel != nil {
313+
s.ambientCancel()
314+
s.ambientCancel = nil
315+
}
316+
s.inputMu.Unlock()
317+
301318
return s.recordManager.StopAll(ctx)
302319
}

0 commit comments

Comments
 (0)