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.
- package/LICENSE +661 -0
- package/README.md +608 -0
- package/backends/chromium/index.ts +50 -0
- package/backends/chromium-py/bridge.py +67 -0
- package/backends/firefox/index.ts +60 -0
- package/backends/firefox-py/bridge.py +64 -0
- package/backends/playwright-base/playwright-plugin.ts +1294 -0
- package/backends/python-adapter.ts +1141 -0
- package/backends/python-base/pi_browser_bridge/__init__.py +71 -0
- package/backends/python-base/pi_browser_bridge/accessibility.py +408 -0
- package/backends/python-base/pi_browser_bridge/bot_detection.py +115 -0
- package/backends/python-base/pi_browser_bridge/bridge.py +598 -0
- package/backends/python-base/pi_browser_bridge/playwright_base.py +1222 -0
- package/backends/python-base/pi_browser_bridge/transport.py +167 -0
- package/backends/python-base/pyproject.toml +15 -0
- package/browser-cookies.ts +88 -0
- package/browser-profile.ts +260 -0
- package/browser-status.ts +84 -0
- package/browser-toggle.ts +527 -0
- package/core/fetch-backend.ts +466 -0
- package/core/guides.ts +467 -0
- package/core/plugin-api.ts +302 -0
- package/core/plugin-config.ts +388 -0
- package/core/plugin-registry.ts +263 -0
- package/core/router.ts +1186 -0
- package/core/shared/accessibility-tree.ts +408 -0
- package/core/shared/bot-detection.ts +187 -0
- package/core/shared/browser-events.ts +111 -0
- package/core/shared/dom-extractor.ts +550 -0
- package/core/shared/nav-settle.ts +187 -0
- package/core/shared/paths.ts +56 -0
- package/core/shared/session-manager.ts +258 -0
- package/core/shared/settings-reader.ts +63 -0
- package/core/shared/snapshot-cache.ts +231 -0
- package/core/shared/storage-state.ts +560 -0
- package/core/shared/task-id.ts +77 -0
- package/core/shared/url-safety.ts +164 -0
- package/index.ts +253 -0
- package/package.json +63 -0
- package/ship-manifest.test.ts +12 -0
- package/tools/browser-back.ts +50 -0
- package/tools/browser-click.ts +74 -0
- package/tools/browser-console.ts +160 -0
- package/tools/browser-inspect.ts +136 -0
- package/tools/browser-navigate.ts +254 -0
- package/tools/browser-press.ts +80 -0
- package/tools/browser-scroll.ts +56 -0
- package/tools/browser-snapshot.ts +90 -0
- package/tools/browser-type.ts +60 -0
- package/tools/index.ts +19 -0
- package/tools/utils.ts +157 -0
- package/tools/web-fetch.ts +147 -0
- package/tools/web-guide.ts +55 -0
- package/tools/web-learn.ts +128 -0
- 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
|
+
}
|