pi-lean-portal 0.1.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 (55) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +608 -0
  3. package/backends/chromium/index.ts +50 -0
  4. package/backends/chromium-py/bridge.py +67 -0
  5. package/backends/firefox/index.ts +60 -0
  6. package/backends/firefox-py/bridge.py +64 -0
  7. package/backends/playwright-base/playwright-plugin.ts +1294 -0
  8. package/backends/python-adapter.ts +1141 -0
  9. package/backends/python-base/pi_browser_bridge/__init__.py +71 -0
  10. package/backends/python-base/pi_browser_bridge/accessibility.py +408 -0
  11. package/backends/python-base/pi_browser_bridge/bot_detection.py +115 -0
  12. package/backends/python-base/pi_browser_bridge/bridge.py +598 -0
  13. package/backends/python-base/pi_browser_bridge/playwright_base.py +1222 -0
  14. package/backends/python-base/pi_browser_bridge/transport.py +167 -0
  15. package/backends/python-base/pyproject.toml +15 -0
  16. package/browser-cookies.ts +88 -0
  17. package/browser-profile.ts +260 -0
  18. package/browser-status.ts +84 -0
  19. package/browser-toggle.ts +527 -0
  20. package/core/fetch-backend.ts +466 -0
  21. package/core/guides.ts +467 -0
  22. package/core/plugin-api.ts +302 -0
  23. package/core/plugin-config.ts +388 -0
  24. package/core/plugin-registry.ts +263 -0
  25. package/core/router.ts +1186 -0
  26. package/core/shared/accessibility-tree.ts +408 -0
  27. package/core/shared/bot-detection.ts +187 -0
  28. package/core/shared/browser-events.ts +111 -0
  29. package/core/shared/dom-extractor.ts +550 -0
  30. package/core/shared/nav-settle.ts +187 -0
  31. package/core/shared/paths.ts +56 -0
  32. package/core/shared/session-manager.ts +258 -0
  33. package/core/shared/settings-reader.ts +63 -0
  34. package/core/shared/snapshot-cache.ts +231 -0
  35. package/core/shared/storage-state.ts +560 -0
  36. package/core/shared/task-id.ts +77 -0
  37. package/core/shared/url-safety.ts +164 -0
  38. package/index.ts +253 -0
  39. package/package.json +63 -0
  40. package/ship-manifest.test.ts +12 -0
  41. package/tools/browser-back.ts +50 -0
  42. package/tools/browser-click.ts +74 -0
  43. package/tools/browser-console.ts +160 -0
  44. package/tools/browser-inspect.ts +136 -0
  45. package/tools/browser-navigate.ts +254 -0
  46. package/tools/browser-press.ts +80 -0
  47. package/tools/browser-scroll.ts +56 -0
  48. package/tools/browser-snapshot.ts +90 -0
  49. package/tools/browser-type.ts +60 -0
  50. package/tools/index.ts +19 -0
  51. package/tools/utils.ts +157 -0
  52. package/tools/web-fetch.ts +147 -0
  53. package/tools/web-guide.ts +55 -0
  54. package/tools/web-learn.ts +128 -0
  55. package/verify-ship-manifest.ts +126 -0
@@ -0,0 +1,1141 @@
1
+ /**
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
23
+ */
24
+
25
+ import { spawn, spawnSync } from "node:child_process";
26
+ import type { ChildProcess } from "node:child_process";
27
+ import { existsSync } from "node:fs";
28
+
29
+ import { sessionManager } from "../core/shared/session-manager.js";
30
+ import { saveStorageState } from "../core/shared/storage-state.js";
31
+
32
+ import {
33
+ DEFAULT_CAPABILITIES,
34
+ type BrowserPlugin,
35
+ type PluginCapabilities,
36
+ type DialogEvent,
37
+ type NavigateResult,
38
+ type SnapshotResult,
39
+ type InteractionResult,
40
+ type ScreenshotResult,
41
+ type ConsoleMessagesResult,
42
+ type EvaluateResult,
43
+ type Cookie,
44
+ type CookieResult,
45
+ type ClearCookiesOptions,
46
+ type StorageStateResult,
47
+ type ResultBase,
48
+ } from "../core/plugin-api.js";
49
+ import type { AriaCachedNode } from "../core/shared/accessibility-tree.js";
50
+
51
+ // ─── Constants ─────────────────────────────────────────────────────────
52
+
53
+ /** Default timeout for the JSON-RPC transport layer (milliseconds). */
54
+ const DEFAULT_TRANSPORT_TIMEOUT_MS = 60_000;
55
+
56
+ /** Default timeout for the `ping` handshake on startup. */
57
+ const PING_TIMEOUT_MS = 10_000;
58
+
59
+ /** Grace period after sending `shutdown` before force-killing. */
60
+ const SHUTDOWN_GRACE_MS = 5_000;
61
+
62
+ /** Standard JSON-RPC error codes that we recognise. */
63
+ const ERROR_CODES = {
64
+ APPLICATION_ERROR: -32000,
65
+ TIMEOUT_ERROR: -32001,
66
+ SESSION_ERROR: -32002,
67
+ } as const;
68
+
69
+ // ─── Custom error ─────────────────────────────────────────────────────
70
+
71
+ /**
72
+ * Error thrown when the Python bridge returns a JSON-RPC error response
73
+ * or when a transport-level failure occurs.
74
+ */
75
+ export class PythonBridgeError extends Error {
76
+ /** JSON-RPC error code */
77
+ readonly code: number;
78
+ /** Python traceback string, if available */
79
+ readonly traceback?: string;
80
+
81
+ constructor(error: {
82
+ code: number;
83
+ message: string;
84
+ data?: { traceback?: string };
85
+ }) {
86
+ super(error.message);
87
+ this.name = "PythonBridgeError";
88
+ this.code = error.code;
89
+
90
+ if (error.data?.traceback) {
91
+ this.traceback = error.data.traceback;
92
+ }
93
+ }
94
+ }
95
+
96
+ // ─── Configuration ────────────────────────────────────────────────────
97
+
98
+ /** Configuration for a PythonPluginAdapter instance. */
99
+ export interface PythonBridgeConfig {
100
+ /** Absolute path to the Python bridge script (required). */
101
+ bridgeScript: string;
102
+
103
+ /** Python interpreter path (default: "python3"). */
104
+ pythonPath?: string;
105
+
106
+ /** Additional arguments to pass to the Python process. */
107
+ pythonArgs?: string[];
108
+
109
+ /**
110
+ * Advertised capabilities overrides. Defaults to a Python-capable
111
+ * Chromium profile (everything except AbortSignal support).
112
+ */
113
+ capabilities?: Partial<PluginCapabilities>;
114
+
115
+ /**
116
+ * JSON-RPC transport timeout in milliseconds (default: 60 000).
117
+ * This is the wall-clock limit for waiting for a response from the
118
+ * bridge. Operations that accept their own timeout (e.g. navigate)
119
+ * get `timeoutMs + 10s` as the transport timeout so the bridge has
120
+ * room to report the timeout itself.
121
+ */
122
+ transportTimeoutMs?: number;
123
+ }
124
+
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
+ // ─── Pending request type ─────────────────────────────────────────────
144
+
145
+ interface PendingRequest {
146
+ resolve: (value: unknown) => void;
147
+ reject: (reason: unknown) => void;
148
+ timer: ReturnType<typeof setTimeout>;
149
+ }
150
+
151
+ // ─── PythonPluginAdapter ──────────────────────────────────────────────
152
+
153
+ /**
154
+ * Adapts a Python browser backend (running the `BrowserBridge` protocol)
155
+ * to the TypeScript `BrowserPlugin` interface.
156
+ */
157
+ export class PythonPluginAdapter implements BrowserPlugin {
158
+ readonly name: string;
159
+ readonly capabilities: PluginCapabilities;
160
+
161
+ // ── Python subprocess ───────────────────────────────────────
162
+ private _process: ChildProcess | null = null;
163
+ private _exitCode: number | null = null; // null = still running
164
+
165
+ // ── Config ─────────────────────────────────────────────────
166
+ private readonly _pythonPath: string;
167
+ private readonly _bridgeScript: string;
168
+ private readonly _pythonArgs: readonly string[];
169
+ private readonly _transportTimeoutMs: number;
170
+
171
+ // ── Stderr capture ─────────────────────────────────────────
172
+ private _stderrAccumulated = "";
173
+
174
+ // ── JSON-RPC state ─────────────────────────────────────────
175
+ private _reqId = 0;
176
+ private _pending = new Map<number, PendingRequest>();
177
+ private _buffer = "";
178
+
179
+ // ── Startup lock ───────────────────────────────────────────
180
+ private _startupPromise: Promise<void> | null = null;
181
+ private _started = false;
182
+
183
+ /**
184
+ * Local element caches per task, populated from bridge responses.
185
+ * Map: taskId → Map<ref, AriaCachedNode>
186
+ */
187
+ private _elementCaches = new Map<string, Map<string, AriaCachedNode>>();
188
+
189
+ /**
190
+ * Per-taskId page metadata.
191
+ * Used to track profile names for storage state save on cleanup.
192
+ */
193
+ private _pages = new Map<
194
+ string,
195
+ {
196
+ profileName?: string;
197
+ }
198
+ >();
199
+
200
+ /**
201
+ * @param name Unique plugin identifier (e.g. "chromium-py").
202
+ * @param config Bridge configuration.
203
+ */
204
+ constructor(name: string, config: PythonBridgeConfig) {
205
+ this.name = name;
206
+
207
+ // Validate bridge script exists
208
+ if (!config.bridgeScript) {
209
+ throw new Error(
210
+ `PythonPluginAdapter('${name}'): bridgeScript is required`,
211
+ );
212
+ }
213
+ if (!existsSync(config.bridgeScript)) {
214
+ throw new Error(
215
+ `PythonPluginAdapter('${name}'): bridge script not found: ${config.bridgeScript}`,
216
+ );
217
+ }
218
+
219
+ this._bridgeScript = config.bridgeScript;
220
+ this._pythonPath = config.pythonPath ?? "python3";
221
+ this._pythonArgs = config.pythonArgs ?? [];
222
+ this._transportTimeoutMs =
223
+ config.transportTimeoutMs ?? DEFAULT_TRANSPORT_TIMEOUT_MS;
224
+
225
+ // Merge capabilities
226
+ this.capabilities = {
227
+ ...DEFAULT_PYTHON_CAPABILITIES,
228
+ ...config.capabilities,
229
+ };
230
+ }
231
+
232
+ // ═════════════════════════════════════════════════════════════════
233
+ // Lifecycle hooks
234
+ // ═════════════════════════════════════════════════════════════════
235
+
236
+ /**
237
+ * Initialise the adapter. Validates that the Python path and bridge
238
+ * script are available, but does NOT spawn the subprocess yet.
239
+ */
240
+ async init(_config?: Record<string, unknown>): Promise<void> {
241
+ // Validate pythonPath exists
242
+ const pythonOk = this._checkPythonExists();
243
+ if (!pythonOk) {
244
+ console.warn(
245
+ `[pi-lean-portal] PythonPluginAdapter('${this.name}'): ` +
246
+ `Python interpreter '${this._pythonPath}' not found in PATH. ` +
247
+ `Will retry on first use.`,
248
+ );
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Clean up ALL resources. Closes all pages, then sends ``shutdown`` to the bridge.
254
+ */
255
+ async cleanupAll(): Promise<void> {
256
+ for (const taskId of [...this._pages.keys()]) {
257
+ await this.cleanup(taskId).catch(() => {});
258
+ }
259
+ await this._stopProcess();
260
+ }
261
+
262
+ // ═════════════════════════════════════════════════════════════════
263
+ // Process management
264
+ // ═════════════════════════════════════════════════════════════════
265
+
266
+ /**
267
+ * Ensure the Python subprocess is running.
268
+ *
269
+ * If the process has exited or was never started, spawn a new one
270
+ * and perform a `ping` handshake. Uses a lock to prevent concurrent
271
+ * starts.
272
+ */
273
+ private async ensureRunning(): Promise<void> {
274
+ // Fast path: process is alive
275
+ if (this._process && this._exitCode === null) return;
276
+
277
+ // Wait for an in-progress startup
278
+ if (this._startupPromise) {
279
+ await this._startupPromise;
280
+ return;
281
+ }
282
+
283
+ this._startupPromise = this._startProcess();
284
+ try {
285
+ await this._startupPromise;
286
+ } finally {
287
+ this._startupPromise = null;
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Spawn the Python subprocess and perform a ping handshake.
293
+ */
294
+ private _startProcess(): Promise<void> {
295
+ return new Promise<void>((resolve, reject) => {
296
+ this._buffer = "";
297
+ this._exitCode = null;
298
+ this._pending.clear();
299
+ this._started = false;
300
+ this._stderrAccumulated = "";
301
+
302
+ const proc = spawn(
303
+ this._pythonPath,
304
+ [this._bridgeScript, ...this._pythonArgs],
305
+ {
306
+ stdio: ["pipe", "pipe", "pipe"],
307
+ env: {
308
+ ...process.env,
309
+ PYTHONUNBUFFERED: "1",
310
+ },
311
+ },
312
+ );
313
+
314
+ this._process = proc;
315
+
316
+ // ── stdout reader ────────────────────────────────
317
+ const stdout = proc.stdout;
318
+ if (stdout) {
319
+ stdout.on("data", (chunk: Buffer) => {
320
+ this._buffer += chunk.toString();
321
+ this._flushBuffer();
322
+ });
323
+
324
+ stdout.on("end", () => {
325
+ // Flush any remaining data in the buffer
326
+ if (this._buffer.trim()) {
327
+ this._flushBuffer();
328
+ }
329
+ });
330
+ }
331
+
332
+ // ── stderr capture (member variable for inclusion in all errors) ─
333
+ const stderr = proc.stderr;
334
+ if (stderr) {
335
+ stderr.on("data", (chunk: Buffer) => {
336
+ this._stderrAccumulated += chunk.toString();
337
+ });
338
+ }
339
+
340
+ // ── process events ───────────────────────────────
341
+ const onError = (err: Error) => {
342
+ if (!this._started) {
343
+ reject(
344
+ new Error(
345
+ `PythonPluginAdapter('${this.name}'): Failed to spawn process: ${err.message}`,
346
+ ),
347
+ );
348
+ }
349
+ };
350
+
351
+ const onExit = (code: number | null, _signal: string | null) => {
352
+ this._exitCode = code;
353
+ this._process = null;
354
+
355
+ // Build stderr guidance for error messages
356
+ const stderrSuffix = this._stderrAccumulated
357
+ ? `\nstderr:\n${this._stderrAccumulated}`
358
+ : "";
359
+
360
+ // Reject all pending requests
361
+ const pendingSnapshot = new Map(this._pending);
362
+ this._pending.clear();
363
+
364
+ for (const [, pending] of pendingSnapshot) {
365
+ clearTimeout(pending.timer);
366
+ const reason = new PythonBridgeError({
367
+ code: ERROR_CODES.APPLICATION_ERROR,
368
+ message:
369
+ `Python bridge exited unexpectedly ` +
370
+ `(code: ${code}, signal: ${_signal}). ` +
371
+ `Use browser-navigate to start a fresh session.` +
372
+ stderrSuffix,
373
+ });
374
+ pending.reject(reason);
375
+ }
376
+
377
+ if (!this._started) {
378
+ reject(
379
+ new Error(
380
+ `PythonPluginAdapter('${this.name}'): Process exited before handshake ` +
381
+ `(code: ${code}, signal: ${_signal})` +
382
+ stderrSuffix,
383
+ ),
384
+ );
385
+ }
386
+ };
387
+
388
+ proc.on("error", onError);
389
+ proc.on("exit", onExit);
390
+
391
+ // ── Ping handshake ─────────────────────────────
392
+ // Wait a short tick for the process to initialise, then send ping.
393
+ // The bridge must respond within PING_TIMEOUT_MS.
394
+ // NOTE: uses _directRpcCall to avoid re-entering ensureRunning().
395
+ setImmediate(async () => {
396
+ try {
397
+ await this._directRpcCall("ping", {}, PING_TIMEOUT_MS);
398
+ this._started = true;
399
+ resolve();
400
+ } catch (err: unknown) {
401
+ // If the process already exited, the exit handler will reject.
402
+ // Only reject here if the process is still alive but ping failed.
403
+ if (this._process && this._exitCode === null) {
404
+ // Ping failed — kill and reject
405
+ this._killProcess();
406
+ reject(
407
+ new Error(
408
+ `PythonPluginAdapter('${this.name}'): Ping handshake failed: ` +
409
+ (err instanceof Error ? err.message : String(err)),
410
+ ),
411
+ );
412
+ }
413
+ }
414
+ });
415
+ });
416
+ }
417
+
418
+ /**
419
+ * Stop the Python subprocess gracefully, then force-kill if needed.
420
+ */
421
+ private async _stopProcess(): Promise<void> {
422
+ const proc = this._process;
423
+ if (!proc || proc.killed || this._exitCode !== null) {
424
+ this._process = null;
425
+ return;
426
+ }
427
+
428
+ // Try graceful shutdown via direct RPC (no ensureRunning —
429
+ // we know the process is alive, and starting a new process
430
+ // just to shut it down would be wasteful).
431
+ try {
432
+ await this._directRpcCall("shutdown", {}, SHUTDOWN_GRACE_MS);
433
+ } catch {
434
+ // Shutdown command failed — kill anyway
435
+ }
436
+
437
+ this._killProcess();
438
+ }
439
+
440
+ /**
441
+ * Force-kill the subprocess and clean up state.
442
+ */
443
+ private _killProcess(): void {
444
+ // Clear all per-task tracking — the bridge
445
+ // process is dead, so all Python-side state is lost.
446
+ this._elementCaches.clear();
447
+ this._pages.clear();
448
+
449
+ const proc = this._process;
450
+ if (!proc) return;
451
+
452
+ try {
453
+ // Remove listeners to prevent double-callbacks
454
+ proc.removeAllListeners("exit");
455
+ proc.removeAllListeners("error");
456
+
457
+ if (!proc.killed && this._exitCode === null) {
458
+ proc.kill("SIGTERM");
459
+ // Give it a moment, then SIGKILL
460
+ setTimeout(() => {
461
+ try {
462
+ if (!proc.killed && this._exitCode === null) {
463
+ proc.kill("SIGKILL");
464
+ }
465
+ } catch {
466
+ // Process may have already exited
467
+ }
468
+ }, 500).unref();
469
+ }
470
+ } catch {
471
+ // May already have exited
472
+ }
473
+
474
+ // Reject any remaining pending requests
475
+ const pendingSnapshot = new Map(this._pending);
476
+ this._pending.clear();
477
+ for (const [, pending] of pendingSnapshot) {
478
+ clearTimeout(pending.timer);
479
+ pending.reject(
480
+ new PythonBridgeError({
481
+ code: ERROR_CODES.APPLICATION_ERROR,
482
+ message: "Python bridge was shut down",
483
+ }),
484
+ );
485
+ }
486
+
487
+ this._process = null;
488
+ this._exitCode = 0; // Mark as dead
489
+ this._buffer = "";
490
+ }
491
+
492
+ // ═════════════════════════════════════════════════════════════════
493
+ // JSON-RPC communication
494
+ // ═════════════════════════════════════════════════════════════════
495
+
496
+ /**
497
+ * Send a JSON-RPC request to the bridge and wait for the response.
498
+ *
499
+ * Ensures the bridge process is running before sending.
500
+ *
501
+ * @param method JSON-RPC method name (e.g. "browser.navigate").
502
+ * @param params Parameters object.
503
+ * @param timeoutMs Transport-level timeout (milliseconds). Defaults
504
+ * to `this._transportTimeoutMs`.
505
+ * @returns The `result` field of the JSON-RPC response.
506
+ * @throws {PythonBridgeError} On bridge error, timeout, or infrastructure failure.
507
+ */
508
+ private async _rpcCall(
509
+ method: string,
510
+ params: Record<string, unknown>,
511
+ timeoutMs?: number,
512
+ ): Promise<unknown> {
513
+ await this.ensureRunning();
514
+ return this._directRpcCall(method, params, timeoutMs);
515
+ }
516
+
517
+ /**
518
+ * Direct JSON-RPC call — does NOT call ensureRunning.
519
+ *
520
+ * Used internally for startup ping and shutdown, where the process
521
+ * lifecycle is managed by the caller and calling ensureRunning would
522
+ * create a circular dependency or wasteful re-spawn.
523
+ */
524
+ private _directRpcCall(
525
+ method: string,
526
+ params: Record<string, unknown>,
527
+ timeoutMs?: number,
528
+ ): Promise<unknown> {
529
+ return new Promise<unknown>((resolve, reject) => {
530
+ const id = ++this._reqId;
531
+ const request = {
532
+ jsonrpc: "2.0" as const,
533
+ method,
534
+ params,
535
+ id,
536
+ };
537
+
538
+ const transportTimeout = timeoutMs ?? this._transportTimeoutMs;
539
+
540
+ const timer = setTimeout(() => {
541
+ this._pending.delete(id);
542
+ // The process is stuck — kill and restart
543
+ this._killProcess();
544
+ const stderrSuffix = this._stderrAccumulated
545
+ ? `\nstderr:\n${this._stderrAccumulated}`
546
+ : "";
547
+ reject(
548
+ new PythonBridgeError({
549
+ code: ERROR_CODES.TIMEOUT_ERROR,
550
+ message:
551
+ `Request timed out after ${transportTimeout}ms: ${method}` +
552
+ stderrSuffix,
553
+ }),
554
+ );
555
+ }, transportTimeout);
556
+
557
+ this._pending.set(id, { resolve, reject, timer });
558
+
559
+ try {
560
+ const line = JSON.stringify(request) + "\n";
561
+ const stdin = this._process?.stdin;
562
+ if (!stdin || stdin.destroyed) {
563
+ clearTimeout(timer);
564
+ this._pending.delete(id);
565
+ reject(
566
+ new PythonBridgeError({
567
+ code: ERROR_CODES.APPLICATION_ERROR,
568
+ message: "Python bridge stdin is closed or unavailable",
569
+ }),
570
+ );
571
+ return;
572
+ }
573
+ stdin.write(line);
574
+ } catch (err: unknown) {
575
+ clearTimeout(timer);
576
+ this._pending.delete(id);
577
+ reject(
578
+ new PythonBridgeError({
579
+ code: ERROR_CODES.APPLICATION_ERROR,
580
+ message: `Failed to write to Python bridge stdin: ${err instanceof Error ? err.message : String(err)}`,
581
+ }),
582
+ );
583
+ }
584
+ });
585
+ }
586
+
587
+ /**
588
+ * Flush buffered stdout data and process complete lines.
589
+ */
590
+ private _flushBuffer(): void {
591
+ const lines = this._buffer.split("\n");
592
+ // Keep the last (possibly incomplete) segment in the buffer
593
+ this._buffer = lines.pop() ?? "";
594
+
595
+ for (const line of lines) {
596
+ const trimmed = line.trim();
597
+ if (!trimmed) continue; // Skip empty lines
598
+ this._handleResponseLine(trimmed);
599
+ }
600
+ }
601
+
602
+ /**
603
+ * Handle a single complete JSON-RPC response line from stdout.
604
+ */
605
+ private _handleResponseLine(line: string): void {
606
+ let response: {
607
+ id?: unknown;
608
+ result?: unknown;
609
+ error?: { code: number; message: string; data?: { traceback?: string } };
610
+ };
611
+
612
+ try {
613
+ response = JSON.parse(line);
614
+ } catch {
615
+ // Invalid JSON on the wire — this is a protocol violation.
616
+ // Log and skip; if it's critical the transport timeout will fire.
617
+ console.error(
618
+ `[pi-lean-portal] PythonPluginAdapter('${this.name}'): Invalid JSON from bridge: ${line.slice(0, 200)}`,
619
+ );
620
+ return;
621
+ }
622
+
623
+ // Notification (no id) — ignore
624
+ if (response.id === undefined || response.id === null) return;
625
+
626
+ const id =
627
+ typeof response.id === "number" ? response.id : Number(response.id);
628
+ const pending = this._pending.get(id);
629
+ if (!pending) {
630
+ // Response for an already-resolved/rejected request — stale
631
+ return;
632
+ }
633
+
634
+ clearTimeout(pending.timer);
635
+ this._pending.delete(id);
636
+
637
+ if (response.error) {
638
+ pending.reject(new PythonBridgeError(response.error));
639
+ } else {
640
+ pending.resolve(response.result);
641
+ }
642
+ }
643
+
644
+ // ═════════════════════════════════════════════════════════════════
645
+ // BrowserPlugin: Navigation & state
646
+ // ═════════════════════════════════════════════════════════════════
647
+
648
+ async navigate(
649
+ url: string,
650
+ taskId: string,
651
+ timeoutMs: number = 30_000,
652
+ options?: {
653
+ signal?: AbortSignal;
654
+ storageState?: unknown;
655
+ profileName?: string;
656
+ profileMode?: "none" | "session" | "named";
657
+ },
658
+ ): Promise<NavigateResult> {
659
+ // We don't use the AbortSignal directly (supportsAbortSignal: false),
660
+ // but we accept it for interface compatibility.
661
+
662
+ try {
663
+ // Build RPC params — include storageState and profileName if provided
664
+ const rpcParams: Record<string, unknown> = { url, taskId, timeoutMs };
665
+ if (options?.storageState !== undefined) {
666
+ rpcParams.storageState = options.storageState;
667
+ }
668
+ if (options?.profileName !== undefined) {
669
+ rpcParams.profileName = options.profileName;
670
+ rpcParams.profileMode = options.profileMode ?? "named";
671
+ }
672
+
673
+ // ── Persist state before re-navigate (if session exists) ──
674
+ // If the task already has a page entry, this is a re-navigate.
675
+ // Save the current storage state to disk so it survives
676
+ // process restarts (crash, reload, resume).
677
+ if (this._pages.has(taskId)) {
678
+ await this._persistState(taskId).catch(() => {});
679
+ }
680
+
681
+ // Give the bridge slightly more time so navigation timeouts
682
+ // are reported by the bridge, not the transport layer.
683
+ const transportTimeout = timeoutMs + 10_000;
684
+ const raw = await this._rpcCall(
685
+ "browser.navigate",
686
+ rpcParams,
687
+ transportTimeout,
688
+ );
689
+
690
+ const result = raw as Record<string, unknown>;
691
+ const success = !!result.success;
692
+
693
+ // Track this task for cleanup
694
+ const pageMeta: { profileName?: string } = {};
695
+ if (options?.profileName) {
696
+ pageMeta.profileName = options.profileName;
697
+ }
698
+ this._pages.set(taskId, pageMeta);
699
+
700
+ // Update session manager
701
+ if (success) {
702
+ sessionManager.updateSession(taskId, {
703
+ currentUrl: (result.url as string) ?? url,
704
+ currentTitle: (result.title as string) ?? "",
705
+ pluginName: this.name,
706
+ });
707
+
708
+ // Populate local element cache from bridge response
709
+ this._populateElementCache(taskId, result.elements);
710
+ }
711
+
712
+ const navResult: NavigateResult = {
713
+ success,
714
+ url: (result.url as string) ?? url,
715
+ title: (result.title as string) ?? "",
716
+ snapshot: (result.snapshot as string) ?? "",
717
+ elementCount: (result.elementCount as number) ?? 0,
718
+ };
719
+ if (result.botDetected) navResult.botDetected = true;
720
+ if (result.error !== undefined) navResult.error = result.error as string;
721
+ if (result.dialogEvents !== undefined) {
722
+ navResult.dialogEvents = result.dialogEvents as DialogEvent[];
723
+ }
724
+ return navResult;
725
+ } catch (err: unknown) {
726
+ return {
727
+ success: false,
728
+ url,
729
+ title: "",
730
+ snapshot: "",
731
+ elementCount: 0,
732
+ error: err instanceof Error ? err.message : String(err),
733
+ };
734
+ }
735
+ }
736
+
737
+ async snapshot(taskId: string): Promise<SnapshotResult> {
738
+ return this._rpcCallTyped(
739
+ "browser.snapshot",
740
+ { taskId },
741
+ (raw) => {
742
+ // Populate local element cache from bridge response
743
+ this._populateElementCache(taskId, raw.elements);
744
+ return {
745
+ success: !!raw.success,
746
+ snapshot: (raw.snapshot as string) ?? "",
747
+ elementCount: (raw.elementCount as number) ?? 0,
748
+ dialogEvents: (raw.dialogEvents as DialogEvent[]) ?? [],
749
+ ...(raw.error !== undefined ? { error: raw.error as string } : {}),
750
+ };
751
+ },
752
+ (error) => ({
753
+ success: false,
754
+ snapshot: "",
755
+ elementCount: 0,
756
+ error,
757
+ dialogEvents: [],
758
+ }),
759
+ );
760
+ }
761
+
762
+ // ═════════════════════════════════════════════════════════════════
763
+ // BrowserPlugin: Interaction
764
+ // ═════════════════════════════════════════════════════════════════
765
+
766
+ async click(taskId: string, ref: string): Promise<InteractionResult> {
767
+ return this._rpcCallTyped(
768
+ "browser.click",
769
+ { taskId, ref },
770
+ (raw) => this._toInteractionResult(raw),
771
+ (error) => ({ success: false, error }),
772
+ );
773
+ }
774
+
775
+ async type(
776
+ taskId: string,
777
+ ref: string,
778
+ text: string,
779
+ ): Promise<InteractionResult> {
780
+ return this._rpcCallTyped(
781
+ "browser.type",
782
+ { taskId, ref, text },
783
+ (raw) => this._toInteractionResult(raw),
784
+ (error) => ({ success: false, error }),
785
+ );
786
+ }
787
+
788
+ async scroll(
789
+ taskId: string,
790
+ direction: "up" | "down",
791
+ ): Promise<InteractionResult> {
792
+ return this._rpcCallTyped(
793
+ "browser.scroll",
794
+ { taskId, direction },
795
+ (raw) => this._toInteractionResult(raw),
796
+ (error) => ({ success: false, error }),
797
+ );
798
+ }
799
+
800
+ async goBack(taskId: string): Promise<InteractionResult> {
801
+ return this._rpcCallTyped(
802
+ "browser.goBack",
803
+ { taskId },
804
+ (raw) => this._toInteractionResult(raw),
805
+ (error) => ({ success: false, error }),
806
+ );
807
+ }
808
+
809
+ async press(taskId: string, key: string): Promise<InteractionResult> {
810
+ return this._rpcCallTyped(
811
+ "browser.press",
812
+ { taskId, key },
813
+ (raw) => this._toInteractionResult(raw),
814
+ (error) => ({ success: false, error }),
815
+ );
816
+ }
817
+
818
+ // ═════════════════════════════════════════════════════════════════
819
+ // BrowserPlugin: Media
820
+ // ═════════════════════════════════════════════════════════════════
821
+
822
+ async screenshot(
823
+ taskId: string,
824
+ options?: { fullPage?: boolean },
825
+ ): Promise<ScreenshotResult> {
826
+ // If capabilities don't support fullPage, never pass it
827
+ const fullPage =
828
+ this.capabilities.supportsFullPageScreenshot &&
829
+ options?.fullPage === true;
830
+
831
+ return this._rpcCallTyped(
832
+ "browser.screenshot",
833
+ { taskId, fullPage },
834
+ (raw) => ({
835
+ success: !!raw.success,
836
+ dataUri: (raw.dataUri as string) ?? "",
837
+ ...(raw.error !== undefined ? { error: raw.error as string } : {}),
838
+ }),
839
+ (error) => ({ success: false, dataUri: "", error }),
840
+ );
841
+ }
842
+
843
+ // ═════════════════════════════════════════════════════════════════
844
+ // BrowserPlugin: Console & eval
845
+ // ═════════════════════════════════════════════════════════════════
846
+
847
+ async getConsoleMessages(taskId: string): Promise<ConsoleMessagesResult> {
848
+ return this._rpcCallTyped(
849
+ "browser.getConsoleMessages",
850
+ { taskId },
851
+ (raw) => ({
852
+ success: !!raw.success,
853
+ messages: (raw.messages as ConsoleMessagesResult["messages"]) ?? [],
854
+ ...(raw.error !== undefined ? { error: raw.error as string } : {}),
855
+ }),
856
+ (error) => ({ success: false, messages: [], error }),
857
+ );
858
+ }
859
+
860
+ async clearConsole(taskId: string): Promise<void> {
861
+ try {
862
+ await this._rpcCall("browser.clearConsole", { taskId });
863
+ } catch (err: unknown) {
864
+ // clearConsole is best-effort; swallow the error
865
+ console.error(
866
+ `[pi-lean-portal] PythonPluginAdapter('${this.name}'): clearConsole failed:`,
867
+ err,
868
+ );
869
+ }
870
+ }
871
+
872
+ async evaluate(taskId: string, expression: string): Promise<EvaluateResult> {
873
+ return this._rpcCallTyped(
874
+ "browser.evaluate",
875
+ { taskId, expression },
876
+ (raw) => ({
877
+ success: !!raw.success,
878
+ result: raw.result,
879
+ ...(raw.error !== undefined ? { error: raw.error as string } : {}),
880
+ }),
881
+ (error) => ({ success: false, error, result: undefined }),
882
+ );
883
+ }
884
+
885
+ // ═════════════════════════════════════════════════════════════════
886
+ // BrowserPlugin: Cookies & storage state
887
+ // ═════════════════════════════════════════════════════════════════
888
+
889
+ async getCookies(taskId: string, urls?: string[]): Promise<CookieResult> {
890
+ return this._rpcCallTyped(
891
+ "browser.getCookies",
892
+ { taskId, ...(urls ? { urls } : {}) },
893
+ (raw) => ({
894
+ success: !!raw.success,
895
+ cookies: (raw.cookies as Cookie[]) ?? [],
896
+ ...(raw.error !== undefined ? { error: raw.error as string } : {}),
897
+ }),
898
+ (error) => ({ success: false, cookies: [], error }),
899
+ );
900
+ }
901
+
902
+ async addCookies(taskId: string, cookies: Cookie[]): Promise<ResultBase> {
903
+ return this._rpcCallTyped(
904
+ "browser.addCookies",
905
+ { taskId, cookies },
906
+ (raw) => ({
907
+ success: !!raw.success,
908
+ ...(raw.error !== undefined ? { error: raw.error as string } : {}),
909
+ }),
910
+ (error) => ({ success: false, error }),
911
+ );
912
+ }
913
+
914
+ async clearCookies(
915
+ taskId: string,
916
+ options?: ClearCookiesOptions,
917
+ ): Promise<ResultBase> {
918
+ return this._rpcCallTyped(
919
+ "browser.clearCookies",
920
+ {
921
+ taskId,
922
+ ...(options?.name ? { name: options.name } : {}),
923
+ ...(options?.domain ? { domain: options.domain } : {}),
924
+ ...(options?.path ? { path: options.path } : {}),
925
+ },
926
+ (raw) => ({
927
+ success: !!raw.success,
928
+ ...(raw.error !== undefined ? { error: raw.error as string } : {}),
929
+ }),
930
+ (error) => ({ success: false, error }),
931
+ );
932
+ }
933
+
934
+ async getStorageState(taskId: string): Promise<StorageStateResult> {
935
+ return this._rpcCallTyped(
936
+ "browser.getStorageState",
937
+ { taskId },
938
+ (raw) => ({
939
+ success: !!raw.success,
940
+ cookies: (raw.cookies as StorageStateResult["cookies"]) ?? [],
941
+ origins: (raw.origins as StorageStateResult["origins"]) ?? [],
942
+ ...(raw.error !== undefined ? { error: raw.error as string } : {}),
943
+ }),
944
+ (error) => ({ success: false, cookies: [], origins: [], error }),
945
+ );
946
+ }
947
+
948
+ // ═════════════════════════════════════════════════════════════════
949
+ // BrowserPlugin: Per-task cleanup
950
+ // ═════════════════════════════════════════════════════════════════
951
+
952
+ async cleanup(taskId: string): Promise<void> {
953
+ const pageEntry = this._pages.get(taskId);
954
+ if (!pageEntry) {
955
+ this._elementCaches.delete(taskId);
956
+ return;
957
+ }
958
+
959
+ // ── Auto-save storage state for persistent profiles ──────────
960
+ await this._persistState(taskId).catch(() => {});
961
+
962
+ // Clean up local state and tell the bridge to close the context
963
+ this._elementCaches.delete(taskId);
964
+ this._pages.delete(taskId);
965
+
966
+ try {
967
+ await this._rpcCall("browser.cleanup", { taskId });
968
+ } catch (err: unknown) {
969
+ console.error(
970
+ `[pi-lean-portal] PythonPluginAdapter('${this.name}'): cleanup failed for task '${taskId}':`,
971
+ err,
972
+ );
973
+ }
974
+ }
975
+
976
+ // ═════════════════════════════════════════════════════════════════
977
+ // Internal helpers
978
+ // ═════════════════════════════════════════════════════════════════
979
+
980
+ // ═════════════════════════════════════════════════════════════════
981
+ // BrowserPlugin: Element cache access
982
+ // ═════════════════════════════════════════════════════════════════
983
+
984
+ /**
985
+ * Return the local element cache for the given task.
986
+ * Returns null if no cache has been populated yet (navigate/snapshot
987
+ * has not been called, or the bridge doesn't support element caching).
988
+ */
989
+ getElementCache(taskId: string): Map<string, AriaCachedNode> | null {
990
+ return this._elementCaches.get(taskId) ?? null;
991
+ }
992
+
993
+ /**
994
+ * Save the current session's storage state to disk for a persistent profile.
995
+ *
996
+ * Mirrors the Chromium plugin's `_persistState`: checks
997
+ * `session?.persistState`, calls the bridge's `browser.getStorageState`
998
+ * RPC, and persists via `saveStorageState()`. Best-effort — failures
999
+ * are logged to stderr and swallowed.
1000
+ *
1001
+ * Unlike the Chromium plugin, the Python bridge reuses BrowserContexts
1002
+ * across navigations (via `ensure_session`), so the returned state is
1003
+ * not immediately needed for a new context. It is returned for API
1004
+ * consistency so callers can optionally use it as a fallback.
1005
+ *
1006
+ * @param taskId - The task/session ID.
1007
+ * @returns The raw storage state object (cookies + origins), or undefined
1008
+ * if the session is non-persistent or the save failed.
1009
+ */
1010
+ private async _persistState(
1011
+ taskId: string,
1012
+ ): Promise<{ cookies: unknown[]; origins: unknown[] } | undefined> {
1013
+ 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 = {
1022
+ cookies: (result.cookies ?? []) as Record<string, unknown>[],
1023
+ origins: (result.origins ?? []) as Record<string, unknown>[],
1024
+ };
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
+ }
1038
+ }
1039
+
1040
+ /**
1041
+ * Populate the local element cache from a bridge response's `elements` dict.
1042
+ * The dict format is { "e1": { role, name, props, depth, raw, occurrenceIndex, parentRef }, ... }
1043
+ * If `elements` is not present (older bridge), the cache stays empty.
1044
+ */
1045
+ private _populateElementCache(taskId: string, elements: unknown): void {
1046
+ if (!elements || typeof elements !== "object") return;
1047
+
1048
+ const cache = new Map<string, AriaCachedNode>();
1049
+
1050
+ for (const [ref, raw] of Object.entries(
1051
+ elements as Record<string, unknown>,
1052
+ )) {
1053
+ const node = raw as Record<string, unknown>;
1054
+ if (!node.role) continue;
1055
+
1056
+ const cachedNode: AriaCachedNode = {
1057
+ ref,
1058
+ role: node.role as string,
1059
+ name: (node.name as string) ?? "",
1060
+ props: (node.props as string[]) ?? [],
1061
+ depth: (node.depth as number) ?? 0,
1062
+ raw: (node.raw as string) ?? "",
1063
+ occurrenceIndex: (node.occurrenceIndex as number) ?? 0,
1064
+ ...(node.parentRef ? { parentRef: node.parentRef as string } : {}),
1065
+ };
1066
+ cache.set(ref, cachedNode);
1067
+ }
1068
+
1069
+ if (cache.size > 0) {
1070
+ this._elementCaches.set(taskId, cache);
1071
+ } else {
1072
+ this._elementCaches.delete(taskId);
1073
+ }
1074
+ }
1075
+
1076
+ // ═════════════════════════════════════════════════════════════════
1077
+
1078
+ /**
1079
+ * Execute an RPC method with consistent error handling.
1080
+ *
1081
+ * Calls `_rpcCall(method, params)`, then passes the raw response to
1082
+ * `onSuccess` for type-specific processing. On failure, calls `onError`
1083
+ * with the formatted error message.
1084
+ *
1085
+ * This eliminates the duplicated try/catch + `err instanceof Error`
1086
+ * pattern that was repeated in every standard BrowserPlugin method.
1087
+ *
1088
+ * @param method JSON-RPC method name (e.g. "browser.click").
1089
+ * @param params Parameters object.
1090
+ * @param onSuccess Transforms the raw RPC response into the result type.
1091
+ * @param onError Returns a failure result for the given error message.
1092
+ * @returns The transformed result on success, or the error result on failure.
1093
+ */
1094
+ private async _rpcCallTyped<T extends { success: boolean }>(
1095
+ method: string,
1096
+ params: Record<string, unknown>,
1097
+ onSuccess: (raw: Record<string, unknown>) => T,
1098
+ onError: (error: string) => T,
1099
+ ): Promise<T> {
1100
+ try {
1101
+ const raw = await this._rpcCall(method, params);
1102
+ return onSuccess(raw as Record<string, unknown>);
1103
+ } catch (err: unknown) {
1104
+ return onError(err instanceof Error ? err.message : String(err));
1105
+ }
1106
+ }
1107
+
1108
+ /**
1109
+ * Check whether the configured Python interpreter exists in PATH.
1110
+ */
1111
+ private _checkPythonExists(): boolean {
1112
+ try {
1113
+ const result = spawnSync(this._pythonPath, ["--version"], {
1114
+ stdio: "ignore",
1115
+ timeout: 5_000,
1116
+ });
1117
+ return result.status === 0;
1118
+ } catch {
1119
+ return false;
1120
+ }
1121
+ }
1122
+
1123
+ /**
1124
+ * Convert a raw RPC result to an InteractionResult.
1125
+ */
1126
+ private _toInteractionResult(raw: unknown): InteractionResult {
1127
+ const r = raw as Record<string, unknown>;
1128
+ const result: InteractionResult = {
1129
+ success: !!r.success,
1130
+ };
1131
+ if (r.newUrl != null) result.newUrl = r.newUrl as string;
1132
+ if (r.newTitle != null) result.newTitle = r.newTitle as string;
1133
+ if (r.snapshot != null) result.snapshot = r.snapshot as string;
1134
+ if (r.elementCount != null) result.elementCount = r.elementCount as number;
1135
+ if (r.dialogEvents != null) {
1136
+ result.dialogEvents = r.dialogEvents as DialogEvent[];
1137
+ }
1138
+ if (r.error != null) result.error = r.error as string;
1139
+ return result;
1140
+ }
1141
+ }