Skip to content

feat(stealth): human-like timing, mouse movement, and typing - #709

Open
rimonhanna wants to merge 5 commits into
stickerdaniel:mainfrom
rimonhanna:feat/stealth-humanization
Open

feat(stealth): human-like timing, mouse movement, and typing#709
rimonhanna wants to merge 5 commits into
stickerdaniel:mainfrom
rimonhanna:feat/stealth-humanization

Conversation

@rimonhanna

Copy link
Copy Markdown

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() jittered asyncio.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 in send_message.
  • 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 tick, the React keyboard-processing wait, a retry backoff), where jitter would add flakiness for no gain.

Why

send_message and 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

  • No new dependencies (stdlib random/asyncio only).
  • No behavioural change to what any tool returns, only to timing and cursor/keyboard motion.
  • The mouse helper is fully guarded and logs-and-continues on any error.

Tests

  • New tests/test_humanize.py: the jitter stays in-band and actually varies, human_type reproduces the exact text even with a typo forced on every character, and the mouse helper never raises (including missing viewport).
  • The existing composer tests reconstruct the typed text rather than pinning a single fixed-cadence call.
  • Full suite green, including -n auto (2026 passed).

Note on the second commit

test: isolate the server-log diagnostics case from the global trace dir is an unrelated, separable test-isolation fix. test_build_issue_diagnostics_omits_missing_server_log_from_gist had a hidden dependency on the process-wide trace dir, so under -n auto it 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.

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.
@github-actions github-actions Bot added the enhancement New feature or request label Aug 9, 2026
@greptile-apps

greptile-apps Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR introduces randomized pauses, cursor movement, and human-like message typing across browser interaction paths.

  • Adds reusable jitter, pause, mouse-movement, and typing helpers.
  • Integrates those helpers into navigation, scrolling, and message composition.
  • Adds focused tests and isolates a diagnostics test from process-wide trace state.

Confidence Score: 3/5

The 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

Filename Overview
linkedin_mcp_server/core/humanize.py Adds randomized timing, bounded deliberate pauses, typo correction, and guarded mouse movement.
linkedin_mcp_server/core/utils.py Replaces fixed pauses in page and container scrolling with bounded jitter.
linkedin_mcp_server/scraping/extractor.py Applies humanized pauses and interactions throughout navigation, expansion, pagination, and messaging paths.
tests/test_humanize.py Covers jitter ranges, typo correction, deliberate-sleep budgeting, and guarded cursor movement.
tests/test_error_diagnostics.py Makes the missing-server-log case deterministic by disabling process-wide trace-directory state.

Reviews (4): Last reviewed commit: "fix(stealth): make the typing bound wall..." | Re-trigger Greptile

Comment thread linkedin_mcp_server/core/humanize.py Outdated
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)
@rimonhanna

Copy link
Copy Markdown
Author

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 fix(stealth): bound human_type total time for long messages:

  • human_type now takes budget_seconds (default 60s). When the natural pace would exceed it, the per-key timing (base delay, thinking pauses, and typo-correction pauses) is scaled down proportionally, so total typing time is bounded for any length while staying jittered and human. A person types a long message faster per key anyway, so this stays realistic.
  • Short messages (the common case) are under budget and type at full natural cadence, so nothing changes there.
  • I chose a time budget over a message-length cap so no legitimate input is rejected; the worst case is simply bounded.

New test test_human_type_bounds_total_time_for_long_messages types a 3000-character message (~480s at natural pace) with a 30s budget and asserts total typing time stays near the budget and far below the unbounded pace, with the text still reproduced exactly. Full suite green including -n auto.

Comment thread linkedin_mcp_server/core/humanize.py Outdated
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)
Comment thread linkedin_mcp_server/scraping/extractor.py
Comment thread linkedin_mcp_server/core/humanize.py Outdated
…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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant