pi-lean-dimension 0.1.0 → 0.2.1

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
@@ -1,33 +1,16 @@
1
1
  /**
2
2
  * PythonPluginAdapter — TypeScript side of the Python bridge protocol.
3
- *
4
- * Implements `BrowserPlugin` by spawning a Python subprocess and
5
- * communicating via JSON-RPC 2.0 over stdin/stdout with newline-delimited
6
- * framing.
7
- *
8
- * Lifecycle
9
- * ---------
10
- * 1. Constructed with a plugin name and `PythonBridgeConfig`.
11
- * 2. `init()` validates paths — no spawn yet (lazy start).
12
- * 3. First operation triggers `ensureRunning()`, which spawns the Python
13
- * process and waits for a `ping` handshake.
14
- * 4. Operations send JSON-RPC requests, the Python bridge handles them.
15
- * 5. `cleanupAll()` sends `shutdown`, waits for graceful exit, force-kills
16
- * if unresponsive.
17
- * 6. Process crashes are detected and auto-restart on the next call.
18
- *
19
- * Thread safety: operations are serialised through the single stdin/stdout
20
- * channel. The Python bridge processes one request at a time.
21
- *
22
- * @module
3
+ * Implements BrowserPlugin via JSON-RPC 2.0 over stdin/stdout.
23
4
  */
24
5
 
25
6
  import { spawn, spawnSync } from "node:child_process";
26
7
  import type { ChildProcess } from "node:child_process";
27
8
  import { existsSync } from "node:fs";
9
+ import { join, delimiter as pathDelimiter } from "node:path";
28
10
 
29
11
  import { sessionManager } from "../core/shared/session-manager.js";
30
- import { saveStorageState } from "../core/shared/storage-state.js";
12
+ import { persistSessionState } from "../core/shared/storage-state.js";
13
+ import { DEFAULT_BACKENDS_ROOT } from "../core/plugin-config.js";
31
14
 
32
15
  import {
33
16
  DEFAULT_CAPABILITIES,
@@ -47,13 +30,12 @@ import {
47
30
  type ResultBase,
48
31
  } from "../core/plugin-api.js";
49
32
  import type { AriaCachedNode } from "../core/shared/accessibility-tree.js";
33
+ import { EXTRACTOR_SCRIPT } from "../core/shared/dom-extractor.js";
50
34
 
51
35
  // ─── Constants ─────────────────────────────────────────────────────────
52
36
 
53
- /** Default timeout for the JSON-RPC transport layer (milliseconds). */
54
37
  const DEFAULT_TRANSPORT_TIMEOUT_MS = 60_000;
55
38
 
56
- /** Default timeout for the `ping` handshake on startup. */
57
39
  const PING_TIMEOUT_MS = 10_000;
58
40
 
59
41
  /** Grace period after sending `shutdown` before force-killing. */
@@ -107,8 +89,7 @@ export interface PythonBridgeConfig {
107
89
  pythonArgs?: string[];
108
90
 
109
91
  /**
110
- * Advertised capabilities overrides. Defaults to a Python-capable
111
- * Chromium profile (everything except AbortSignal support).
92
+ * Advertised capabilities overrides. Defaults to DEFAULT_CAPABILITIES.
112
93
  */
113
94
  capabilities?: Partial<PluginCapabilities>;
114
95
 
@@ -122,24 +103,6 @@ export interface PythonBridgeConfig {
122
103
  transportTimeoutMs?: number;
123
104
  }
124
105
 
125
- // ─── Default capabilities ─────────────────────────────────────────────
126
-
127
- /**
128
- * Default capabilities for a Python-based Chromium bridge.
129
- *
130
- * - Full-page screenshots: yes (Playwright supports it)
131
- * - Console capture: yes (page.on("console"))
132
- * - JavaScript evaluate: yes (page.evaluate)
133
- * - Bot detection: yes (heuristics via checkPage logic)
134
- * - Dialog auto-dismissal: yes (page.on("dialog"))
135
- * - AbortSignal: no (JSON-RPC transport doesn't support it natively)
136
- * - Engine: chromium
137
- */
138
- const DEFAULT_PYTHON_CAPABILITIES: PluginCapabilities = {
139
- ...DEFAULT_CAPABILITIES,
140
- supportsAbortSignal: false,
141
- };
142
-
143
106
  // ─── Pending request type ─────────────────────────────────────────────
144
107
 
145
108
  interface PendingRequest {
@@ -148,6 +111,37 @@ interface PendingRequest {
148
111
  timer: ReturnType<typeof setTimeout>;
149
112
  }
150
113
 
114
+ // ─── Quirks Descriptor ────────────────────────────────────────────────
115
+
116
+ /**
117
+ * Quirks flags declared by a Python bridge at runtime, read via the
118
+ * ``browser.describeQuirks`` introspection RPC.
119
+ *
120
+ * Each field corresponds to a class attribute on
121
+ * ``PlaywrightBridge`` (defaults shown). Subclasses override these
122
+ * to signal engine-specific behavior to the TypeScript runner, which
123
+ * uses ``skipIf`` to only run tests that apply to the declared quirks.
124
+ *
125
+ * Also extends ResultBase so the adapter method can use
126
+ * ``_rpcCallTyped`` (which requires ``{ success: boolean }``).
127
+ */
128
+ export interface QuirksDescriptor extends ResultBase {
129
+ /** Bridge owns fingerprint management (viewport, UA). */
130
+ fingerprint_managed_context: boolean;
131
+ /** Prefix prepended to ``page.evaluate`` expressions (e.g. ``"mw:"``). */
132
+ eval_prefix: string;
133
+ /** ``scroll()`` uses ``page.mouse.wheel`` instead of ``window.scrollBy``. */
134
+ scroll_via_wheel: boolean;
135
+ /** ``create_browser_context`` passes ``no_viewport=True``. */
136
+ skip_default_viewport: boolean;
137
+ /** Navigation uses ``load`` instead of ``networkidle``. */
138
+ skip_networkidle: boolean;
139
+ /** ``do_evaluate`` wraps the script in ``eval(<json>)`` to survive main-world wrapper. */
140
+ wrap_mw_eval_in_eval: boolean;
141
+ /** CSP-safe read-only ``do_evaluate`` via init-script (patched Firefox stealth). */
142
+ csp_safe_readonly_via_init_script: boolean;
143
+ }
144
+
151
145
  // ─── PythonPluginAdapter ──────────────────────────────────────────────
152
146
 
153
147
  /**
@@ -180,6 +174,15 @@ export class PythonPluginAdapter implements BrowserPlugin {
180
174
  private _startupPromise: Promise<void> | null = null;
181
175
  private _started = false;
182
176
 
177
+ /**
178
+ * Plugin config dict forwarded to the bridge via the `browser.init` RPC
179
+ * immediately after the ping handshake. Populated by `init()` from the
180
+ * user's `settings.json` `config.config` object (which carries the
181
+ * `launch` sub-object for stealth backends). Empty when no config was
182
+ * provided — the bridge defaults `_plugin_config` to `{}` either way.
183
+ */
184
+ private _pluginInitConfig: Record<string, unknown> | undefined;
185
+
183
186
  /**
184
187
  * Local element caches per task, populated from bridge responses.
185
188
  * Map: taskId → Map<ref, AriaCachedNode>
@@ -224,7 +227,7 @@ export class PythonPluginAdapter implements BrowserPlugin {
224
227
 
225
228
  // Merge capabilities
226
229
  this.capabilities = {
227
- ...DEFAULT_PYTHON_CAPABILITIES,
230
+ ...DEFAULT_CAPABILITIES,
228
231
  ...config.capabilities,
229
232
  };
230
233
  }
@@ -236,8 +239,17 @@ export class PythonPluginAdapter implements BrowserPlugin {
236
239
  /**
237
240
  * Initialise the adapter. Validates that the Python path and bridge
238
241
  * script are available, but does NOT spawn the subprocess yet.
242
+ *
243
+ * Stores the supplied plugin config dict (the user's `config.config`
244
+ * from `settings.json`) so it can be forwarded to the bridge via the
245
+ * `browser.init` RPC immediately after the ping handshake. This is the
246
+ * channel that carries stealth launch options (`config.launch`) to the
247
+ * Python-side bridge.
239
248
  */
240
- async init(_config?: Record<string, unknown>): Promise<void> {
249
+ async init(config?: Record<string, unknown>): Promise<void> {
250
+ // Stash the plugin config for the post-ping browser.init RPC.
251
+ this._pluginInitConfig = config;
252
+
241
253
  // Validate pythonPath exists
242
254
  const pythonOk = this._checkPythonExists();
243
255
  if (!pythonOk) {
@@ -307,6 +319,13 @@ export class PythonPluginAdapter implements BrowserPlugin {
307
319
  env: {
308
320
  ...process.env,
309
321
  PYTHONUNBUFFERED: "1",
322
+ // Phase 0b: make the shared `pi_browser_bridge` library
323
+ // importable from any venv the user points `pythonPath`
324
+ // at, without requiring a `pip install` of the bridge.
325
+ // Appended to any existing PYTHONPATH so user entries keep
326
+ // precedence; for the shipped chromium-py / firefox-py
327
+ // venvs the editable install points at the same source.
328
+ PYTHONPATH: this._buildPythonPath(),
310
329
  },
311
330
  },
312
331
  );
@@ -395,17 +414,48 @@ export class PythonPluginAdapter implements BrowserPlugin {
395
414
  setImmediate(async () => {
396
415
  try {
397
416
  await this._directRpcCall("ping", {}, PING_TIMEOUT_MS);
417
+
418
+ // ── Forward plugin config to the bridge (Phase 0) ──
419
+ // Sent exactly once, immediately after the ping handshake and
420
+ // before any other RPC, so stealth subclasses can read launch
421
+ // options from `self.plugin_config.get("launch", {})` at
422
+ // browser-init time. A bridge too old to know this method
423
+ // returns METHOD_NOT_FOUND, which we surface as a clear error.
424
+ await this._directRpcCall(
425
+ "browser.init",
426
+ {
427
+ config: {
428
+ ...(this._pluginInitConfig ?? {}),
429
+ // Plumbing for the CSP-safe read-only eval path (see
430
+ // PlaywrightBridge._csp_safe_readonly_via_init_script).
431
+ // Backends that don't opt into the quirk ignore this key;
432
+ // a stealth backend that flips the quirk registers it as
433
+ // an add_init_script so its EXTRACTOR_SCRIPT result can
434
+ // be read without page.evaluate (CSP-blocked on patched-
435
+ // Firefox binaries that route eval through the main
436
+ // world). Sent for every Python backend so any stealth
437
+ // backend can opt in by flipping the quirk.
438
+ readOnlyExtractorScript: EXTRACTOR_SCRIPT,
439
+ },
440
+ },
441
+ PING_TIMEOUT_MS,
442
+ );
443
+
398
444
  this._started = true;
399
445
  resolve();
400
446
  } catch (err: unknown) {
401
447
  // If the process already exited, the exit handler will reject.
402
- // Only reject here if the process is still alive but ping failed.
448
+ // Only reject here if the process is still alive but ping/init failed.
403
449
  if (this._process && this._exitCode === null) {
404
- // Ping failed — kill and reject
450
+ // Ping/init failed — kill and reject
405
451
  this._killProcess();
452
+ const stage =
453
+ err instanceof PythonBridgeError && err.code === -32601
454
+ ? "browser.init (bridge too old — upgrade pi-lean-portal)"
455
+ : "Ping handshake";
406
456
  reject(
407
457
  new Error(
408
- `PythonPluginAdapter('${this.name}'): Ping handshake failed: ` +
458
+ `PythonPluginAdapter('${this.name}'): ${stage} failed: ` +
409
459
  (err instanceof Error ? err.message : String(err)),
410
460
  ),
411
461
  );
@@ -584,9 +634,6 @@ export class PythonPluginAdapter implements BrowserPlugin {
584
634
  });
585
635
  }
586
636
 
587
- /**
588
- * Flush buffered stdout data and process complete lines.
589
- */
590
637
  private _flushBuffer(): void {
591
638
  const lines = this._buffer.split("\n");
592
639
  // Keep the last (possibly incomplete) segment in the buffer
@@ -599,9 +646,6 @@ export class PythonPluginAdapter implements BrowserPlugin {
599
646
  }
600
647
  }
601
648
 
602
- /**
603
- * Handle a single complete JSON-RPC response line from stdout.
604
- */
605
649
  private _handleResponseLine(line: string): void {
606
650
  let response: {
607
651
  id?: unknown;
@@ -656,8 +700,7 @@ export class PythonPluginAdapter implements BrowserPlugin {
656
700
  profileMode?: "none" | "session" | "named";
657
701
  },
658
702
  ): Promise<NavigateResult> {
659
- // We don't use the AbortSignal directly (supportsAbortSignal: false),
660
- // but we accept it for interface compatibility.
703
+ // AbortSignal not wired through JSON-RPC; accepted for interface compatibility.
661
704
 
662
705
  try {
663
706
  // Build RPC params — include storageState and profileName if provided
@@ -869,10 +912,16 @@ export class PythonPluginAdapter implements BrowserPlugin {
869
912
  }
870
913
  }
871
914
 
872
- async evaluate(taskId: string, expression: string): Promise<EvaluateResult> {
915
+ async evaluate(
916
+ taskId: string,
917
+ expression: string,
918
+ readOnly?: boolean,
919
+ ): Promise<EvaluateResult> {
873
920
  return this._rpcCallTyped(
874
921
  "browser.evaluate",
875
- { taskId, expression },
922
+ readOnly === undefined
923
+ ? { taskId, expression }
924
+ : { taskId, expression, readOnly },
876
925
  (raw) => ({
877
926
  success: !!raw.success,
878
927
  result: raw.result,
@@ -882,6 +931,48 @@ export class PythonPluginAdapter implements BrowserPlugin {
882
931
  );
883
932
  }
884
933
 
934
+ // ═════════════════════════════════════════════════════════════════
935
+ // BrowserPlugin: Quirks introspection
936
+ // ═════════════════════════════════════════════════════════════════
937
+
938
+ /**
939
+ * Query the bridge for its declared quirks flags via the
940
+ * ``browser.describeQuirks`` introspection RPC.
941
+ *
942
+ * Returns the bridge's class-attribute quirks, or all-defaults on
943
+ * transport error (the runner uses ``skipIf`` so tests only run for
944
+ * quirks the bridge actually declares).
945
+ *
946
+ * @internal Only used by test helpers.
947
+ */
948
+ async describeQuirks(): Promise<QuirksDescriptor> {
949
+ return this._rpcCallTyped<QuirksDescriptor>(
950
+ "browser.describeQuirks",
951
+ {},
952
+ (raw): QuirksDescriptor => ({
953
+ success: true,
954
+ fingerprint_managed_context: !!raw.fingerprint_managed_context,
955
+ eval_prefix: (raw.eval_prefix as string) ?? "",
956
+ scroll_via_wheel: !!raw.scroll_via_wheel,
957
+ skip_default_viewport: !!raw.skip_default_viewport,
958
+ skip_networkidle: !!raw.skip_networkidle,
959
+ wrap_mw_eval_in_eval: !!raw.wrap_mw_eval_in_eval,
960
+ csp_safe_readonly_via_init_script: !!raw.csp_safe_readonly_via_init_script,
961
+ }),
962
+ (error): QuirksDescriptor => ({
963
+ success: false,
964
+ fingerprint_managed_context: false,
965
+ eval_prefix: "",
966
+ scroll_via_wheel: false,
967
+ skip_default_viewport: false,
968
+ skip_networkidle: false,
969
+ wrap_mw_eval_in_eval: false,
970
+ csp_safe_readonly_via_init_script: false,
971
+ error,
972
+ }),
973
+ );
974
+ }
975
+
885
976
  // ═════════════════════════════════════════════════════════════════
886
977
  // BrowserPlugin: Cookies & storage state
887
978
  // ═════════════════════════════════════════════════════════════════
@@ -1011,30 +1102,21 @@ export class PythonPluginAdapter implements BrowserPlugin {
1011
1102
  taskId: string,
1012
1103
  ): Promise<{ cookies: unknown[]; origins: unknown[] } | undefined> {
1013
1104
  const session = sessionManager.getSession(taskId);
1014
- if (!session?.persistState) return undefined;
1015
-
1016
- try {
1017
- const raw = await this._rpcCall("browser.getStorageState", { taskId });
1018
- const result = raw as Record<string, unknown>;
1019
- if (result.success) {
1020
- const name = session.profileName ?? "default";
1021
- const state = {
1105
+ return persistSessionState(
1106
+ session,
1107
+ async () => {
1108
+ const raw = await this._rpcCall("browser.getStorageState", { taskId });
1109
+ const result = raw as Record<string, unknown>;
1110
+ if (!result.success) {
1111
+ throw new Error("bridge.getStorageState returned failure");
1112
+ }
1113
+ return {
1022
1114
  cookies: (result.cookies ?? []) as Record<string, unknown>[],
1023
1115
  origins: (result.origins ?? []) as Record<string, unknown>[],
1024
1116
  };
1025
- saveStorageState(name, state);
1026
- return state;
1027
- }
1028
- return undefined;
1029
- } catch (err) {
1030
- console.warn(
1031
- `[pi-lean-portal] Failed to auto-save storage state for profile ` +
1032
- `'${session.profileName ?? "default"}' via Python bridge: ` +
1033
- `${err instanceof Error ? err.message : String(err)}. ` +
1034
- "Session state may be lost.",
1035
- );
1036
- return undefined;
1037
- }
1117
+ },
1118
+ "via Python bridge",
1119
+ );
1038
1120
  }
1039
1121
 
1040
1122
  /**
@@ -1105,9 +1187,6 @@ export class PythonPluginAdapter implements BrowserPlugin {
1105
1187
  }
1106
1188
  }
1107
1189
 
1108
- /**
1109
- * Check whether the configured Python interpreter exists in PATH.
1110
- */
1111
1190
  private _checkPythonExists(): boolean {
1112
1191
  try {
1113
1192
  const result = spawnSync(this._pythonPath, ["--version"], {
@@ -1121,8 +1200,28 @@ export class PythonPluginAdapter implements BrowserPlugin {
1121
1200
  }
1122
1201
 
1123
1202
  /**
1124
- * Convert a raw RPC result to an InteractionResult.
1203
+ * Build the `PYTHONPATH` env value for the spawned bridge process.
1204
+ *
1205
+ * Appends the package's `backends/python-base/` directory (home of the
1206
+ * shared `pi_browser_bridge` Python library) to any existing
1207
+ * `PYTHONPATH` so user-installed stealth backends running in their
1208
+ * own venvs can `from pi_browser_bridge.playwright_base import
1209
+ * PlaywrightBridge` without a separate `pip install` of the bridge
1210
+ * library. Appended (not prepended) so a user's own `PYTHONPATH`
1211
+ * entries keep precedence; for the shipped `chromium-py` / `firefox-py`
1212
+ * venvs the editable install resolves to the same source directory, so
1213
+ * ordering is moot either way.
1125
1214
  */
1215
+ private _buildPythonPath(): string {
1216
+ const pythonBaseDir = join(DEFAULT_BACKENDS_ROOT, "python-base");
1217
+ const existing = process.env.PYTHONPATH;
1218
+ return existing ? existing + pathDelimiter + pythonBaseDir : pythonBaseDir;
1219
+ }
1220
+
1221
+ // ═════════════════════════════════════════════════════════════════
1222
+ // Result conversion helpers
1223
+ // ═════════════════════════════════════════════════════════════════
1224
+
1126
1225
  private _toInteractionResult(raw: unknown): InteractionResult {
1127
1226
  const r = raw as Record<string, unknown>;
1128
1227
  const result: InteractionResult = {
@@ -1,47 +1,6 @@
1
1
  """
2
2
  pi_browser_bridge — Shared Python bridge library for pi-lean-portal.
3
-
4
- Provides the infrastructure needed to build Python browser automation
5
- backends that communicate with the TypeScript ``PythonPluginAdapter``
6
- via JSON-RPC 2.0 over stdin/stdout.
7
-
8
- Key components
9
- --------------
10
- * :class:`BrowserBridge` — base class; subclass and override
11
- ``create_browser_session()`` and operation methods.
12
- * :class:`PlaywrightBridge` — Playwright-specific base extracted from
13
- the Chromium reference; parameterizes engine, UA, and launch args.
14
- * :mod:`.transport` — JSON-RPC 2.0 transport over stdin/stdout.
15
- * :mod:`.accessibility` — Playwright accessibility snapshot parser,
16
- mirroring the TypeScript version.
17
- * :mod:`.bot_detection` — anti-automation / bot detection signal
18
- matcher, mirroring the TypeScript version.
19
-
20
- Shipped bridges
21
- ---------------
22
- * ``backends/chromium-py/bridge.py`` — ``ChromiumPyBridge``, a thin
23
- subclass of :class:`PlaywrightBridge` driving Chromium. Ships as the
24
- parity reference for Python Chromium-based stealth backends.
25
- * ``backends/firefox-py/bridge.py`` — ``FirefoxPyBridge``, a thin
26
- subclass driving Firefox. Ships as the parity reference for Python
27
- Firefox-based stealth backends.
28
-
29
- Quick start
30
- -----------
31
- ::
32
-
33
- from pi_browser_bridge import BrowserBridge
34
-
35
- class MyBridge(BrowserBridge):
36
- def create_browser_session(self, task_id, config):
37
- # launch browser, return session dict
38
- ...
39
- def do_navigate(self, task_id, url, timeout_ms):
40
- # navigate, return result dict
41
- ...
42
-
43
- if __name__ == "__main__":
44
- MyBridge().run()
3
+ Provides BrowserBridge and PlaywrightBridge base classes for Python browser backends.
45
4
  """
46
5
 
47
6
  from .bridge import BrowserBridge, SessionNotFoundError, InvalidParamsError