pi-lean-dimension 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/README.md +96 -36
  2. package/node_modules/pi-lean-portal/AGENTS.md +146 -4
  3. package/node_modules/pi-lean-portal/README.md +112 -50
  4. package/node_modules/pi-lean-portal/__tests__/browser-data.test.ts +124 -0
  5. package/node_modules/pi-lean-portal/__tests__/browser-inspect.test.ts +192 -16
  6. package/node_modules/pi-lean-portal/__tests__/browser-toggle-profile.test.ts +3 -3
  7. package/node_modules/pi-lean-portal/__tests__/browser-toggle.test.ts +49 -35
  8. package/node_modules/pi-lean-portal/__tests__/chromium-py-persistence.test.ts +21 -195
  9. package/node_modules/pi-lean-portal/__tests__/chromium-py.test.ts +17 -81
  10. package/node_modules/pi-lean-portal/__tests__/chromium.test.ts +25 -0
  11. package/node_modules/pi-lean-portal/__tests__/contributed/invisible-py/invisible-py.test.ts +299 -0
  12. package/node_modules/pi-lean-portal/__tests__/cookie-persistence.test.ts +22 -182
  13. package/node_modules/pi-lean-portal/__tests__/fetch-backend.test.ts +1 -1
  14. package/node_modules/pi-lean-portal/__tests__/firefox-py-persistence.test.ts +21 -184
  15. package/node_modules/pi-lean-portal/__tests__/firefox-py.test.ts +17 -101
  16. package/node_modules/pi-lean-portal/__tests__/firefox.test.ts +2 -18
  17. package/node_modules/pi-lean-portal/__tests__/helpers/__pycache__/mock-python-bridge.cpython-313.pyc +0 -0
  18. package/node_modules/pi-lean-portal/__tests__/helpers/create-py-backend-harness.ts +105 -0
  19. package/node_modules/pi-lean-portal/__tests__/helpers/load-plugin-config-from-file.ts +53 -0
  20. package/node_modules/pi-lean-portal/__tests__/helpers/mock-plugin.ts +11 -7
  21. package/node_modules/pi-lean-portal/__tests__/helpers/mock-python-bridge.py +4 -0
  22. package/node_modules/pi-lean-portal/__tests__/helpers/persistence-suite.ts +218 -0
  23. package/node_modules/pi-lean-portal/__tests__/helpers/plugin-contract.ts +198 -318
  24. package/node_modules/pi-lean-portal/__tests__/helpers/probe-user-backend.ts +198 -0
  25. package/node_modules/pi-lean-portal/__tests__/helpers/test-server.ts +14 -0
  26. package/node_modules/pi-lean-portal/__tests__/plugin-config-browser.test.ts +150 -15
  27. package/node_modules/pi-lean-portal/__tests__/plugin-loading.test.ts +120 -18
  28. package/node_modules/pi-lean-portal/__tests__/plugin-registry.test.ts +6 -67
  29. package/node_modules/pi-lean-portal/__tests__/probe-user-backend.test.ts +236 -0
  30. package/node_modules/pi-lean-portal/__tests__/python-adapter.test.ts +401 -11
  31. package/node_modules/pi-lean-portal/__tests__/router-session.test.ts +4 -1
  32. package/node_modules/pi-lean-portal/__tests__/run-contributed-suites.test.ts +318 -0
  33. package/node_modules/pi-lean-portal/__tests__/session-manager.test.ts +50 -0
  34. package/node_modules/pi-lean-portal/__tests__/snapshot-cache.test.ts +2 -2
  35. package/node_modules/pi-lean-portal/__tests__/url-safety.test.ts +1 -1
  36. package/node_modules/pi-lean-portal/backends/chromium/index.ts +5 -13
  37. package/node_modules/pi-lean-portal/backends/chromium-py/__pycache__/bridge.cpython-313.pyc +0 -0
  38. package/node_modules/pi-lean-portal/backends/chromium-py/bridge.py +0 -2
  39. package/node_modules/pi-lean-portal/backends/firefox/index.ts +6 -5
  40. package/node_modules/pi-lean-portal/backends/firefox-py/__pycache__/bridge.cpython-313.pyc +0 -0
  41. package/node_modules/pi-lean-portal/backends/firefox-py/bridge.py +7 -6
  42. package/node_modules/pi-lean-portal/backends/playwright-base/playwright-plugin.ts +241 -398
  43. package/node_modules/pi-lean-portal/backends/python-adapter.ts +182 -83
  44. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__init__.py +1 -42
  45. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/__init__.cpython-312.pyc +0 -0
  46. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/__init__.cpython-313.pyc +0 -0
  47. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/accessibility.cpython-312.pyc +0 -0
  48. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/accessibility.cpython-313.pyc +0 -0
  49. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/bot_detection.cpython-312.pyc +0 -0
  50. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/bot_detection.cpython-313.pyc +0 -0
  51. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/bridge.cpython-312.pyc +0 -0
  52. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/bridge.cpython-313.pyc +0 -0
  53. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/browser_data.cpython-312.pyc +0 -0
  54. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/browser_data.cpython-313.pyc +0 -0
  55. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/patch_playwright.cpython-312.pyc +0 -0
  56. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/patch_playwright.cpython-313.pyc +0 -0
  57. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/playwright_base.cpython-312.pyc +0 -0
  58. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/playwright_base.cpython-313.pyc +0 -0
  59. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/transport.cpython-312.pyc +0 -0
  60. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/transport.cpython-313.pyc +0 -0
  61. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/accessibility.py +12 -147
  62. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/bot_detection.py +12 -37
  63. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/bridge.py +249 -322
  64. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/browser_data.py +92 -0
  65. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/patch_playwright.py +321 -0
  66. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/playwright_base.py +511 -299
  67. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
  68. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/{test_accessibility.cpython-313-pytest-9.1.0.pyc → test_accessibility.cpython-313-pytest-9.1.1.pyc} +0 -0
  69. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_accessibility.cpython-313.pyc +0 -0
  70. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_bot_detection.cpython-313-pytest-9.1.1.pyc +0 -0
  71. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_bot_detection.cpython-313.pyc +0 -0
  72. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_browser_data.cpython-313-pytest-9.1.1.pyc +0 -0
  73. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_browser_data.cpython-313.pyc +0 -0
  74. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/{test_chromium_py_bridge.cpython-313-pytest-9.1.0.pyc → test_chromium_py_bridge.cpython-313-pytest-9.1.1.pyc} +0 -0
  75. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/{test_firefox_py_bridge.cpython-313-pytest-9.1.0.pyc → test_firefox_py_bridge.cpython-313-pytest-9.1.1.pyc} +0 -0
  76. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_playwright_base_quirks.cpython-313-pytest-9.1.1.pyc +0 -0
  77. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_playwright_base_quirks.cpython-313.pyc +0 -0
  78. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_py_bridges.cpython-313-pytest-9.1.1.pyc +0 -0
  79. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/{test_transport.cpython-313-pytest-9.1.0.pyc → test_transport.cpython-313-pytest-9.1.1.pyc} +0 -0
  80. package/node_modules/pi-lean-portal/backends/python-base/tests/conftest.py +95 -0
  81. package/node_modules/pi-lean-portal/backends/python-base/tests/test_accessibility.py +8 -132
  82. package/node_modules/pi-lean-portal/backends/python-base/tests/test_bot_detection.py +8 -147
  83. package/node_modules/pi-lean-portal/backends/python-base/tests/test_browser_data.py +131 -0
  84. package/node_modules/pi-lean-portal/backends/python-base/tests/test_playwright_base_quirks.py +768 -0
  85. package/node_modules/pi-lean-portal/backends/python-base/tests/test_py_bridges.py +198 -0
  86. package/node_modules/pi-lean-portal/browser-toggle.ts +33 -69
  87. package/node_modules/pi-lean-portal/contributed/CHOOSING.md +126 -0
  88. package/node_modules/pi-lean-portal/contributed/README.md +304 -0
  89. package/node_modules/pi-lean-portal/contributed/camoufox-py/bridge.py +216 -0
  90. package/node_modules/pi-lean-portal/contributed/invisible-py/__pycache__/bridge.cpython-313.pyc +0 -0
  91. package/node_modules/pi-lean-portal/contributed/invisible-py/bridge.py +434 -0
  92. package/node_modules/pi-lean-portal/core/fetch-backend.ts +0 -5
  93. package/node_modules/pi-lean-portal/core/plugin-api.ts +6 -33
  94. package/node_modules/pi-lean-portal/core/plugin-config.ts +75 -58
  95. package/node_modules/pi-lean-portal/core/plugin-registry.ts +11 -49
  96. package/node_modules/pi-lean-portal/core/router.ts +59 -98
  97. package/node_modules/pi-lean-portal/core/shared/accessibility-tree.ts +10 -143
  98. package/node_modules/pi-lean-portal/core/shared/bot-detection.ts +31 -77
  99. package/node_modules/pi-lean-portal/core/shared/browser-data.json +183 -0
  100. package/node_modules/pi-lean-portal/core/shared/browser-data.ts +50 -0
  101. package/node_modules/pi-lean-portal/core/shared/browser-events.ts +6 -6
  102. package/node_modules/pi-lean-portal/core/shared/dom-extractor.ts +97 -36
  103. package/node_modules/pi-lean-portal/core/shared/nav-settle.ts +12 -15
  104. package/node_modules/pi-lean-portal/core/shared/paths.ts +3 -0
  105. package/node_modules/pi-lean-portal/core/shared/session-manager.ts +7 -21
  106. package/node_modules/pi-lean-portal/{verify-ship-manifest.ts → core/shared/ship-manifest.ts} +34 -9
  107. package/node_modules/pi-lean-portal/core/shared/snapshot-cache.ts +7 -6
  108. package/node_modules/pi-lean-portal/core/shared/storage-state.ts +40 -9
  109. package/node_modules/pi-lean-portal/index.ts +42 -7
  110. package/node_modules/pi-lean-portal/package.json +8 -3
  111. package/node_modules/pi-lean-portal/ship-manifest.test.ts +8 -3
  112. package/node_modules/pi-lean-portal/tools/browser-inspect.ts +2 -6
  113. package/node_modules/pi-lean-portal/tools/browser-navigate.ts +7 -5
  114. package/node_modules/pi-lean-portal/tools/browser-snapshot.ts +2 -5
  115. package/node_modules/pi-lean-portal/tools/utils.ts +22 -4
  116. package/node_modules/pi-lean-portal/tools/web-fetch.ts +3 -4
  117. package/node_modules/pi-lean-search/README.md +6 -2
  118. package/node_modules/pi-lean-search/__tests__/web-search.test.ts +11 -0
  119. package/node_modules/pi-lean-search/index.ts +4 -21
  120. package/node_modules/pi-lean-search/package.json +2 -2
  121. package/node_modules/pi-lean-search/verify-ship-manifest.ts +6 -92
  122. package/node_modules/pi-lean-search/web-search-tool.ts +19 -13
  123. package/package.json +4 -4
  124. package/node_modules/pi-lean-portal/__tests__/helpers/reddit-fixture.ts +0 -264
  125. package/node_modules/pi-lean-portal/__tests__/helpers/toggle-test-utils.ts +0 -31
  126. package/node_modules/pi-lean-portal/__tests__/reddit-dialog.test.ts +0 -302
  127. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/occlusion.cpython-313.pyc +0 -0
  128. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_bot_detection.cpython-313-pytest-9.1.0.pyc +0 -0
  129. package/node_modules/pi-lean-portal/backends/python-base/tests/test_chromium_py_bridge.py +0 -281
  130. package/node_modules/pi-lean-portal/backends/python-base/tests/test_firefox_py_bridge.py +0 -212
@@ -0,0 +1,434 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Invisible-Py Bridge — Python-side stealth backend using invisible_playwright.
4
+
5
+ **This is a user-installed backend — it does NOT ship in the npm tarball.**
6
+ To install, copy this file and its ``.venv/`` to::
7
+
8
+ ~/.pi/agent/pi-lean-portal/user-backends/invisible-py/bridge.py
9
+
10
+ Then create a virtual environment, install dependencies, fetch the binary,
11
+ and add the plugin entry to your ``settings.json``.
12
+
13
+ Lifecycle pattern: engine owns its own Playwright instance
14
+ -----------------------------------------------------------
15
+ Unlike some stealth backends (notably shipped Camoufox) which accept an
16
+ external Playwright instance and only override ``_launch_browser``, this
17
+ bridge uses **lifecycle pattern #2**: the engine (``InvisiblePlaywright``)
18
+ starts its own Playwright instance inside ``__enter__()`` and owns it.
19
+ The base class normally calls ``_ensure_playwright()`` → ``sync_playwright().start()``
20
+ → ``_launch_browser()``. To avoid two Playwright instances, this bridge
21
+ overrides both ``_ensure_playwright`` and ``_maybe_stop_playwright`` to
22
+ delegate to the ``InvisiblePlaywright`` context manager.
23
+
24
+ For the same reason, ``_launch_browser()`` is NOT overridden — the base's
25
+ ``_launch_browser`` is never called because ``_ensure_playwright`` completely
26
+ replaces the launch path.
27
+
28
+ This pattern is the **unique probe** for the architecture's ability to
29
+ handle backends that manage their own Playwright lifecycle.
30
+
31
+ Quirks rationale
32
+ -----------------
33
+ ``_fingerprint_managed_context = True``
34
+ The base's ``create_browser_context()`` unconditionally sets
35
+ ``viewport: {1280, 720}`` and ``user_agent: effective_user_agent``.
36
+ The patched Firefox binary generates viewport, user_agent, screen, dpr
37
+ from the fingerprint with "user wins" semantics. Without this flag,
38
+ the base's hard-coded values would override the fingerprint — producing
39
+ a detectable mismatch.
40
+
41
+ ``_capture_user_agent = False``
42
+ ``_capture_ua()`` probes the UA via a throwaway ``self._browser.new_page()``,
43
+ which creates an implicit BrowserContext that is never closed. On the
44
+ patched Firefox binary, that leftover context reliably deadlocks the
45
+ Juggler driver when a subsequent BrowserContext's ``new_page()`` is
46
+ created — the second ``browser.navigate`` for a different task hangs
47
+ forever inside the Playwright sync greenlet. UA capture is also
48
+ pointless here: with ``_fingerprint_managed_context = True``,
49
+ ``create_browser_context()`` does NOT pass ``user_agent`` to
50
+ ``new_context`` (the fingerprint package sets it), so
51
+ ``effective_user_agent`` is never read.
52
+
53
+ ``_skip_networkidle = True``
54
+ The patched Firefox binary does not fire ``networkidle`` reliably.
55
+ Waiting for it in ``do_go_back`` / ``_wait_for_page_ready`` either
56
+ times out (30s) or loiters in the Playwright sync greenlet long enough
57
+ to risk deadlocking the Juggler driver when a subsequent BrowserContext
58
+ is created. This flag makes navigation settle use ``load`` instead of
59
+ ``networkidle``, matching ``do_navigate``'s load-based settle.
60
+
61
+ Back navigation (``/web back``)
62
+ --------------------------------
63
+ The patched Firefox binary does not support back/forward navigation:
64
+ ``page.go_back()`` times out for every ``wait_until`` value and the URL
65
+ never changes. This bridge's ``do_go_back`` implements a
66
+ ``document.referrer`` workaround: re-navigate to the referrer URL via
67
+ ``page.goto``. This covers the dominant agent pattern (click a link →
68
+ wrong way → go back). When ``document.referrer`` is empty (direct
69
+ navigations, ``rel=noreferrer`` links), the bridge returns ``success: false``
70
+ with a clear error — it is an honest limitation, not a silent no-op.
71
+
72
+ Requires
73
+ --------
74
+ * Python >= 3.10
75
+ * ``invisible-playwright`` installed (``pip install invisible-playwright``)
76
+ * Fetched patched Firefox binary (``python -m invisible_playwright fetch``)
77
+ * ``xvfb`` system package on Linux for ``headless=True``
78
+ """
79
+
80
+ import json
81
+ import os
82
+ import re
83
+ import sys
84
+ import time
85
+ from typing import Any
86
+
87
+ from pi_browser_bridge.playwright_base import PlaywrightBridge
88
+ from pi_browser_bridge.bridge import SessionNotFoundError
89
+
90
+
91
+ class InvisiblePyBridge(PlaywrightBridge):
92
+ """Stealth bridge using invisible_playwright (Firefox-based).
93
+
94
+ Uses lifecycle pattern #2: overrides ``_ensure_playwright`` and
95
+ ``_maybe_stop_playwright`` to delegate to an ``InvisiblePlaywright``
96
+ context manager, which owns its own Playwright instance and patches
97
+ ``browser.new_context`` for fingerprint coherence.
98
+ """
99
+
100
+ _plugin_name: str = "invisible-py"
101
+ _fingerprint_managed_context: bool = True
102
+ # invisible_playwright's patched Firefox binary routes page.evaluate
103
+ # through eval() in the page's main world (a stealth measure that kills
104
+ # Juggler's isolated-world debugger signature). The page's CSP then
105
+ # applies, so on CSP-strict sites (e.g. Reddit, which forbids
106
+ # unsafe-eval) every page.evaluate fails with "call to eval() blocked
107
+ # by CSP". This is invisible_playwright-specific — Camoufox keeps
108
+ # Juggler's CSP-free isolated-world + MainWorldContext.executeInGlobal
109
+ # paths, so camoufox-py read-only evals bypass CSP and need no quirk.
110
+ # The flag makes do_evaluate(read_only=True) read the EXTRACTOR_SCRIPT
111
+ # result from a <meta> tag that an add_init_script (isolated world,
112
+ # CSP-free) stashes at DOMContentLoaded. See the base quirk docstring.
113
+ _csp_safe_readonly_via_init_script: bool = True
114
+ # invisible_playwright's patched Firefox binary also routes write evals
115
+ # like window.scrollBy through eval() in the page's main world, hitting
116
+ # CSP on strict sites. page.mouse.wheel drives scroll via input events
117
+ # instead — CSP-free. Same fix as camoufox-py.
118
+ _scroll_via_wheel: bool = True
119
+ # The patched Firefox binary doesn't fire `networkidle` reliably; waiting for
120
+ # it in `do_go_back` / `_wait_for_page_ready` either times out (30s) or
121
+ # loiters in the Playwright sync greenlet long enough to deadlock the
122
+ # Juggler driver when a subsequent BrowserContext's `new_page()` is
123
+ # created. Match `do_navigate`'s load-based settle instead.
124
+ _skip_networkidle: bool = True
125
+ # NOTE: UA capture is intentionally DISABLED.
126
+ # `_capture_ua()` probes the UA via a throwaway `self._browser.new_page()`,
127
+ # which creates an implicit BrowserContext that is never closed. On the
128
+ # patched Firefox binary, that leftover context reliably deadlocks the
129
+ # Juggler driver when a subsequent (second) BrowserContext's `new_page()`
130
+ # is created — the second `browser.navigate` for a different task hangs
131
+ # forever inside the Playwright sync greenlet.
132
+ # It is also pointless here: with `_fingerprint_managed_context = True`,
133
+ # `create_browser_context()` does NOT pass `user_agent` to `new_context`
134
+ # (the fingerprint package sets it), so `effective_user_agent` is never
135
+ # read. The base's `_user_agent` fallback is similarly unused.
136
+ _capture_user_agent: bool = False
137
+ _install_hint: str = (
138
+ "invisible-playwright browser not installed.\n"
139
+ "Run the following commands in your invisible-py virtual environment:\n"
140
+ " pip install invisible-playwright\n"
141
+ " python -m invisible_playwright fetch\n"
142
+ "On Linux, also install xvfb:\n"
143
+ " apt-get install xvfb"
144
+ )
145
+
146
+ #: The ``InvisiblePlaywright`` context manager instance, held for the
147
+ #: bridge's lifetime. ``None`` before the first call to
148
+ #: ``_ensure_playwright()``.
149
+ _stealth_ctx = None
150
+
151
+ def _ensure_playwright(self): # type: ignore[override]
152
+ """Override base lifecycle to delegate to InvisiblePlaywright.
153
+
154
+ Instead of calling ``sync_playwright().start()`` +
155
+ ``_launch_browser()``, we instantiate ``InvisiblePlaywright`` from
156
+ the plugin config and enter the context manager, which returns a
157
+ patched Playwright Browser. The Playwright handle is read from
158
+ ``self._stealth_ctx._pw``.
159
+
160
+ Returns:
161
+ ``(pw, browser)`` — the Playwright instance and Browser.
162
+ """
163
+ if self._pw is None:
164
+ # Lazy import invisible_playwright — gives better error messages
165
+ # and avoids import cost when this bridge is disabled.
166
+ try:
167
+ from invisible_playwright import InvisiblePlaywright # type: ignore[import-unresolved]
168
+ except ImportError as exc:
169
+ raise RuntimeError(self._install_hint) from exc
170
+
171
+ # Read launch options from browser.init plugin config
172
+ launch = self.plugin_config.get("launch", {}) or {}
173
+
174
+ # Map camelCase keys from TypeScript settings to snake_case
175
+ # Python kwargs. Only forward keys that
176
+ # invisible_playwright.InvisiblePlaywright accepts.
177
+ kwargs: dict[str, object] = {
178
+ # Default to headless=True for server agents (no X display needed).
179
+ # invisible_playwright's ``headless=True`` uses Xvfb to hide the
180
+ # window while keeping the real rendering pipeline (coherent
181
+ # fingerprint). Users can override via ``headless=False`` for
182
+ # local headed debugging or ``headless='virtual'`` for the
183
+ # PyVirtualDisplay-based approach.
184
+ "headless": launch.get("headless", True),
185
+ }
186
+ _KEY_MAP: dict[str, str] = {
187
+ "seed": "seed",
188
+ "proxy": "proxy",
189
+ "humanize": "humanize",
190
+ "locale": "locale",
191
+ "timezone": "timezone",
192
+ "extraPrefs": "extra_prefs",
193
+ "binaryPath": "binary_path",
194
+ "prepRecaptcha": "prep_recaptcha",
195
+ "pin": "pin",
196
+ }
197
+ for ts_key, py_key in _KEY_MAP.items():
198
+ if ts_key in launch:
199
+ kwargs[py_key] = launch[ts_key]
200
+
201
+ self._stealth_ctx = InvisiblePlaywright(**kwargs)
202
+ try:
203
+ # __enter__() returns a Browser (not a BrowserContext when
204
+ # profile_dir is omitted — which is what we want).
205
+ self._browser = self._stealth_ctx.__enter__()
206
+ except Exception as _exc:
207
+ if re.search(
208
+ r"Executable doesn't exist|browserType\.launch",
209
+ str(_exc),
210
+ re.IGNORECASE,
211
+ ):
212
+ raise RuntimeError(self._install_hint) from _exc
213
+ raise
214
+
215
+ # Read the Playwright handle from the InvisiblePlaywright instance.
216
+ # InvisiblePlaywright owns sync_playwright(); the Browser it
217
+ # returns does not expose _pw.
218
+ self._pw = self._stealth_ctx._pw
219
+
220
+ # Probe UA at first launch
221
+ if self._capture_user_agent:
222
+ self._capture_ua()
223
+ return self._pw, self._browser
224
+
225
+ def create_browser_context(self, config: dict[str, Any]) -> Any:
226
+ """Override base context creation to neutralize invisible_playwright's
227
+ injected ``new_context`` defaults.
228
+
229
+ Why the base's ``_skip_default_viewport`` flag is NOT enough:
230
+ invisible_playwright (lifecycle pattern #2) patches
231
+ ``browser.new_context`` to merge in ``viewport`` + ``screen`` +
232
+ ``device_scale_factor`` defaults derived from the spoofed fingerprint
233
+ — unlike Camoufox, which injects the fingerprint at browser *launch*
234
+ via ``NewBrowser`` and leaves ``new_context`` vanilla. The base's
235
+ ``_skip_default_viewport`` path passes only ``no_viewport=True``, which
236
+ works for Camoufox (vanilla ``new_context``) but fails here because the
237
+ patched ``new_context`` injects ``viewport`` under a *different* key, so
238
+ ``no_viewport`` cannot override it — Playwright still issues
239
+ ``Browser.setDefaultViewport`` and the patched Firefox binary rejects
240
+ the ``screenSize`` field it includes.
241
+
242
+ Verified empirically against the real patched binary, the only
243
+ combination that works is to override ALL THREE injected defaults to
244
+ ``None`` AND pass ``no_viewport=True``:
245
+ * ``viewport=None`` → Playwright sees ``viewport === null`` → sets
246
+ ``noDefaultViewport=True`` → the ``setDefaultViewport`` CDP call is
247
+ skipped entirely (its guard is ``if (!viewport) return``).
248
+ * ``device_scale_factor=None`` → clears the injected default that
249
+ otherwise triggers Playwright's "deviceScaleFactor not supported
250
+ with null viewport" validation.
251
+ * ``screen=None`` → clears the remaining injected default.
252
+
253
+ The fingerprint still manages viewport/screen/dpr *inside* the browser
254
+ process (the patched binary spoofs screen at the window level); we are
255
+ only stopping Playwright from re-sending them via the CDP call the
256
+ binary cannot handle — functionally the same as Camoufox's
257
+ ``no_viewport=True`` path, just requiring the extra None overrides
258
+ because of the ``new_context``-time injection.
259
+ """
260
+ _pw, browser = self._ensure_playwright()
261
+
262
+ context_kwargs: dict[str, Any] = {
263
+ "no_viewport": True,
264
+ # Override the three defaults invisible_playwright's patched
265
+ # new_context injects — each must be None, or no_viewport is
266
+ # either ignored (viewport) or rejected (device_scale_factor).
267
+ "viewport": None,
268
+ "screen": None,
269
+ "device_scale_factor": None,
270
+ }
271
+ storage_state = config.get("storageState")
272
+ if storage_state is not None:
273
+ context_kwargs["storage_state"] = storage_state
274
+
275
+ context = browser.new_context(**context_kwargs)
276
+
277
+ # Start Playwright trace capture if BROWSER_TRACE_DIR is set.
278
+ _trace_dir = os.environ.get("BROWSER_TRACE_DIR")
279
+ if _trace_dir:
280
+ try:
281
+ context.tracing.start(
282
+ screenshots=True,
283
+ snapshots=True,
284
+ sources=True,
285
+ )
286
+ self._log("tracing", taskId=config.get("_task_id", "shared"),
287
+ action="start", dir=_trace_dir)
288
+ except Exception:
289
+ pass # Best-effort
290
+
291
+ # CSP-safe read-only eval path — register the EXTRACTOR_SCRIPT as an
292
+ # init script (isolated world, CSP-free) that stashes its result in a
293
+ # <meta> tag at DOMContentLoaded. See _csp_safe_readonly_via_init_script.
294
+ self._register_readonly_extractor_init_script(context)
295
+
296
+ return context
297
+
298
+ def _maybe_stop_playwright(self) -> None:
299
+ """Stop the InvisiblePlaywright when no sessions remain.
300
+
301
+ Calls ``self._stealth_ctx.__exit__(None, None, None)`` instead of
302
+ ``self._pw.stop()`` so the context manager properly cleans up its
303
+ own Playwright instance and the patched Firefox binary.
304
+ """
305
+ if not self.sessions and self._stealth_ctx is not None:
306
+ try:
307
+ if self._browser:
308
+ self._browser.close()
309
+ except Exception:
310
+ pass
311
+ try:
312
+ self._stealth_ctx.__exit__(None, None, None)
313
+ except Exception:
314
+ pass
315
+ self._pw = None
316
+ self._browser = None
317
+ self._stealth_ctx = None
318
+
319
+ def do_go_back(self, task_id: str) -> dict[str, Any]:
320
+ """Navigate back — workaround for the patched Firefox binary's broken
321
+ history navigation.
322
+
323
+ The patched Firefox binary does not support back/forward navigation:
324
+ ``page.go_back()`` times out for every ``wait_until`` value
325
+ (``networkidle`` / ``load`` / ``domcontentloaded`` / ``commit`` /
326
+ default) and the URL never changes. Standard Playwright Firefox
327
+ (the ``firefox-py`` backend) works in 0.01s — so this is a
328
+ patched-binary limitation, not a Playwright or bridge bug. Root
329
+ cause is the binary's ``disallowBFCache`` stealth attribute combined
330
+ with Fission and the about:newtab race.
331
+
332
+ Workaround: re-navigate to ``document.referrer`` via ``page.goto``.
333
+ This covers the dominant agent pattern (click a link → wrong way →
334
+ go back), where ``document.referrer`` is populated. It's a re-fetch
335
+ rather than a true history back, but the patched binary has bfcache
336
+ disabled anyway, so a true history back would re-render from scratch
337
+ too — functionally identical.
338
+
339
+ Falls through with ``success: false`` when ``document.referrer``
340
+ is empty (direct navigations, ``rel=noreferrer`` links, POST
341
+ submissions, multi-step back). This is an honest limitation — the
342
+ error message is explicit about what's not supported, so agents can
343
+ react appropriately.
344
+ """
345
+ _t_start = time.time()
346
+ try:
347
+ page = self._get_page(task_id)
348
+ except SessionNotFoundError:
349
+ raise
350
+ except Exception as exc:
351
+ self._log("goBack", taskId=task_id, success=False,
352
+ time=round((time.time() - _t_start) * 1000))
353
+ return {"success": False, "error": f"GoBack failed: {exc}"}
354
+
355
+ # Try the referrer workaround first.
356
+ referrer: str = ""
357
+ try:
358
+ referrer = page.evaluate("() => document.referrer") or ""
359
+ except Exception:
360
+ referrer = ""
361
+
362
+ if not referrer:
363
+ # No referrer → return an immediate error instead of timing out
364
+ # via ``super().do_go_back()`` (which calls ``page.go_back()`` —
365
+ # broken on this patched binary, always times out).
366
+ self._log("goBack", taskId=task_id, success=False,
367
+ reason="no referrer (fallback not supported on this binary)",
368
+ time=round((time.time() - _t_start) * 1000))
369
+ return {
370
+ "success": False,
371
+ "error": (
372
+ "GoBack not supported for direct/noreferrer navigations "
373
+ "on this patched Firefox binary (history navigation is "
374
+ "non-functional)"
375
+ ),
376
+ }
377
+
378
+ try:
379
+ page.goto(referrer, wait_until="load", timeout=15_000)
380
+
381
+ new_url: str = page.url
382
+ new_title: str = page.title()
383
+ snap_text, element_count, elements = self._take_snapshot_and_cache(
384
+ task_id, page
385
+ )
386
+ self._log("goBack", taskId=task_id, success=True,
387
+ elementCount=element_count, via="referrer",
388
+ time=round((time.time() - _t_start) * 1000))
389
+ return {
390
+ "success": True,
391
+ "snapshot": snap_text,
392
+ "elementCount": element_count,
393
+ "elements": elements,
394
+ "dialogEvents": self._get_dialog_events(task_id),
395
+ "newUrl": new_url,
396
+ "newTitle": new_title,
397
+ }
398
+ except Exception as exc:
399
+ self._log("goBack", taskId=task_id, success=False, via="referrer",
400
+ time=round((time.time() - _t_start) * 1000))
401
+ return {"success": False, "error": f"GoBack failed: {exc}"}
402
+
403
+
404
+ # ═══════════════════════════════════════════════════════════════════════
405
+ # Entry point
406
+ # ═══════════════════════════════════════════════════════════════════════
407
+
408
+ if __name__ == "__main__":
409
+ # The stealth engine bundles its own Playwright, so we check for the
410
+ # engine itself rather than the standard playwright package.
411
+ # Print a JSON-RPC error and exit immediately so the TypeScript
412
+ # PythonPluginAdapter gets a parseable error.
413
+ try:
414
+ import invisible_playwright # type: ignore[import-unresolved] # noqa: F401
415
+ except ImportError:
416
+ print(
417
+ json.dumps({
418
+ "jsonrpc": "2.0",
419
+ "id": None,
420
+ "error": {
421
+ "code": -32000,
422
+ "message": (
423
+ "invisible-playwright is not installed.\n"
424
+ "Run: pip install invisible-playwright && "
425
+ "python -m invisible_playwright fetch"
426
+ ),
427
+ },
428
+ })
429
+ )
430
+ sys.stdout.flush()
431
+ sys.exit(1)
432
+
433
+ bridge = InvisiblePyBridge()
434
+ bridge.run()
@@ -312,11 +312,6 @@ export function cleanupFetchTempFiles(taskId?: string): void {
312
312
  }
313
313
  }
314
314
  activeFetchFiles.clear();
315
- try {
316
- rmSync(BROWSER_TEMP_DIR, { recursive: true, force: true });
317
- } catch {
318
- /* best-effort */
319
- }
320
315
  }
321
316
  }
322
317
 
@@ -21,18 +21,8 @@ import type { AriaCachedNode } from "./shared/accessibility-tree.js";
21
21
  * screenshot when unsupported).
22
22
  */
23
23
  export interface PluginCapabilities {
24
- /** Can take full-page screenshots */
25
24
  supportsFullPageScreenshot: boolean;
26
- /** Can capture console messages via CDP or equivalent */
27
- supportsConsoleCapture: boolean;
28
- /** Can evaluate arbitrary JavaScript in the page */
29
25
  supportsJavaScriptEvaluate: boolean;
30
- /** Can detect bot/anti-automation signals (Cloudflare, CAPTCHA, etc.) */
31
- supportsBotDetection: boolean;
32
- /** Can auto-dismiss JS dialogs (alert/confirm/prompt) */
33
- supportsDialogAutoDismissal: boolean;
34
- /** Can accept an AbortSignal for long-running navigations */
35
- supportsAbortSignal: boolean;
36
26
  /** Browser engine (used for display/debugging only) */
37
27
  engine: "chromium" | "firefox" | "webkit" | string;
38
28
  }
@@ -40,11 +30,7 @@ export interface PluginCapabilities {
40
30
  /** Default capabilities matching a full-featured Chromium backend. */
41
31
  export const DEFAULT_CAPABILITIES: PluginCapabilities = {
42
32
  supportsFullPageScreenshot: true,
43
- supportsConsoleCapture: true,
44
33
  supportsJavaScriptEvaluate: true,
45
- supportsBotDetection: true,
46
- supportsDialogAutoDismissal: true,
47
- supportsAbortSignal: true,
48
34
  engine: "chromium",
49
35
  };
50
36
 
@@ -59,7 +45,6 @@ export const DEFAULT_CAPABILITIES: PluginCapabilities = {
59
45
  export interface DialogEvent {
60
46
  /** Dialog type as reported by the browser */
61
47
  type: string;
62
- /** Dialog message text */
63
48
  message: string;
64
49
  /** How the dialog was handled (always "accepted" for auto-dismiss) */
65
50
  handledAs: "accepted" | "dismissed";
@@ -76,13 +61,11 @@ export interface ResultBase {
76
61
  error?: string;
77
62
  }
78
63
 
79
- /** Result from browser-navigate */
80
64
  export interface NavigateResult extends ResultBase {
81
65
  url: string;
82
66
  title: string;
83
67
  /** Accessibility-tree text with @e refs */
84
68
  snapshot: string;
85
- /** Number of interactive elements found */
86
69
  elementCount: number;
87
70
  /** Plugin-internal signal: page may be blocked by bot detection */
88
71
  botDetected?: boolean;
@@ -90,17 +73,14 @@ export interface NavigateResult extends ResultBase {
90
73
  dialogDetected?: boolean;
91
74
  /** Auto-dismissed JavaScript dialogs (alert/confirm/prompt) since last navigate */
92
75
  dialogEvents?: DialogEvent[];
93
- /** The active profile mode for this session. */
94
76
  profileMode?: "none" | "session" | "named";
95
77
  /** Which profile was loaded for this session (if any) */
96
78
  profileName?: string;
97
79
  }
98
80
 
99
- /** Result from browser-snapshot */
100
81
  export interface SnapshotResult extends ResultBase {
101
82
  /** Accessibility-tree text with @e refs */
102
83
  snapshot: string;
103
- /** Number of interactive elements found */
104
84
  elementCount: number;
105
85
  /** Auto-dismissed JavaScript dialogs (alert/confirm/prompt) since last snapshot */
106
86
  dialogEvents?: DialogEvent[];
@@ -114,19 +94,16 @@ export interface InteractionResult extends ResultBase {
114
94
  newTitle?: string;
115
95
  /** Auto-captured snapshot after the interaction */
116
96
  snapshot?: string;
117
- /** Number of interactive elements in the auto-snapshot */
118
97
  elementCount?: number;
119
98
  /** Auto-dismissed JavaScript dialogs since the interaction */
120
99
  dialogEvents?: DialogEvent[];
121
100
  }
122
101
 
123
- /** Result from plugin.screenshot() */
124
102
  export interface ScreenshotResult extends ResultBase {
125
103
  /** JPEG data URI of the screenshot */
126
104
  dataUri: string;
127
105
  }
128
106
 
129
- /** Result from getConsoleMessages */
130
107
  export interface ConsoleMessagesResult extends ResultBase {
131
108
  messages: Array<{ type: string; text: string }>;
132
109
  }
@@ -157,7 +134,6 @@ export interface Cookie {
157
134
  sameSite?: "Strict" | "Lax" | "None";
158
135
  }
159
136
 
160
- /** Result from browser-getCookies */
161
137
  export interface CookieResult extends ResultBase {
162
138
  cookies: Cookie[];
163
139
  }
@@ -169,7 +145,6 @@ export interface ClearCookiesOptions {
169
145
  path?: string;
170
146
  }
171
147
 
172
- /** Result from browser-getStorageState */
173
148
  export interface StorageStateResult extends ResultBase {
174
149
  cookies: Cookie[];
175
150
  origins: Array<{
@@ -183,7 +158,7 @@ export interface StorageStateResult extends ResultBase {
183
158
  /**
184
159
  * The contract every interactive browser backend must implement.
185
160
  *
186
- * The 12 required operations (plus getElementCache and cookie/storage
161
+ * The 18 required operations (plus getElementCache and cookie/storage
187
162
  * methods) make up the full contract. Lifecycle hooks are called by
188
163
  * the framework, not the agent.
189
164
  */
@@ -231,9 +206,6 @@ export interface BrowserPlugin {
231
206
 
232
207
  // ── Cookies & storage state ────────────────────────────────
233
208
 
234
- /**
235
- * Get all browser cookies for the session, optionally filtered by URL.
236
- */
237
209
  getCookies(taskId: string, urls?: string[]): Promise<CookieResult>;
238
210
 
239
211
  /**
@@ -282,7 +254,11 @@ export interface BrowserPlugin {
282
254
 
283
255
  clearConsole(taskId: string): Promise<void>;
284
256
 
285
- evaluate(taskId: string, expression: string): Promise<EvaluateResult>;
257
+ evaluate(
258
+ taskId: string,
259
+ expression: string,
260
+ readOnly?: boolean,
261
+ ): Promise<EvaluateResult>;
286
262
 
287
263
  // ── Element cache access ─────────────────────────────────
288
264
 
@@ -295,8 +271,5 @@ export interface BrowserPlugin {
295
271
 
296
272
  // ── Per-task cleanup ──────────────────────────────────────
297
273
 
298
- /**
299
- * Clean up resources for a specific task (browser context, page, etc.).
300
- */
301
274
  cleanup(taskId: string): Promise<void>;
302
275
  }