feat(stealth): human-like timing, mouse movement, and typing - #709
feat(stealth): human-like timing, mouse movement, and typing#709rimonhanna wants to merge 5 commits into
Conversation
Automation that paces itself with constant delays, a frozen cursor, and a uniform keystroke cadence is easy to fingerprint as non-human. This adds a small humanization layer (linkedin_mcp_server/core/humanize.py) and wires it into the browser paths: - jitter(): every pause is drawn from a band around its base (default +/-50%) instead of a fixed value. A constant delay is itself the tell. - human_pause(): jittered asyncio.sleep, used for the inter-navigation delays and the post-action pauses (after a click, recipient select, composer close). - humanize_after_nav(): a few small stepped mouse moves inside the viewport after each page load. Guarded so a mouse failure can never break a scrape. - human_type(): types a message with jittered per-key timing, an occasional thinking pause, and now and then a wrong keystroke that is immediately backspaced and corrected. The net typed text always equals the input. - the JS container scroller now jitters its pause instead of a constant cadence. Left deliberately fixed: functional settles that are not behavioural tells (a response-drain poll, the React keyboard-processing wait, a retry backoff). Tests: test_humanize.py covers the jitter band, that typing reproduces the exact text even with a typo forced on every character, and that the mouse helper never raises. The existing composer tests reconstruct the typed text rather than pinning a single fixed-cadence call.
test_build_issue_diagnostics_omits_missing_server_log_from_gist asserts that server.log is absent from the suggested gist command, but whether it appears depends on the process-wide trace dir (get_trace_dir), which another test on the same xdist worker can set. Under -n auto that made the case order-dependent: green in isolation, red for some worker distributions. Pinning the trace dir off inside the test makes the "log is absent" case deterministic. No production code changes.
Greptile SummaryThe PR introduces randomized pauses, cursor movement, and human-like message typing across browser interaction paths.
Confidence Score: 3/5The PR is not yet safe to merge because message typing can still outlive both its nominal budget and the caller’s remaining tool deadline, leaving messages partially typed and unsent. The current implementation starts a fresh fixed typing budget after potentially lengthy setup and continues awaiting one browser keyboard operation per remaining character after that budget expires, so the two previously reported timeout paths remain reachable. Files Needing Attention: linkedin_mcp_server/core/humanize.py, linkedin_mcp_server/scraping/extractor.py Important Files Changed
Reviews (4): Last reviewed commit: "fix(stealth): make the typing bound wall..." | Re-trigger Greptile |
Human typing cadence is ~0.16s per character, so a long message (thousands of characters) would type for minutes and could overrun the caller's tool timeout before the Send click, cancelling a message the API otherwise accepts. human_type now takes a budget_seconds (default 60s): when the natural pace would exceed it, the per-key timing is scaled down proportionally, so total typing time stays bounded for any length while remaining jittered and human. Short messages (the common case) are under budget and type at full natural cadence. This bounds the worst case without imposing an arbitrary message-length limit. (cherry picked from commit 71ddce0)
|
Thanks, this is a valid catch. Human cadence is ~0.16s/char, so a multi-thousand-character message would type for minutes and could overrun the caller's tool timeout before the Send click. Fixed in
New test |
Follow-up to the typing budget: two refinements so the budget reliably keeps the whole messaging operation under the caller's tool timeout, not just the sleeps. - Lower the default budget from 60s to 30s, so it sits well under typical tool timeouts even when one is configured shorter than 60s. - Spend only 80% of the budget on deliberate sleeps; the remaining 20% is headroom for the browser keyboard round-trips (one per key), the composer setup spent before typing, and the Send click after -- none of which the sleep budget itself accounts for. The test now asserts total sleep time stays within the full budget rather than a loose multiple of it. (cherry picked from commit 14672bc)
…rd I/O The previous budget precomputed a per-key scale from an estimated cadence, so it bounded only the summed sleeps. The serial browser keyboard round-trips (one await per character) were not counted, so a very long message could still run past the budget on I/O alone. human_type now paces against a wall-clock deadline (time.monotonic) instead: deliberate pauses run until real elapsed reaches the budgeted share, then the remaining characters are typed at the browser's own pace with no added pauses. Because keyboard round-trips advance that same clock, they count against the budget too, so total typing time is bounded by budget_seconds regardless of message length or per-key latency. Short messages still type at full cadence. On the caller-deadline point: MCP does not surface the client's timeout to the server (FastMCP Context exposes only request ids, no deadline), so there is no remaining-deadline to inherit; budget_seconds is the knob, defaulting to a conservative 30s. The bound test now simulates per-keystroke I/O advancing the clock and asserts the deliberate pauses cut off at the budget rather than accumulating across all characters. (cherry picked from commit 5a60a97)
What
A small anti-detection layer for the browser paths. Automation that paces itself with constant delays, a frozen cursor, and a uniform keystroke cadence is easy to fingerprint as non-human; this makes that timing and interaction look human.
All in one new module,
linkedin_mcp_server/core/humanize.py, wired into the scraper:jitter()every pause is drawn from a band around its base (default +/-50%) instead of a fixed value. A constant delay is itself the tell.human_pause()jitteredasyncio.sleep, used for the inter-navigation delays and the post-action pauses (after a click, a recipient select, closing the composer).humanize_after_nav()a few small stepped mouse moves inside the viewport after each page load. Guarded, so a mouse failure can never break a scrape.human_type()types a message with jittered per-key timing, an occasional thinking pause, and now and then a wrong keystroke that is immediately backspaced and corrected. The net typed text always equals the input. Replaces the previous uniform 15ms/key cadence insend_message.Left deliberately fixed: functional settles that are not behavioural tells (a response-drain poll tick, the React keyboard-processing wait, a retry backoff), where jitter would add flakiness for no gain.
Why
send_messageand the navigation/scroll loops are the highest-risk paths for looking like a bot. Constant cadence across keystrokes, pauses, and scrolls is the cheapest signal to detect. Jittering it, moving the cursor, and correcting the occasional typo removes those signals at essentially no cost.Scope / safety
random/asyncioonly).Tests
tests/test_humanize.py: the jitter stays in-band and actually varies,human_typereproduces the exact text even with a typo forced on every character, and the mouse helper never raises (including missing viewport).-n auto(2026 passed).Note on the second commit
test: isolate the server-log diagnostics case from the global trace diris an unrelated, separable test-isolation fix.test_build_issue_diagnostics_omits_missing_server_log_from_gisthad a hidden dependency on the process-wide trace dir, so under-n autoit was order-dependent (green in isolation, red for some worker distributions). Adding a test file here shifted the distribution and surfaced it. The fix pins the trace dir off inside that one test; no production code changes. Happy to split it into its own PR if you prefer.