Skip to content

feat(native): a Playwright-free driver over raw DevTools (phases 1-2) - #9

Merged
unohee merged 1 commit into
masterfrom
feat/native-cdp-driver
Oct 25, 2026
Merged

unohee merged 1 commit into
masterfrom
feat/native-cdp-driver

Conversation

@unohee

@unohee unohee commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

Why

lyra depends on Playwright for every browser action. That has three costs:

  • VEGA bundling. VEGA has to bundle playwright, and a 1.58 bundle loses element refs.
  • Bot detection. Playwright sends Runtime.enable, and context.route disables the cache; Kasada and DataDome refuse browsers that do either.
  • Control. lyra cannot control input, tabs and downloads itself.

This PR adds an opt-in native driver: Chrome over raw DevTools on lyra's own WebSocket, with no Playwright, Node or chromedriver. Plan and phase-0 spike: AX-tree parity 324/324, isolated worlds without Runtime.enable, no document escaping the guard, cookies readable across drivers.

What (phases 1–2 of 6)

  • LYRA_BROWSER_DRIVER=native makes session.load_driver return lyra_browser.native.async_native. It has the same shape as async_playwright, so BrowserSession and the page-level tools are unchanged.
  • launcher
    • Uses Playwright 1.64's measured default switches. That keeps the keychain pair (the shared profile's cookies stay readable), the bfcache switch the guard depends on, and popup blocking off.
    • Keeps the sandbox on, where Playwright passes --no-sandbox. LYRA_BROWSER_SANDBOX=off turns it off for containers that cannot sandbox. The Cloudflare container will need that if it ever moves to native.
    • Reads the endpoint from DevToolsActivePort.
    • Recognises a held profile from the exit code: 0 when a windowed Chrome hands over, 21 / SingletonLock when headless.
  • connection / context / page / dialog / download / evaluate / errors, following Playwright's semantics and wording, because the tools read error class names and message text:
    • navigation is waited on per loader;
    • evaluate runs in an isolated world by default, with world="main" on request;
    • downloads go to a private directory;
    • dialogs nobody listens for are dismissed.
  • Wiring
    • native forces guard_backend=cdp.
    • CdpGuard.start(endpoint=) takes the endpoint the launcher already read, because the file is deleted on read.
    • describe_failure counts lyra_browser.native errors as driver errors.
  • Fix for patchright too. read_form, read_draft and set_editor now evaluate in the page's own world (isolated_context=False / world="main"). Before this, window.CKEDITOR was invisible to them under patchright.

Not in this PR: element tools (click, type_text, the tree view). They need selectors, actionability and input (phase 3) and the AX snapshot (phase 4). keyboard / mouse raise "not implemented by the native driver yet". auto never picks native.

Verified

  • pytest: 1707 passed. Two tests/test_claude_plugin.py tests fail on master as well: bootstrap.sh uses sha256sum, which macOS does not have on /usr/bin:/bin.

  • tests/native/: 48 unit tests.

    • Error wording is fed to describe_failure and session's matchers.
    • Every lyra page script is classified as a function or an expression.
    • Switches: sandbox, headless, debugging port.
    • Connection routing and teardown, config wiring, and guard endpoint injection.
  • Real-Chrome smoke through the tools (testing/native_driver_smoke_261023_v1.py, gitignored), 18/18 headless and headful:

    covered
    navigate with status, back, reload
    screenshots, including full page
    tabs
    a download saved
    a dialog answered
    a popup adopted, then closed back to its opener
    read_form reading window.CKEDITOR from the main world
    a page-started navigation and a popup to a denied origin, both refused by the guard with 0 requests reaching the server
  • A held profile was measured live, headless and headful, and reads as profile is already in use.

LYRA_BROWSER_DRIVER=native launches an installed Chrome itself and drives it
over lyra's own WebSocket (cdp_socket), with no Playwright, Node or
chromedriver. It implements the slice of Playwright's async API that
BrowserSession and the page-level tools use, so neither changes:

- launcher: Playwright 1.64's measured default switches (the keychain pair
  that keeps the shared profile's cookies readable, the bfcache switch the
  guard depends on, popup blocking off), minus --no-sandbox: the sandbox
  stays on unless LYRA_BROWSER_SANDBOX is off. The endpoint comes from
  DevToolsActivePort; a held profile is recognised from the exit (0 when a
  windowed Chrome hands over, 21 / SingletonLock headless) and worded as
  'profile is already in use' for session.
- connection: commands by id, events by flat session, every waiting call
  failed as TargetClosedError when the stream ends.
- context: tabs by browser-level auto-attach without waitForDebuggerOnStart,
  so cdp_guard keeps holding each new tab until its document filter is in
  place; downloads into a private dir; unheard dialogs dismissed.
- page: goto/go_back/reload waited on per loader with Playwright's
  'interrupted by another navigation' and 'net::ERR_ABORTED; maybe frame was
  detached?' wording, document responses, evaluate in an isolated world by
  default (never Runtime.enable) with world='main' on request, screenshots,
  tab front/close/opener.
- errors are named and worded as Playwright's, because describe_failure,
  session and capture read class names and message text.

Wiring: native forces guard_backend=cdp; CdpGuard.start takes the endpoint
the launcher already read (the file is deleted on read); describe_failure
counts lyra_browser.native errors as the driver's. read_form, read_draft and
set_editor now evaluate in the page's own world on patchright, whose Page and
Locator evaluate both take isolated_context (window.CKEDITOR is invisible
from an isolated world, so the CKEditor sync silently did nothing there).
On native, read_form and read_draft do too; set_editor's sync goes through a
Locator, which native gets in phase 3.

Element tools (click, type_text, the tree view) arrive in phases 3-4; until
then native is opt-in and README says so.

Verified: pytest 1707 passed (the 2 test_claude_plugin sha256sum failures are
pre-existing on macOS); tests/native 48 unit tests; a real-Chrome smoke
through the tools (testing/native_driver_smoke_261023_v1.py, 18/18 headless
and headful): navigate with status, back, reload, screenshots, tabs, download
saved, a dialog answered (the tab keeps evaluating), a popup adopted and
closed back to its opener, read_form seeing window.CKEDITOR, and both a
page-started navigation and a popup to a denied origin refused by the guard
with no request reaching the server.
@unohee
unohee marked this pull request as ready for review October 25, 2026 03:53
@unohee
unohee force-pushed the feat/native-cdp-driver branch from 3fc4686 to dc9805a Compare October 25, 2026 03:53
@unohee
unohee merged commit 5f58117 into master Oct 25, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant