dsh-moli 1.1.10 → 1.1.11
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/README.md +64 -0
- package/README.zh.md +53 -0
- package/index.js +238 -1
- package/lib/actions.js +380 -0
- package/lib/session.js +372 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -223,6 +223,52 @@ moli_fetch url=https://example.com dump=pdf writeTo=/abs/path/a.pdf
|
|
|
223
223
|
|
|
224
224
|
Screenshot and PDF dumps need `writeTo`; the bytes are written verbatim rather than printed.
|
|
225
225
|
|
|
226
|
+
### `moli_session` — an interactive browser
|
|
227
|
+
|
|
228
|
+
`moli_fetch` loads a page and prints it. That covers reading, and nothing else: it cannot dismiss a
|
|
229
|
+
consent wall, fill a search box, scroll a lazy list, or wait for a client-side route to change.
|
|
230
|
+
`moli_session` drives a persistent browser instead, over the Chrome DevTools Protocol endpoint that
|
|
231
|
+
`moli serve` publishes. State — cookies, navigation, typed input — survives between calls until you
|
|
232
|
+
stop it.
|
|
233
|
+
|
|
234
|
+
```
|
|
235
|
+
moli_session action=open url=https://example.com # start the browser and navigate
|
|
236
|
+
moli_session action=snapshot # the rendered page as text
|
|
237
|
+
moli_session action=click selector="#accept-cookies"
|
|
238
|
+
moli_session action=type selector="#q" text="moli" submit=true
|
|
239
|
+
moli_session action=scroll dy=1200
|
|
240
|
+
moli_session action=waitFor selector=".results"
|
|
241
|
+
moli_session action=eval expression="document.title"
|
|
242
|
+
moli_session action=screenshot writeTo=/abs/path/a.png
|
|
243
|
+
moli_session action=status # is it running, and on what page
|
|
244
|
+
moli_session action=close # stop it and free the port
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Elements are addressed by **CSS selector**, never by pixel coordinates: the tool measures the element
|
|
248
|
+
and dispatches real mouse and key events, so page handlers run exactly as they do for a person.
|
|
249
|
+
|
|
250
|
+
**One thing to know before relying on `click`.** This engine's element geometry is document-absolute
|
|
251
|
+
and does not follow scrolling, and its hit test is bounded by the viewport height. Measured on Moli
|
|
252
|
+
1.1.16: an element at document `y=1008` reports the same `y` at `scrollY` 0, 400 and 800, while
|
|
253
|
+
`elementFromPoint` stops answering past `y=1080`. The consequence is that synthetic input cannot reach
|
|
254
|
+
an element below the first viewport, on any page long enough to scroll. `click` says so, naming the
|
|
255
|
+
element, and points at the route that does work:
|
|
256
|
+
|
|
257
|
+
```
|
|
258
|
+
moli_session action=eval expression="document.querySelector('#far-down').click()"
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
`eval` runs a click inside the page and bypasses the input layer, so it reaches elements the input API
|
|
262
|
+
cannot. Note that it also skips hover and focus behaviour, which is the difference between clicking a
|
|
263
|
+
control and calling its handler.
|
|
264
|
+
|
|
265
|
+
The session needs **Node 21 or newer** for the built-in `WebSocket`; on Node 20 the tool answers with
|
|
266
|
+
that reason instead of failing obscurely. `moli_fetch` and `moli_probe` still work on 20.12.
|
|
267
|
+
|
|
268
|
+
`moli_session` also takes `port` (default 9222). If that port is already served by a `moli serve` you
|
|
269
|
+
started yourself, the tool connects to it rather than failing to bind — and `close` then leaves it
|
|
270
|
+
alone, because it is not ours to stop.
|
|
271
|
+
|
|
226
272
|
## Relationship to Moli's own agent skills
|
|
227
273
|
|
|
228
274
|
Moli ships official agent skills in its repository — [`moli-webfetch`](https://github.com/lexmount/moli/tree/main/skills/moli-webfetch),
|
|
@@ -310,12 +356,24 @@ Override any field under the plugin's `config` entry in the profile patch layer.
|
|
|
310
356
|
maxChars: 200000 # inline text truncation
|
|
311
357
|
proxy: 'http://127.0.0.1:7890' # forward proxy for moli, downloads, and the installer's engine download
|
|
312
358
|
insecureTls: true # see the TLS notes below
|
|
359
|
+
sessionPort: 9222 # CDP port for moli_session
|
|
360
|
+
blockPrivateNetworks: false # refuse private addresses (see the note below)
|
|
361
|
+
blockCidrs: [] # extra CIDRs to refuse
|
|
313
362
|
```
|
|
314
363
|
|
|
315
364
|
The plugin-level `proxy` above is the default; `moli_fetch` also takes a `proxy` argument, so a single
|
|
316
365
|
call can route through a different proxy without changing the profile. A loopback URL always bypasses
|
|
317
366
|
whichever proxy is in effect.
|
|
318
367
|
|
|
368
|
+
`blockPrivateNetworks` is **off by default, matching the engine**: `moli fetch http://10.0.0.1/` reaches
|
|
369
|
+
a private address, so the plugin does not silently narrow what the moli you installed already does.
|
|
370
|
+
Turn it on, or list `blockCidrs`, when the agent is following links on pages you do not control and
|
|
371
|
+
you want the engine to refuse private destinations. Two caveats worth knowing before you rely on it:
|
|
372
|
+
moli documents the policy as applying to **directly connected** targets, so a configured `proxy`
|
|
373
|
+
routes around it, and only `blockCidrs` (which the proxy must enforce) covers that path. The sharpest
|
|
374
|
+
target this protects is the cloud metadata address `169.254.169.254`, which hands out instance
|
|
375
|
+
credentials on a VM.
|
|
376
|
+
|
|
319
377
|
Without `exePath`, the plugin probes `exePath` → `MOLI_BIN` → `%LOCALAPPDATA%\Moli\bin\moli.exe` →
|
|
320
378
|
`%USERPROFILE%\.local\bin\moli.exe`, then scans inside `%LOCALAPPDATA%\Moli` for a `moli.exe`, so an
|
|
321
379
|
installation under a versioned subdirectory is still found.
|
|
@@ -378,6 +436,12 @@ Four suites live in [`verify/`](verify/), and all are runnable against a working
|
|
|
378
436
|
`dump` discards its value, an expression that throws is classified as such instead of being blamed
|
|
379
437
|
on the installation, truncation keeps the tail as well as the head, `proxy` is a per-call parameter,
|
|
380
438
|
and `moli_probe` states which process its `httpsOk` describes.
|
|
439
|
+
- `session-verify.mjs` drives `moli_session` end to end against a `data:` URL fixture, so it needs no
|
|
440
|
+
network and no site stays still underneath it: lifecycle and port release, snapshot, click, type
|
|
441
|
+
with and without clearing, press, scroll, `waitFor` in both directions, eval including a throwing
|
|
442
|
+
expression, screenshot byte signature, navigation by clicking a link, restart after close, and the
|
|
443
|
+
viewport limit described above — which is asserted as a limit, with the `eval` route that works
|
|
444
|
+
around it asserted next to it.
|
|
381
445
|
|
|
382
446
|
`.github/workflows/verify.yml` runs the first two on Linux, Windows and macOS (they need no engine and
|
|
383
447
|
no network) and attempts the engine-dependent suites on Linux and Windows, reporting a missing engine
|
package/README.zh.md
CHANGED
|
@@ -209,6 +209,46 @@ moli_fetch url=https://example.com dump=pdf writeTo=/绝对路径/a.pdf
|
|
|
209
209
|
|
|
210
210
|
截图与 PDF 必须给 `writeTo`;字节是原样落盘的,不会打印到输出里。
|
|
211
211
|
|
|
212
|
+
### `moli_session` —— 可交互的浏览器
|
|
213
|
+
|
|
214
|
+
`moli_fetch` 是"加载一页、打印出来",只能读。它点不掉同意墙、填不了搜索框、滚不动懒加载列表,也等不到
|
|
215
|
+
客户端路由切换。`moli_session` 驱动的是一个**常驻浏览器**,走 `moli serve` 暴露的 Chrome DevTools
|
|
216
|
+
Protocol。状态(cookie、导航位置、已输入的内容)在多次调用之间保持,直到你显式停止。
|
|
217
|
+
|
|
218
|
+
```
|
|
219
|
+
moli_session action=open url=https://example.com # 启动浏览器并导航
|
|
220
|
+
moli_session action=snapshot # 渲染后的页面文本
|
|
221
|
+
moli_session action=click selector="#accept-cookies"
|
|
222
|
+
moli_session action=type selector="#q" text="moli" submit=true
|
|
223
|
+
moli_session action=scroll dy=1200
|
|
224
|
+
moli_session action=waitFor selector=".results"
|
|
225
|
+
moli_session action=eval expression="document.title"
|
|
226
|
+
moli_session action=screenshot writeTo=/绝对路径/a.png
|
|
227
|
+
moli_session action=status # 是否在运行、当前在哪一页
|
|
228
|
+
moli_session action=close # 停止并释放端口
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
元素一律用 **CSS 选择器**定位,不接受像素坐标:工具自己测量元素并派发真实的鼠标与键盘事件,所以页面的
|
|
232
|
+
处理器会像人操作时一样执行。
|
|
233
|
+
|
|
234
|
+
**依赖 `click` 之前需要知道一件事。** 这个引擎的元素几何是**文档绝对坐标且不随滚动更新**,而它的命中测试
|
|
235
|
+
**受视口高度限制**。在 Moli 1.1.16 上实测:文档 `y=1008` 的元素在 `scrollY` 为 0、400、800 时报告的 `y`
|
|
236
|
+
完全相同,而 `elementFromPoint` 在 `y=1080` 之后一律返回 null。后果是:**在长到需要滚动的页面上,合成输入
|
|
237
|
+
无法触达首个视口以下的元素。** `click` 会如实报出这一点并点名元素,同时给出真正可行的路径:
|
|
238
|
+
|
|
239
|
+
```
|
|
240
|
+
moli_session action=eval expression="document.querySelector('#far-down').click()"
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
`eval` 在页面内部执行点击,绕开输入层,因此能触达输入 API 达不到的元素。注意它同时跳过了 hover 与 focus
|
|
244
|
+
行为——这正是"点击一个控件"与"调用它的处理器"的区别。
|
|
245
|
+
|
|
246
|
+
本工具需要 **Node 21 或更新版本**(内置 `WebSocket`);在 Node 20 上会直接说明原因,而不是给出费解的失败。
|
|
247
|
+
`moli_fetch` 与 `moli_probe` 仍支持 20.12。
|
|
248
|
+
|
|
249
|
+
`moli_session` 也接受 `port`(默认 9222)。如果该端口已经由你自己启动的 `moli serve` 占用,工具会连接它
|
|
250
|
+
而不是绑定失败——此时 `close` **不会**停掉它,因为那不是我们的进程。
|
|
251
|
+
|
|
212
252
|
## 与 Moli 官方 agent skill 的关系
|
|
213
253
|
|
|
214
254
|
Moli 仓库自带官方 agent skill:[`moli-webfetch`](https://github.com/lexmount/moli/tree/main/skills/moli-webfetch)、
|
|
@@ -284,11 +324,20 @@ cookie 驱动的查询参数。那次实测同时覆盖了代理下载路由与
|
|
|
284
324
|
maxChars: 200000 # 内联文本截断阈值
|
|
285
325
|
proxy: 'http://127.0.0.1:7890' # 转给本地正向代理(moli、下载、安装器内部引擎下载都走它)
|
|
286
326
|
insecureTls: true # 见下方 TLS 说明
|
|
327
|
+
sessionPort: 9222 # moli_session 的 CDP 端口
|
|
328
|
+
blockPrivateNetworks: false # 拒绝私网地址(见下方说明)
|
|
329
|
+
blockCidrs: [] # 额外拒绝的 CIDR
|
|
287
330
|
```
|
|
288
331
|
|
|
289
332
|
上面配置里的 `proxy` 是默认值;`moli_fetch` 也能接收 `proxy` 参数,因此单次调用可以走另一个代理而不必改
|
|
290
333
|
profile。无论生效的是哪个代理,loopback 地址一律绕过它。
|
|
291
334
|
|
|
335
|
+
`blockPrivateNetworks` **默认关闭,与引擎一致**:`moli fetch http://10.0.0.1/` 本来就能访问私网地址,插件
|
|
336
|
+
不会悄悄收窄你已安装的 moli 的能力。当 agent 会在你无法控制的页面上跟着链接走、而你希望引擎拒绝私网目标时,
|
|
337
|
+
再打开它或配置 `blockCidrs`。依赖它之前有两点需要知道:moli 文档写明该策略只作用于**直连**目标,因此配置了
|
|
338
|
+
`proxy` 就会绕过它,只有 `blockCidrs`(由代理执行)能覆盖那条路径。这个开关保护的最锐利目标是云元数据地址
|
|
339
|
+
`169.254.169.254`——在虚拟机上它会交出实例凭据。
|
|
340
|
+
|
|
292
341
|
未设置 `exePath` 时探测顺序:`exePath` → 环境变量 `MOLI_BIN` → `%LOCALAPPDATA%\Moli\bin\moli.exe`
|
|
293
342
|
→ `%USERPROFILE%\.local\bin\moli.exe`,随后还会在 `%LOCALAPPDATA%\Moli` 内递归查找 `moli.exe`,
|
|
294
343
|
所以装在带版本号的子目录里也能找到。
|
|
@@ -335,6 +384,10 @@ profile。无论生效的是哪个代理,loopback 地址一律绕过它。
|
|
|
335
384
|
- `report-verify.mjs` 守住一份使用体验报告里的发现:`eval` 与 `dump` 同用会丢弃表达式值(已在参数描述里
|
|
336
385
|
写明)、表达式抛异常时被正确归类而不是甩锅给"安装有问题"、截断同时保留头尾、`proxy` 是可按调用传入的参数、
|
|
337
386
|
以及 `moli_probe` 会说明它的 `httpsOk` 描述的是哪个进程。
|
|
387
|
+
- `session-verify.mjs` 用 `data:` URL 夹具端到端驱动 `moli_session`,因此不依赖网络、也不会因为站点变化而
|
|
388
|
+
失效:生命周期与端口释放、snapshot、click、带与不带清空的 type、press、scroll、两个方向的 `waitFor`、
|
|
389
|
+
含抛异常的 eval、截图字节签名、点击链接导航、关闭后重启,以及上面那条视口限制——限制被当作**限制**断言,
|
|
390
|
+
紧随其后断言真正能绕过它的 `eval` 路径。
|
|
338
391
|
|
|
339
392
|
`.github/workflows/verify.yml` 在 Linux、Windows、macOS 上跑前两个(无需引擎、无需网络),在 Linux 与 Windows
|
|
340
393
|
上尝试依赖引擎的几套;若 runner 上没有引擎,则报告为跳过而不是失败。
|
package/index.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/** Host half of dsh-moli: expose the local `moli` headless browser as callable tools. */
|
|
2
|
+
import { performAction } from './lib/actions.js';
|
|
2
3
|
import { MANUAL_INSTALL, installMoli } from './lib/install.js';
|
|
3
4
|
import { findMoli, moliCandidates, probe, runFetch } from './lib/moli.js';
|
|
5
|
+
import { SessionManager, sessionSupported, sessionUnsupportedReason } from './lib/session.js';
|
|
4
6
|
|
|
5
7
|
export const name = 'dsh-moli';
|
|
6
8
|
export const inject = ['tools'];
|
|
@@ -226,6 +228,151 @@ function renderProbe(_args, value) {
|
|
|
226
228
|
return [{ type: 'text', text: lines.join('\n') }];
|
|
227
229
|
}
|
|
228
230
|
|
|
231
|
+
/** Actions `moli_session` accepts. */
|
|
232
|
+
const SESSION_ACTIONS = ['start', 'open', 'snapshot', 'click', 'type', 'press', 'scroll', 'waitFor', 'eval', 'screenshot', 'status', 'close'];
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Parameters of `moli_session`.
|
|
236
|
+
*
|
|
237
|
+
* Raw JSON Schema with a root `type: "object"`, like the other tools: `schemaOf`
|
|
238
|
+
* projects this straight to the provider, so the author DSL would arrive as
|
|
239
|
+
* `type: null` and be rejected.
|
|
240
|
+
*/
|
|
241
|
+
const SESSION_PARAMETERS = {
|
|
242
|
+
type: 'object',
|
|
243
|
+
additionalProperties: false,
|
|
244
|
+
required: ['action'],
|
|
245
|
+
properties: {
|
|
246
|
+
action: {
|
|
247
|
+
type: 'string',
|
|
248
|
+
enum: SESSION_ACTIONS,
|
|
249
|
+
description: 'start: ensure the browser is running. open: navigate. snapshot: read the rendered page. '
|
|
250
|
+
+ 'click/type/press/scroll: interact. waitFor: wait for a selector to appear or disappear. '
|
|
251
|
+
+ 'eval: run JavaScript in the page. screenshot: write a PNG. status: report the session. close: stop the browser.',
|
|
252
|
+
},
|
|
253
|
+
url: { type: 'string', description: 'open: absolute http:// or https:// URL to navigate to.' },
|
|
254
|
+
selector: { type: 'string', description: 'CSS selector for click, type, scroll and waitFor.' },
|
|
255
|
+
text: { type: 'string', description: 'type: the text to enter.' },
|
|
256
|
+
submit: { type: 'boolean', description: 'type: press Enter after entering the text.' },
|
|
257
|
+
clear: { type: 'boolean', description: 'type: empty the field before entering text.' },
|
|
258
|
+
key: { type: 'string', description: 'press: key name, e.g. Enter, Tab, Escape, ArrowDown.' },
|
|
259
|
+
dy: { type: 'number', description: 'scroll: pixels to move down (negative moves up). Defaults to 800.' },
|
|
260
|
+
block: { type: 'string', enum: ['start', 'center', 'end', 'nearest'], description: 'scroll: where to align the element. Defaults to center.' },
|
|
261
|
+
state: { type: 'string', enum: ['attached', 'detached'], description: 'waitFor: wait for the selector to appear (attached) or vanish (detached).' },
|
|
262
|
+
expression: { type: 'string', description: 'eval: a JavaScript expression; promises are awaited and the value is returned.' },
|
|
263
|
+
writeTo: { type: 'string', description: 'screenshot: absolute path for the PNG. Required, since images are not returned inline.' },
|
|
264
|
+
format: { type: 'string', enum: ['png', 'jpeg'], description: 'screenshot: image format. Defaults to png.' },
|
|
265
|
+
includeHtml: { type: 'boolean', description: 'snapshot: also return the rendered HTML.' },
|
|
266
|
+
maxChars: { type: 'integer', description: 'snapshot: truncate the text to this many characters.' },
|
|
267
|
+
timeoutMs: { type: 'integer', description: 'Deadline for this action. Defaults to the plugin configuration.' },
|
|
268
|
+
port: { type: 'integer', description: 'start: CDP port. Defaults to the plugin configuration (9222).' },
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const SESSION_OUTPUT_SCHEMA = {
|
|
273
|
+
type: 'object',
|
|
274
|
+
additionalProperties: true,
|
|
275
|
+
required: ['ok'],
|
|
276
|
+
properties: {
|
|
277
|
+
ok: { type: 'boolean' },
|
|
278
|
+
action: { type: 'string' },
|
|
279
|
+
error: { type: 'string' },
|
|
280
|
+
hint: { type: 'string' },
|
|
281
|
+
url: { type: 'string' },
|
|
282
|
+
title: { type: 'string' },
|
|
283
|
+
text: { type: 'string' },
|
|
284
|
+
chars: { type: 'integer' },
|
|
285
|
+
truncated: { type: 'boolean' },
|
|
286
|
+
links: { type: 'array', items: { type: 'object', additionalProperties: true } },
|
|
287
|
+
fields: { type: 'array', items: { type: 'object', additionalProperties: true } },
|
|
288
|
+
html: { type: 'string' },
|
|
289
|
+
selector: { type: 'string' },
|
|
290
|
+
clicked: { type: 'object', additionalProperties: true },
|
|
291
|
+
navigated: { type: 'boolean' },
|
|
292
|
+
submitted: { type: 'boolean' },
|
|
293
|
+
value: {},
|
|
294
|
+
serialized: { type: 'string' },
|
|
295
|
+
key: { type: 'string' },
|
|
296
|
+
y: { type: 'number' },
|
|
297
|
+
height: { type: 'number' },
|
|
298
|
+
viewport: { type: 'number' },
|
|
299
|
+
state: { type: 'string' },
|
|
300
|
+
waitedMs: { type: 'integer' },
|
|
301
|
+
path: { type: 'string' },
|
|
302
|
+
bytes: { type: 'integer' },
|
|
303
|
+
format: { type: 'string' },
|
|
304
|
+
running: { type: 'boolean' },
|
|
305
|
+
ownedByThisPlugin: { type: 'boolean' },
|
|
306
|
+
external: { type: 'boolean' },
|
|
307
|
+
pid: { type: 'integer' },
|
|
308
|
+
endpoint: { type: 'string' },
|
|
309
|
+
logPath: { type: 'string' },
|
|
310
|
+
settled: { type: 'boolean' },
|
|
311
|
+
settledMs: { type: 'integer' },
|
|
312
|
+
stopped: { type: 'boolean' },
|
|
313
|
+
},
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Render a `moli_session` value for the model.
|
|
318
|
+
*
|
|
319
|
+
* A snapshot is the one result worth formatting rather than dumping as JSON: the
|
|
320
|
+
* link and field tables are what the next action usually depends on.
|
|
321
|
+
*
|
|
322
|
+
* @param _args - the call arguments, unused.
|
|
323
|
+
* @param value - the canonical result value.
|
|
324
|
+
* @returns result content blocks.
|
|
325
|
+
*/
|
|
326
|
+
function renderSession(_args, value) {
|
|
327
|
+
if (value.ok !== true) {
|
|
328
|
+
const lines = [`moli_session failed${typeof value.action === 'string' ? ` (${value.action})` : ''}: ${value.error ?? 'unknown error'}`];
|
|
329
|
+
if (typeof value.hint === 'string' && value.hint !== '') lines.push(value.hint);
|
|
330
|
+
if (typeof value.bytes === 'number') lines.push(`(the image was ${String(value.bytes)} bytes but could not be written)`);
|
|
331
|
+
return [{ type: 'text', text: lines.join('\n') }];
|
|
332
|
+
}
|
|
333
|
+
if (value.action === 'snapshot') {
|
|
334
|
+
const lines = [`${String(value.title)} — ${String(value.url)}`, ''];
|
|
335
|
+
lines.push(String(value.text ?? ''));
|
|
336
|
+
if (Array.isArray(value.links) && value.links.length > 0) {
|
|
337
|
+
lines.push('', `Links (${String(value.links.length)}):`);
|
|
338
|
+
for (const link of value.links.slice(0, 25)) lines.push(` ${String(link.text)} -> ${String(link.href)}`);
|
|
339
|
+
}
|
|
340
|
+
if (Array.isArray(value.fields) && value.fields.length > 0) {
|
|
341
|
+
lines.push('', `Fields (${String(value.fields.length)}):`);
|
|
342
|
+
for (const field of value.fields.slice(0, 25)) lines.push(` <${String(field.tag)}${field.type === '' ? '' : ` type=${String(field.type)}`}${field.name === '' ? '' : ` name=${String(field.name)}`}${field.id === '' ? '' : ` id=${String(field.id)}`}>`);
|
|
343
|
+
}
|
|
344
|
+
return [{ type: 'text', text: lines.join('\n') }];
|
|
345
|
+
}
|
|
346
|
+
if (value.action === 'status') {
|
|
347
|
+
return [{
|
|
348
|
+
type: 'text',
|
|
349
|
+
text: value.running === true
|
|
350
|
+
? `Session running at ${String(value.endpoint)}${value.external === true ? ' (started outside this plugin, so close will not stop it)' : ''}${value.url === undefined ? '' : `\nCurrent page: ${String(value.title)} — ${String(value.url)}`}`
|
|
351
|
+
: 'No session is running. Call moli_session with action "start" or "open" to launch one.',
|
|
352
|
+
}];
|
|
353
|
+
}
|
|
354
|
+
if (value.action === 'click') {
|
|
355
|
+
return [{
|
|
356
|
+
type: 'text',
|
|
357
|
+
text: `Clicked ${String(value.clicked?.tag ?? 'element')}${value.clicked?.text === undefined || value.clicked.text === '' ? '' : ` (“${String(value.clicked.text)}”)`}`
|
|
358
|
+
+ `${value.navigated === true ? `, which navigated to ${String(value.url)}` : '; the page did not navigate'}`,
|
|
359
|
+
}];
|
|
360
|
+
}
|
|
361
|
+
if (value.action === 'type') {
|
|
362
|
+
return [{ type: 'text', text: `Entered text${value.submitted === true ? ' and pressed Enter' : ''}; the field now reads ${JSON.stringify(String(value.value ?? ''))}` }];
|
|
363
|
+
}
|
|
364
|
+
if (value.action === 'screenshot') {
|
|
365
|
+
return [{ type: 'text', text: `Screenshot written to ${String(value.path)} (${String(value.bytes)} bytes). Read that file to see it.` }];
|
|
366
|
+
}
|
|
367
|
+
if (value.action === 'eval') {
|
|
368
|
+
return [{ type: 'text', text: String(value.serialized ?? '') }];
|
|
369
|
+
}
|
|
370
|
+
if (value.action === 'close') {
|
|
371
|
+
return [{ type: 'text', text: value.stopped === true ? 'Session stopped.' : 'Nothing to stop: no session was running that this plugin started.' }];
|
|
372
|
+
}
|
|
373
|
+
return [{ type: 'text', text: JSON.stringify(value) }];
|
|
374
|
+
}
|
|
375
|
+
|
|
229
376
|
/**
|
|
230
377
|
* Register the Moli tools.
|
|
231
378
|
*
|
|
@@ -355,5 +502,95 @@ export function apply(ctx, config) {
|
|
|
355
502
|
},
|
|
356
503
|
});
|
|
357
504
|
|
|
358
|
-
|
|
505
|
+
// ---------------------------------------------------------------------------
|
|
506
|
+
// moli_session: an interactive browser, for the work a one-shot fetch cannot do
|
|
507
|
+
// ---------------------------------------------------------------------------
|
|
508
|
+
//
|
|
509
|
+
// This is the plugin's only stateful tool. `moli serve` is a long-lived child
|
|
510
|
+
// that has to outlive a single call, so one manager is created per plugin
|
|
511
|
+
// instance and its lifecycle is explicit (`start` implicitly, `close` by hand).
|
|
512
|
+
// Registration stays synchronous: the server is spawned on first use, never at
|
|
513
|
+
// load time, so a plugin load cannot delay Host startup or open a port.
|
|
514
|
+
const session = new SessionManager({
|
|
515
|
+
get exe() { return cachedExe; },
|
|
516
|
+
port: options.sessionPort ?? 9222,
|
|
517
|
+
host: '127.0.0.1',
|
|
518
|
+
insecureTls,
|
|
519
|
+
proxy: options.proxy,
|
|
520
|
+
userAgent: options.sessionUserAgent,
|
|
521
|
+
profileDir: options.profileDir,
|
|
522
|
+
resources: options.sessionResources === true,
|
|
523
|
+
// Off, matching the engine's own default: `moli fetch <private-ip>` can reach
|
|
524
|
+
// a private address, so the plugin does not silently narrow what a user's
|
|
525
|
+
// installed engine already does. Set it to true, or pass blockCidrs, to have
|
|
526
|
+
// the engine refuse private addresses for directly connected targets.
|
|
527
|
+
blockPrivateNetworks: options.blockPrivateNetworks === true,
|
|
528
|
+
blockCidrs: Array.isArray(options.blockCidrs) ? options.blockCidrs : [],
|
|
529
|
+
startTimeoutMs: options.sessionStartTimeoutMs ?? 20000,
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
ctx.tools.register({
|
|
533
|
+
name: 'moli_session',
|
|
534
|
+
description: 'Drive an interactive browser through a persistent session, for what a one-shot fetch cannot'
|
|
535
|
+
+ ' do: click through consent walls, fill and submit a search box, scroll a lazy list, wait for a'
|
|
536
|
+
+ ' client-side route change, or run a script in the page. Keeps one browser alive across calls, so'
|
|
537
|
+
+ ' state (cookies, navigation, form input) persists until you stop it with action "close".'
|
|
538
|
+
+ ' Start with "open"; read the page with "snapshot"; interact with "click", "type", "press" and'
|
|
539
|
+
+ ' "scroll"; use "eval" as the escape hatch. Elements are addressed by CSS selector, never by pixel'
|
|
540
|
+
+ ' coordinates.',
|
|
541
|
+
parameters: SESSION_PARAMETERS,
|
|
542
|
+
output: { schema: SESSION_OUTPUT_SCHEMA, render: renderSession },
|
|
543
|
+
// One browser, one page: overlapping actions would interleave input events and
|
|
544
|
+
// navigate the page out from under each other.
|
|
545
|
+
isConcurrencySafe: () => false,
|
|
546
|
+
async execute(args, exec) {
|
|
547
|
+
// `status` and `close` are meaningful with nothing running, so they are
|
|
548
|
+
// answered before the engine is required.
|
|
549
|
+
if (args.action === 'close') {
|
|
550
|
+
const outcome = await session.close();
|
|
551
|
+
return { ok: true, action: 'close', ...outcome };
|
|
552
|
+
}
|
|
553
|
+
if (args.action === 'status') {
|
|
554
|
+
const state = await session.status();
|
|
555
|
+
return { ok: true, action: 'status', ...state };
|
|
556
|
+
}
|
|
557
|
+
if (args.action === 'start') {
|
|
558
|
+
if (!sessionSupported()) return { ok: false, action: 'start', error: sessionUnsupportedReason() };
|
|
559
|
+
const { external } = await session.ensure();
|
|
560
|
+
const state = await session.status();
|
|
561
|
+
return { ok: true, action: 'start', started: true, external, ...state };
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
if (engine() === undefined) {
|
|
565
|
+
return {
|
|
566
|
+
ok: false,
|
|
567
|
+
action: String(args.action),
|
|
568
|
+
error: 'moli was not found, so a session cannot be started',
|
|
569
|
+
hint: 'Call moli_probe with action "install", or set the plugin config field `exePath`.',
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
if (!sessionSupported()) return { ok: false, action: String(args.action), error: sessionUnsupportedReason() };
|
|
573
|
+
|
|
574
|
+
if (exec?.signal?.aborted === true) return { ok: false, action: String(args.action), error: 'the call was aborted before it started' };
|
|
575
|
+
|
|
576
|
+
try {
|
|
577
|
+
const { connection } = await session.ensure();
|
|
578
|
+
const result = await performAction(connection, args, { maxChars });
|
|
579
|
+
// A closed socket means the browser went away under us; drop it so the
|
|
580
|
+
// next call starts a fresh one instead of failing forever.
|
|
581
|
+
if (connection.closed === true) void session.close();
|
|
582
|
+
return result;
|
|
583
|
+
} catch (error) {
|
|
584
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
585
|
+
const hint = /timed out/u.test(message)
|
|
586
|
+
? 'The action exceeded its deadline. Raise timeoutMs, or check `waitFor` for a selector that never appears.'
|
|
587
|
+
: /ECONNREFUSED|closed|WebSocket/u.test(message)
|
|
588
|
+
? 'The browser connection was lost. Call moli_session with action "status", then "start" to relaunch it.'
|
|
589
|
+
: undefined;
|
|
590
|
+
return { ok: false, action: String(args.action), error: message, ...(hint === undefined ? {} : { hint }) };
|
|
591
|
+
}
|
|
592
|
+
},
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
ctx.logger?.info?.(`dsh-moli: registered moli_fetch, moli_probe and moli_session (${cachedExe ?? 'moli missing'})`);
|
|
359
596
|
}
|
package/lib/actions.js
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The actions a session can perform.
|
|
3
|
+
*
|
|
4
|
+
* Kept apart from {@link ./session.js} so the transport (server lifecycle, CDP
|
|
5
|
+
* plumbing) and the behaviour (what `click` means) can be read and tested
|
|
6
|
+
* separately. Every action takes the CDP connection and returns a plain value;
|
|
7
|
+
* none of them throws for a page-level problem, because "that selector matched
|
|
8
|
+
* nothing" is a result the caller has to see, not a transport failure.
|
|
9
|
+
*
|
|
10
|
+
* Selector handling is deliberately uniform: an element is located by CSS
|
|
11
|
+
* selector in the page, measured, and then driven through real input events. The
|
|
12
|
+
* alternative -- taking viewport coordinates from the caller -- makes the model
|
|
13
|
+
* guess pixels, and the coordinates go stale the moment the page reflows.
|
|
14
|
+
*
|
|
15
|
+
* @module dsh-moli/lib/actions
|
|
16
|
+
*/
|
|
17
|
+
import { existsSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { dirname } from 'node:path';
|
|
19
|
+
|
|
20
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
21
|
+
|
|
22
|
+
/** CSS-selector quoting, so a selector can be embedded in an expression safely. */
|
|
23
|
+
const quote = value => JSON.stringify(String(value));
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve a selector to a click point, or explain why there is not one.
|
|
27
|
+
*
|
|
28
|
+
* **This engine's geometry is document-absolute and ignores scrolling.**
|
|
29
|
+
* `getBoundingClientRect()` returns the same `y` at `scrollY` 0, 400 and 800, and
|
|
30
|
+
* `elementFromPoint()` agrees with it -- scanning the hit test at
|
|
31
|
+
* `rect.y + scrollY` returns null while `rect.y` hits the element. The mouse
|
|
32
|
+
* input APIs share that frame, so a click dispatched at `rect.y` lands even when
|
|
33
|
+
* the page is scrolled.
|
|
34
|
+
*
|
|
35
|
+
* Measured against moli 1.1.16. This is not the web platform's behaviour, and it
|
|
36
|
+
* matters: the textbook recipe of "scroll the element into view, then read the
|
|
37
|
+
* viewport rectangle" produces coordinates that are wrong by the scroll offset,
|
|
38
|
+
* and the failure is silent on a short page and total on a long one. Nothing here
|
|
39
|
+
* may depend on the scroll position.
|
|
40
|
+
*
|
|
41
|
+
* @returns `{ box, point, tag, text, covered }` on success, `{ error }` otherwise.
|
|
42
|
+
*/
|
|
43
|
+
async function boxOf(connection, selector) {
|
|
44
|
+
const expression = `(() => {
|
|
45
|
+
const el = document.querySelector(${quote(selector)});
|
|
46
|
+
if (el === null) return { missing: true };
|
|
47
|
+
const rect = el.getBoundingClientRect();
|
|
48
|
+
const style = getComputedStyle(el);
|
|
49
|
+
const cx = rect.x + rect.width / 2;
|
|
50
|
+
const cy = rect.y + rect.height / 2;
|
|
51
|
+
// The hit test is the authority on whether a click can land, and it uses the
|
|
52
|
+
// same frame as the rectangle. An element that is scrolled out of view still
|
|
53
|
+
// passes, because in this engine the input is not clipped to the viewport.
|
|
54
|
+
const found = document.elementFromPoint(cx, cy);
|
|
55
|
+
const hitSelf = found !== null && (found === el || el.contains(found) || found.contains(el));
|
|
56
|
+
return {
|
|
57
|
+
box: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
|
58
|
+
point: hitSelf ? { x: cx, y: cy } : null,
|
|
59
|
+
covered: hitSelf || found === null ? null : found.tagName.toLowerCase() + (found.id ? '#' + found.id : ''),
|
|
60
|
+
tag: el.tagName.toLowerCase(),
|
|
61
|
+
text: (el.innerText || el.value || el.getAttribute('aria-label') || '').trim().slice(0, 80),
|
|
62
|
+
visible: style.visibility !== 'hidden' && style.display !== 'none' && rect.width > 0 && rect.height > 0,
|
|
63
|
+
scrollY: window.scrollY,
|
|
64
|
+
viewportHeight: innerHeight,
|
|
65
|
+
};
|
|
66
|
+
})()`;
|
|
67
|
+
const result = await connection.evaluate(expression);
|
|
68
|
+
if (result.throws !== undefined) return { error: result.throws };
|
|
69
|
+
const value = result.value;
|
|
70
|
+
if (value === undefined) return { error: 'the page returned nothing for that selector' };
|
|
71
|
+
if (value.missing === true) return { error: `no element matches ${JSON.stringify(selector)}` };
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Measure a selector.
|
|
77
|
+
*
|
|
78
|
+
* `scrollIntoView` is attempted because it is harmless and does move the page for
|
|
79
|
+
* a human reading it, but nothing here depends on it: the geometry does not
|
|
80
|
+
* follow the scroll, so the measurement is identical either way. See {@link boxOf}
|
|
81
|
+
* for the engine detail and its consequence.
|
|
82
|
+
*/
|
|
83
|
+
async function ensureVisible(connection, selector) {
|
|
84
|
+
await connection.evaluate(`(() => {
|
|
85
|
+
const el = document.querySelector(${quote(selector)});
|
|
86
|
+
if (el !== null) el.scrollIntoView({ block: 'center', inline: 'center' });
|
|
87
|
+
})()`);
|
|
88
|
+
await sleep(50);
|
|
89
|
+
return boxOf(connection, selector);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Turn a measurement into a click that is known to land on the element.
|
|
94
|
+
*
|
|
95
|
+
* A click outside the viewport, or one whose point is covered by another element,
|
|
96
|
+
* is reported rather than dispatched: dispatching anyway produces a silent no-op
|
|
97
|
+
* that looks like success to the caller.
|
|
98
|
+
*/
|
|
99
|
+
async function clickSelector(connection, selector) {
|
|
100
|
+
const found = await ensureVisible(connection, selector);
|
|
101
|
+
if (found.error !== undefined) return { ok: false, error: found.error };
|
|
102
|
+
if (found.visible !== true) return { ok: false, error: `${JSON.stringify(selector)} is not visible (display:none or visibility:hidden)` };
|
|
103
|
+
if (found.point === null) {
|
|
104
|
+
// The engine's hit test is clipped to the viewport height while its geometry
|
|
105
|
+
// ignores scrolling, so an element whose absolute y is past the viewport
|
|
106
|
+
// cannot be reached by synthetic input at all -- scrolling does not change
|
|
107
|
+
// either number. Report that plainly and point at the route that does work.
|
|
108
|
+
const beyondViewport = found.box.y + found.box.height / 2 > (found.viewportHeight ?? 0);
|
|
109
|
+
return {
|
|
110
|
+
ok: false,
|
|
111
|
+
selector,
|
|
112
|
+
error: found.covered === null
|
|
113
|
+
? (beyondViewport
|
|
114
|
+
? `${JSON.stringify(selector)} is below the viewport (y=${String(Math.round(found.box.y))}) and this engine cannot send input there`
|
|
115
|
+
: `${JSON.stringify(selector)} has no box to click`)
|
|
116
|
+
: `${JSON.stringify(selector)} is covered by another element (${found.covered})`,
|
|
117
|
+
hint: beyondViewport
|
|
118
|
+
? 'Moli\'s hit test is bounded by the viewport height and its element geometry does not follow scrolling, so synthetic clicks cannot reach elements further down the document. Use eval to click it in the page instead -- for example `document.querySelector("SEL").click()` -- which bypasses the input layer entirely.'
|
|
119
|
+
: found.covered === null
|
|
120
|
+
? 'The element may be display:none, zero-sized, or only a wrapper.'
|
|
121
|
+
: 'Something is on top of it -- commonly a consent banner or a modal. Dismiss that first, or press Escape.',
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
await clickAt(connection, found.point.x, found.point.y);
|
|
125
|
+
return { ok: true, found, x: found.point.x, y: found.point.y };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Dispatch a full click: move, press, release. Real events, so page handlers run. */
|
|
129
|
+
async function clickAt(connection, x, y, { clickCount = 1 } = {}) {
|
|
130
|
+
await connection.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y });
|
|
131
|
+
await connection.send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount });
|
|
132
|
+
await connection.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Wait until the page has no pending load and the document is unchanged for a
|
|
137
|
+
* short settle window. Used after navigation and after clicks that may navigate.
|
|
138
|
+
*/
|
|
139
|
+
async function settle(connection, { quietMs = 250, timeoutMs = 20000 } = {}) {
|
|
140
|
+
const started = Date.now();
|
|
141
|
+
let lastSignature;
|
|
142
|
+
let stableSince = Date.now();
|
|
143
|
+
while (Date.now() - started < timeoutMs) {
|
|
144
|
+
const result = await connection.evaluate('document.readyState + "|" + document.title + "|" + location.href');
|
|
145
|
+
const signature = String(result.value);
|
|
146
|
+
if (signature !== lastSignature) {
|
|
147
|
+
lastSignature = signature;
|
|
148
|
+
stableSince = Date.now();
|
|
149
|
+
} else if (Date.now() - stableSince >= quietMs) {
|
|
150
|
+
return { settled: true, ms: Date.now() - started };
|
|
151
|
+
}
|
|
152
|
+
await sleep(100);
|
|
153
|
+
}
|
|
154
|
+
return { settled: false, ms: Date.now() - started };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** A readable, size-bounded view of the rendered page. */
|
|
158
|
+
async function snapshot(connection, { maxChars, includeHtml }) {
|
|
159
|
+
const expression = `(() => {
|
|
160
|
+
const text = document.body === null ? '' : document.body.innerText.trim();
|
|
161
|
+
const links = [...document.querySelectorAll('a[href]')].slice(0, 200).map(a => ({ text: (a.innerText || '').trim().slice(0, 80), href: a.href }));
|
|
162
|
+
return JSON.stringify({
|
|
163
|
+
url: location.href,
|
|
164
|
+
title: document.title,
|
|
165
|
+
text,
|
|
166
|
+
links,
|
|
167
|
+
forms: [...document.querySelectorAll('input,textarea,select')].slice(0, 50).map(el => ({
|
|
168
|
+
tag: el.tagName.toLowerCase(), type: el.type || '', name: el.name || '', id: el.id || '', placeholder: el.placeholder || '',
|
|
169
|
+
})),
|
|
170
|
+
html: ${includeHtml === true ? 'document.documentElement.outerHTML' : "''"},
|
|
171
|
+
});
|
|
172
|
+
})()`;
|
|
173
|
+
const result = await connection.evaluate(expression);
|
|
174
|
+
if (result.throws !== undefined) return { ok: false, error: result.throws };
|
|
175
|
+
const data = JSON.parse(String(result.value));
|
|
176
|
+
const limit = typeof maxChars === 'number' && maxChars > 0 ? maxChars : 200000;
|
|
177
|
+
const truncated = data.text.length > limit;
|
|
178
|
+
return {
|
|
179
|
+
ok: true,
|
|
180
|
+
url: data.url,
|
|
181
|
+
title: data.title,
|
|
182
|
+
text: truncated ? `${data.text.slice(0, limit)}\n\n… [dsh-moli] ${String(data.text.length - limit)} characters omitted; pass a larger maxChars to keep them.` : data.text,
|
|
183
|
+
chars: data.text.length,
|
|
184
|
+
truncated,
|
|
185
|
+
links: data.links,
|
|
186
|
+
fields: data.forms,
|
|
187
|
+
...(includeHtml === true ? { html: data.html.slice(0, limit) } : {}),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
// the action table
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Run one action against a live connection.
|
|
197
|
+
*
|
|
198
|
+
* @param connection - the CDP connection from {@link SessionManager#ensure}.
|
|
199
|
+
* @param args - the tool arguments.
|
|
200
|
+
* @param options - output limits and the resolved session manager.
|
|
201
|
+
* @returns the action's value.
|
|
202
|
+
*/
|
|
203
|
+
export async function performAction(connection, args, options) {
|
|
204
|
+
const action = args.action;
|
|
205
|
+
const timeoutMs = args.timeoutMs ?? 30000;
|
|
206
|
+
|
|
207
|
+
switch (action) {
|
|
208
|
+
case 'open': {
|
|
209
|
+
if (typeof args.url !== 'string' || args.url === '') return { ok: false, error: 'open needs a url' };
|
|
210
|
+
await connection.send('Page.navigate', { url: args.url }, timeoutMs);
|
|
211
|
+
const result = await settle(connection, { timeoutMs });
|
|
212
|
+
const url = await connection.evaluate('location.href');
|
|
213
|
+
const title = await connection.evaluate('document.title');
|
|
214
|
+
return { ok: true, action, url: url.value, title: title.value, settled: result.settled, settledMs: result.ms };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
case 'snapshot':
|
|
218
|
+
return snapshot(connection, { maxChars: args.maxChars ?? options.maxChars, includeHtml: args.includeHtml === true });
|
|
219
|
+
|
|
220
|
+
case 'click': {
|
|
221
|
+
if (typeof args.selector !== 'string' || args.selector === '') return { ok: false, error: 'click needs a selector' };
|
|
222
|
+
const before = await connection.evaluate('location.href');
|
|
223
|
+
const outcome = await clickSelector(connection, args.selector);
|
|
224
|
+
if (outcome.ok !== true) return { ok: false, action, selector: args.selector, ...outcome };
|
|
225
|
+
// A click may navigate, open a dialog, or only run a handler; settling
|
|
226
|
+
// covers the first, and reporting the URL change covers all three.
|
|
227
|
+
const settled = await settle(connection, { timeoutMs: Math.min(timeoutMs, 10000) });
|
|
228
|
+
const after = await connection.evaluate('location.href');
|
|
229
|
+
return {
|
|
230
|
+
ok: true,
|
|
231
|
+
action,
|
|
232
|
+
selector: args.selector,
|
|
233
|
+
clicked: { tag: outcome.found.tag, text: outcome.found.text, x: Math.round(outcome.x), y: Math.round(outcome.y) },
|
|
234
|
+
navigated: before.value !== after.value,
|
|
235
|
+
url: after.value,
|
|
236
|
+
settled: settled.settled,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
case 'type': {
|
|
241
|
+
if (typeof args.selector !== 'string' || args.selector === '') return { ok: false, error: 'type needs a selector' };
|
|
242
|
+
if (typeof args.text !== 'string') return { ok: false, error: 'type needs text' };
|
|
243
|
+
// Same measurement discipline as click: a field far down the page has to be
|
|
244
|
+
// scrolled into view before it can be focused and typed into reliably.
|
|
245
|
+
const found = await ensureVisible(connection, args.selector);
|
|
246
|
+
if (found.error !== undefined) return { ok: false, error: found.error, selector: args.selector };
|
|
247
|
+
// Focus through the page so the element is the real editing target, then
|
|
248
|
+
// clear it when asked: typing into a pre-filled field would append.
|
|
249
|
+
const focused = await connection.evaluate(
|
|
250
|
+
`(() => { const el = document.querySelector(${quote(args.selector)}); if (el === null) return 'missing'; el.focus(); ${args.clear === true ? 'if ("value" in el) { el.value = ""; }' : ''} return document.activeElement === el ? 'focused' : 'not-focused'; })()`);
|
|
251
|
+
if (focused.value !== 'focused') return { ok: false, error: `could not focus ${JSON.stringify(args.selector)} (${String(focused.value ?? focused.throws)})` };
|
|
252
|
+
await connection.send('Input.insertText', { text: args.text });
|
|
253
|
+
let submitted = false;
|
|
254
|
+
if (args.submit === true) {
|
|
255
|
+
await keyPress(connection, 'Enter');
|
|
256
|
+
submitted = true;
|
|
257
|
+
await settle(connection, { timeoutMs: Math.min(timeoutMs, 10000) });
|
|
258
|
+
}
|
|
259
|
+
const value = await connection.evaluate(`(() => { const el = document.querySelector(${quote(args.selector)}); return el === null ? null : (el.value ?? el.innerText ?? null); })()`);
|
|
260
|
+
return { ok: true, action, selector: args.selector, submitted, value: value.value };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
case 'press':
|
|
264
|
+
if (typeof args.key !== 'string' || args.key === '') return { ok: false, error: 'press needs key' };
|
|
265
|
+
await keyPress(connection, args.key);
|
|
266
|
+
return { ok: true, action, key: args.key };
|
|
267
|
+
|
|
268
|
+
case 'scroll': {
|
|
269
|
+
let expression;
|
|
270
|
+
if (typeof args.selector === 'string' && args.selector !== '') {
|
|
271
|
+
expression = `(() => { const el = document.querySelector(${quote(args.selector)}); if (el === null) return null; el.scrollIntoView({ block: '${String(args.block ?? 'center')}' }); return window.scrollY; })()`;
|
|
272
|
+
} else {
|
|
273
|
+
const dy = typeof args.dy === 'number' ? args.dy : 800;
|
|
274
|
+
expression = `(() => { window.scrollBy(0, ${String(dy)}); return window.scrollY; })()`;
|
|
275
|
+
}
|
|
276
|
+
const result = await connection.evaluate(expression);
|
|
277
|
+
if (result.value === null) return { ok: false, error: `no element matches ${JSON.stringify(String(args.selector))}` };
|
|
278
|
+
const metrics = await connection.evaluate('JSON.stringify({ y: window.scrollY, height: document.body === null ? 0 : document.body.scrollHeight, viewport: innerHeight })');
|
|
279
|
+
return { ok: true, action, ...JSON.parse(String(metrics.value)) };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
case 'waitFor': {
|
|
283
|
+
if (typeof args.selector !== 'string' || args.selector === '') return { ok: false, error: 'waitFor needs a selector' };
|
|
284
|
+
const deadline = Date.now() + timeoutMs;
|
|
285
|
+
const start = Date.now();
|
|
286
|
+
// `detached` means the selector matches nothing. It is deliberately not
|
|
287
|
+
// "hidden": a `display:none` element still matches, and conflating the two
|
|
288
|
+
// would make this wait forever on something the caller can already see is
|
|
289
|
+
// absent from the DOM.
|
|
290
|
+
const wantPresent = args.state !== 'detached';
|
|
291
|
+
for (;;) {
|
|
292
|
+
const present = (await connection.evaluate(`document.querySelector(${quote(args.selector)}) !== null`)).value === true;
|
|
293
|
+
if (present === wantPresent) {
|
|
294
|
+
return {
|
|
295
|
+
ok: true, action, selector: args.selector,
|
|
296
|
+
state: present ? 'attached' : 'detached',
|
|
297
|
+
waitedMs: Date.now() - start,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
if (Date.now() > deadline) {
|
|
301
|
+
return {
|
|
302
|
+
ok: false, action, selector: args.selector,
|
|
303
|
+
error: `timed out after ${String(timeoutMs)}ms waiting for ${JSON.stringify(args.selector)} to be ${wantPresent ? 'attached' : 'detached'}`,
|
|
304
|
+
// A hidden element still matches the selector, which is the usual
|
|
305
|
+
// reason someone reaches for `detached` and waits for ever.
|
|
306
|
+
hint: '`detached` means the element is absent from the DOM; a hidden element still matches the selector. Use eval to check getComputedStyle, or wait for the attribute or class that hides it.',
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
await sleep(150);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
case 'eval': {
|
|
314
|
+
if (typeof args.expression !== 'string' || args.expression === '') return { ok: false, error: 'eval needs expression' };
|
|
315
|
+
const result = await connection.evaluate(args.expression, { awaitPromise: true, timeoutMs });
|
|
316
|
+
if (result.throws !== undefined) return { ok: false, action, error: result.throws, hint: 'The expression threw. Check that the page is in the state you expect and that the selector matches an element.' };
|
|
317
|
+
const value = result.value;
|
|
318
|
+
const serialized = typeof value === 'string' ? value : JSON.stringify(value);
|
|
319
|
+
return { ok: true, action, value, serialized: serialized ?? 'undefined' };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
case 'screenshot': {
|
|
323
|
+
const result = await connection.send('Page.captureScreenshot', { format: args.format ?? 'png' }, timeoutMs);
|
|
324
|
+
const data = result.data;
|
|
325
|
+
if (typeof data !== 'string') return { ok: false, error: 'the engine returned no image data' };
|
|
326
|
+
if (typeof args.writeTo !== 'string' || args.writeTo === '') {
|
|
327
|
+
return { ok: false, error: 'screenshot needs writeTo (an absolute path): the image cannot be returned inline', bytes: Buffer.from(data, 'base64').length };
|
|
328
|
+
}
|
|
329
|
+
const directory = dirname(args.writeTo);
|
|
330
|
+
if (!existsSync(directory)) return { ok: false, error: `the destination directory does not exist: ${directory}` };
|
|
331
|
+
const buffer = Buffer.from(data, 'base64');
|
|
332
|
+
writeFileSync(args.writeTo, buffer);
|
|
333
|
+
return { ok: true, action, path: args.writeTo, bytes: buffer.length, format: args.format ?? 'png' };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
case 'status':
|
|
337
|
+
return { ok: true, action };
|
|
338
|
+
|
|
339
|
+
case 'close':
|
|
340
|
+
return { ok: true, action };
|
|
341
|
+
|
|
342
|
+
default:
|
|
343
|
+
return { ok: false, error: `unknown action ${JSON.stringify(String(action))}` };
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Press one key.
|
|
349
|
+
*
|
|
350
|
+
* The engine needs the key, the code and the legacy virtual key code to produce a
|
|
351
|
+
* complete event; `Enter` also needs the character fields, which is why the table
|
|
352
|
+
* carries more than the key name.
|
|
353
|
+
*/
|
|
354
|
+
const KEYS = {
|
|
355
|
+
Enter: { code: 'Enter', keyCode: 13, text: '\r' },
|
|
356
|
+
Tab: { code: 'Tab', keyCode: 9 },
|
|
357
|
+
Escape: { code: 'Escape', keyCode: 27 },
|
|
358
|
+
Backspace: { code: 'Backspace', keyCode: 8 },
|
|
359
|
+
ArrowUp: { code: 'ArrowUp', keyCode: 38 },
|
|
360
|
+
ArrowDown: { code: 'ArrowDown', keyCode: 40 },
|
|
361
|
+
ArrowLeft: { code: 'ArrowLeft', keyCode: 37 },
|
|
362
|
+
ArrowRight: { code: 'ArrowRight', keyCode: 39 },
|
|
363
|
+
PageUp: { code: 'PageUp', keyCode: 33 },
|
|
364
|
+
PageDown: { code: 'PageDown', keyCode: 34 },
|
|
365
|
+
Home: { code: 'Home', keyCode: 36 },
|
|
366
|
+
End: { code: 'End', keyCode: 35 },
|
|
367
|
+
Delete: { code: 'Delete', keyCode: 46 },
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
async function keyPress(connection, name) {
|
|
371
|
+
const spec = KEYS[name] ?? { code: name, keyCode: 0 };
|
|
372
|
+
const base = { key: name, code: spec.code, windowsVirtualKeyCode: spec.keyCode, nativeVirtualKeyCode: spec.keyCode };
|
|
373
|
+
const down = { ...base, type: spec.text === undefined ? 'rawKeyDown' : 'keyDown' };
|
|
374
|
+
if (spec.text !== undefined) down.text = spec.text;
|
|
375
|
+
await connection.send('Input.dispatchKeyEvent', down);
|
|
376
|
+
await connection.send('Input.dispatchKeyEvent', { ...base, type: 'keyUp' });
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Exported for tests: the selector-and-box step, without an input dispatch. */
|
|
380
|
+
export const __internal = { boxOf, ensureVisible, settle, snapshot, KEYS };
|
package/lib/session.js
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive browser sessions, backed by `moli serve` and driven over CDP.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists next to the fetch tool: `moli_fetch` is one shot -- it loads a
|
|
5
|
+
* page and prints it. That covers reading. It cannot click a cookie banner, fill
|
|
6
|
+
* a search box, scroll a lazy list, or wait for a client-side route change, which
|
|
7
|
+
* is most of what "use the web" means for an agent.
|
|
8
|
+
*
|
|
9
|
+
* `moli serve` already exposes Chrome DevTools Protocol, WebDriver Classic and
|
|
10
|
+
* WebDriver BiDi; this module talks CDP over the loopback WebSocket that the
|
|
11
|
+
* server publishes at `/json/list`. No dependency is added: Node's built-in
|
|
12
|
+
* `WebSocket` (21+) does the transport, which is why this feature needs a newer
|
|
13
|
+
* Node than the rest of the plugin.
|
|
14
|
+
*
|
|
15
|
+
* Two design constraints come from the plugin's history and shape everything here:
|
|
16
|
+
*
|
|
17
|
+
* - **A server is a long-lived child, unlike every other call in this plugin.**
|
|
18
|
+
* `runOnce` spawns, waits and reaps. A serve process must outlive a single tool
|
|
19
|
+
* call, so its lifecycle is explicit: started lazily on first use, held across
|
|
20
|
+
* calls, and stopped by `close`. Nothing here may block the DSH host, so the
|
|
21
|
+
* child is spawned asynchronously and every wait is cancellable.
|
|
22
|
+
* - **The sandbox blocks named pipes, not loopback TCP.** The plugin writes child
|
|
23
|
+
* stdout to real files for that reason. HTTP and WebSocket over 127.0.0.1 are
|
|
24
|
+
* fine -- verified under the restricted sandbox -- so CDP works where an
|
|
25
|
+
* inherited pipe would not.
|
|
26
|
+
*
|
|
27
|
+
* @module dsh-moli/lib/session
|
|
28
|
+
*/
|
|
29
|
+
import { spawn } from 'node:child_process';
|
|
30
|
+
import { closeSync, existsSync, mkdirSync, openSync, statSync, writeFileSync } from 'node:fs';
|
|
31
|
+
import { createConnection } from 'node:net';
|
|
32
|
+
import { tmpdir } from 'node:os';
|
|
33
|
+
import { dirname, join } from 'node:path';
|
|
34
|
+
|
|
35
|
+
/** CDP needs Node's built-in WebSocket, which arrived in 21. */
|
|
36
|
+
export const MIN_NODE_FOR_SESSION = '21.0.0';
|
|
37
|
+
|
|
38
|
+
/** Whether this Node can run a session at all. */
|
|
39
|
+
export function sessionSupported() {
|
|
40
|
+
return typeof globalThis.WebSocket === 'function';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The message used when it cannot, so callers do not have to phrase it. */
|
|
44
|
+
export function sessionUnsupportedReason() {
|
|
45
|
+
return `moli_session needs Node >= ${MIN_NODE_FOR_SESSION} for the built-in WebSocket; this is Node ${process.versions.node}. moli_fetch and moli_probe work on older versions.`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// small helpers
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
53
|
+
|
|
54
|
+
/** Whether something is listening on host:port, used before spawning a server. */
|
|
55
|
+
export function portListens(host, port, timeoutMs = 700) {
|
|
56
|
+
return new Promise(resolve => {
|
|
57
|
+
const socket = createConnection({ host, port });
|
|
58
|
+
const settle = value => {
|
|
59
|
+
socket.removeAllListeners();
|
|
60
|
+
socket.destroy();
|
|
61
|
+
resolve(value);
|
|
62
|
+
};
|
|
63
|
+
socket.setTimeout(timeoutMs);
|
|
64
|
+
socket.once('connect', () => settle(true));
|
|
65
|
+
socket.once('timeout', () => settle(false));
|
|
66
|
+
socket.once('error', () => settle(false));
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** GET a JSON document from the local server, with a deadline. */
|
|
71
|
+
async function getJson(url, timeoutMs) {
|
|
72
|
+
const controller = new AbortController();
|
|
73
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
74
|
+
try {
|
|
75
|
+
const response = await fetch(url, { signal: controller.signal });
|
|
76
|
+
if (!response.ok) throw new Error(`HTTP ${String(response.status)} for ${url}`);
|
|
77
|
+
return await response.json();
|
|
78
|
+
} finally {
|
|
79
|
+
clearTimeout(timer);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// CDP client
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A single WebSocket connection to one page target.
|
|
89
|
+
*
|
|
90
|
+
* `send` resolves with the CDP result or rejects with the protocol error, and
|
|
91
|
+
* never rejects on a target that has gone away without a reply -- the socket
|
|
92
|
+
* close path settles everything outstanding, so a caller cannot hang forever.
|
|
93
|
+
*/
|
|
94
|
+
class CdpConnection {
|
|
95
|
+
constructor(ws, targetId) {
|
|
96
|
+
this.ws = ws;
|
|
97
|
+
this.targetId = targetId;
|
|
98
|
+
this.nextId = 1;
|
|
99
|
+
this.pending = new Map();
|
|
100
|
+
this.listeners = new Set();
|
|
101
|
+
this.closed = false;
|
|
102
|
+
this.lastError = undefined;
|
|
103
|
+
|
|
104
|
+
ws.addEventListener('message', event => {
|
|
105
|
+
let message;
|
|
106
|
+
try { message = JSON.parse(String(event.data)); } catch { return; }
|
|
107
|
+
if (message.id !== undefined && this.pending.has(message.id)) {
|
|
108
|
+
const entry = this.pending.get(message.id);
|
|
109
|
+
this.pending.delete(message.id);
|
|
110
|
+
if (message.error !== undefined) entry.reject(new Error(`${entry.method}: ${String(message.error.message ?? JSON.stringify(message.error))}`));
|
|
111
|
+
else entry.resolve(message.result ?? {});
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (message.method !== undefined) {
|
|
115
|
+
for (const listener of this.listeners) {
|
|
116
|
+
try { listener(message); } catch { /* a listener must not break the socket */ }
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const failAll = reason => {
|
|
122
|
+
this.closed = true;
|
|
123
|
+
this.lastError = reason;
|
|
124
|
+
for (const [, entry] of this.pending) entry.reject(new Error(reason));
|
|
125
|
+
this.pending.clear();
|
|
126
|
+
};
|
|
127
|
+
ws.addEventListener('close', () => failAll('the CDP connection closed'));
|
|
128
|
+
ws.addEventListener('error', () => failAll('the CDP connection errored'));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Subscribe to protocol events; returns an unsubscribe function. */
|
|
132
|
+
onEvent(listener) {
|
|
133
|
+
this.listeners.add(listener);
|
|
134
|
+
return () => this.listeners.delete(listener);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Send one CDP command. */
|
|
138
|
+
send(method, params = {}, timeoutMs = 30000) {
|
|
139
|
+
if (this.closed) return Promise.reject(new Error(this.lastError ?? 'the CDP connection is closed'));
|
|
140
|
+
const id = this.nextId++;
|
|
141
|
+
return new Promise((resolve, reject) => {
|
|
142
|
+
const timer = setTimeout(() => {
|
|
143
|
+
this.pending.delete(id);
|
|
144
|
+
reject(new Error(`${method} timed out after ${String(timeoutMs)}ms`));
|
|
145
|
+
}, timeoutMs);
|
|
146
|
+
this.pending.set(id, {
|
|
147
|
+
method,
|
|
148
|
+
resolve: value => { clearTimeout(timer); resolve(value); },
|
|
149
|
+
reject: error => { clearTimeout(timer); reject(error); },
|
|
150
|
+
});
|
|
151
|
+
try {
|
|
152
|
+
this.ws.send(JSON.stringify({ id, method, params }));
|
|
153
|
+
} catch (error) {
|
|
154
|
+
this.pending.delete(id);
|
|
155
|
+
clearTimeout(timer);
|
|
156
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Evaluate an expression in the page and return its value.
|
|
163
|
+
*
|
|
164
|
+
* A thrown expression is returned as `{ throws }` rather than rejected: the
|
|
165
|
+
* caller is a model, and "your selector matched nothing" is information, not a
|
|
166
|
+
* transport failure.
|
|
167
|
+
*/
|
|
168
|
+
async evaluate(expression, { awaitPromise = false, timeoutMs = 30000 } = {}) {
|
|
169
|
+
const result = await this.send('Runtime.evaluate', {
|
|
170
|
+
expression,
|
|
171
|
+
returnByValue: true,
|
|
172
|
+
awaitPromise,
|
|
173
|
+
userGesture: true,
|
|
174
|
+
}, timeoutMs);
|
|
175
|
+
if (result.exceptionDetails !== undefined) {
|
|
176
|
+
const detail = result.exceptionDetails;
|
|
177
|
+
const text = detail.exception?.description ?? detail.text ?? 'the expression threw';
|
|
178
|
+
return { throws: String(text).split('\n')[0] };
|
|
179
|
+
}
|
|
180
|
+
return { value: result.result?.value };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
close() {
|
|
184
|
+
this.closed = true;
|
|
185
|
+
try { this.ws.close(); } catch { /* already gone */ }
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// server lifecycle
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Owns the `moli serve` child and the connection to it.
|
|
195
|
+
*
|
|
196
|
+
* One session per plugin instance. `ensure` is idempotent: it reuses a live
|
|
197
|
+
* child, and if the port is already served by someone else's moli (the user's own
|
|
198
|
+
* `moli serve`, another profile) it connects to that instead of failing to bind.
|
|
199
|
+
*/
|
|
200
|
+
export class SessionManager {
|
|
201
|
+
/**
|
|
202
|
+
* @param options - resolved engine path, plugin config, and a logger.
|
|
203
|
+
*/
|
|
204
|
+
constructor(options) {
|
|
205
|
+
this.options = options;
|
|
206
|
+
this.child = undefined;
|
|
207
|
+
this.connection = undefined;
|
|
208
|
+
this.port = options.port ?? 9222;
|
|
209
|
+
this.host = options.host ?? '127.0.0.1';
|
|
210
|
+
this.external = false;
|
|
211
|
+
this.pid = undefined;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The running child, or undefined when nothing was started by us. */
|
|
215
|
+
get started() {
|
|
216
|
+
return this.child !== undefined && this.child.exitCode === null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Start the server if needed and return a live CDP connection.
|
|
221
|
+
*
|
|
222
|
+
* @returns the connection, plus whether the server was already running.
|
|
223
|
+
*/
|
|
224
|
+
async ensure() {
|
|
225
|
+
if (this.connection !== undefined && this.connection.closed === false) {
|
|
226
|
+
return { connection: this.connection, external: this.external };
|
|
227
|
+
}
|
|
228
|
+
this.connection = undefined;
|
|
229
|
+
|
|
230
|
+
// Someone else may already serve this port: the user's own moli serve, or a
|
|
231
|
+
// previous run that outlived us. Connecting to it is friendlier than refusing
|
|
232
|
+
// to bind, and it is detectable: the CDP endpoint identifies the browser.
|
|
233
|
+
if (await portListens(this.host, this.port)) {
|
|
234
|
+
const version = await this.#probeVersion();
|
|
235
|
+
if (version !== undefined) {
|
|
236
|
+
const connection = await this.#connect();
|
|
237
|
+
this.external = true;
|
|
238
|
+
this.connection = connection;
|
|
239
|
+
return { connection, external: true, version };
|
|
240
|
+
}
|
|
241
|
+
throw new Error(
|
|
242
|
+
`port ${String(this.port)} is in use but does not answer as a CDP endpoint. `
|
|
243
|
+
+ 'Pass a different `port`, or stop whatever is listening there.');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (!this.options.exe) throw new Error('no moli engine is available to start a session');
|
|
247
|
+
|
|
248
|
+
const args = ['serve', '--host', this.host, '--port', String(this.port)];
|
|
249
|
+
// Layout is what makes geometry, hit-testing and screenshots real; without it
|
|
250
|
+
// the engine keeps compatibility geometry and clicks land in the wrong place.
|
|
251
|
+
args.push('--layout');
|
|
252
|
+
if (this.options.insecureTls === true) args.push('--insecure-disable-tls-host-verification');
|
|
253
|
+
if (this.options.proxy !== undefined && this.options.proxy !== '') args.push('--http-proxy', this.options.proxy);
|
|
254
|
+
if (this.options.userAgent !== undefined && this.options.userAgent !== '') args.push('--user-agent', this.options.userAgent);
|
|
255
|
+
if (this.options.profileDir !== undefined && this.options.profileDir !== '') args.push('--profile-dir', this.options.profileDir);
|
|
256
|
+
if (this.options.resources === true) args.push('--resource');
|
|
257
|
+
if (this.options.blockPrivateNetworks === true) args.push('--block-private-networks');
|
|
258
|
+
for (const cidr of this.options.blockCidrs ?? []) args.push('--block-cidrs', cidr);
|
|
259
|
+
|
|
260
|
+
// Child output goes to real files: the sandbox blocks inherited pipes, the
|
|
261
|
+
// same reason lib/moli.js redirects every other child.
|
|
262
|
+
const logPath = join(tmpdir(), `dsh-moli-serve-${String(process.pid)}.log`);
|
|
263
|
+
const outFd = openSync(logPath, 'w');
|
|
264
|
+
const child = spawn(this.options.exe, args, { stdio: ['ignore', outFd, outFd], windowsHide: true });
|
|
265
|
+
closeSync(outFd);
|
|
266
|
+
this.child = child;
|
|
267
|
+
this.pid = child.pid;
|
|
268
|
+
this.logPath = logPath;
|
|
269
|
+
|
|
270
|
+
const exited = new Promise(resolve => child.once('exit', code => resolve(code)));
|
|
271
|
+
// Wait for the port, or for the child to die trying.
|
|
272
|
+
const started = Date.now();
|
|
273
|
+
const deadline = started + (this.options.startTimeoutMs ?? 20000);
|
|
274
|
+
for (;;) {
|
|
275
|
+
if (child.exitCode !== null) {
|
|
276
|
+
throw new Error(`moli serve exited with ${String(child.exitCode)} before listening; see ${logPath}`);
|
|
277
|
+
}
|
|
278
|
+
if (await portListens(this.host, this.port, 400)) break;
|
|
279
|
+
if (Date.now() > deadline) {
|
|
280
|
+
child.kill('SIGTERM');
|
|
281
|
+
throw new Error(`moli serve did not listen on ${this.host}:${String(this.port)} within ${String(this.options.startTimeoutMs ?? 20000)}ms; see ${logPath}`);
|
|
282
|
+
}
|
|
283
|
+
await sleep(150);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// The socket can open a moment before the target is published.
|
|
287
|
+
let connection;
|
|
288
|
+
let lastError;
|
|
289
|
+
for (let attempt = 0; attempt < 20; attempt++) {
|
|
290
|
+
try { connection = await this.#connect(); break; }
|
|
291
|
+
catch (error) { lastError = error; await sleep(150); }
|
|
292
|
+
}
|
|
293
|
+
if (connection === undefined) {
|
|
294
|
+
child.kill('SIGTERM');
|
|
295
|
+
throw new Error(`moli serve is listening on port ${String(this.port)} but no page target was reachable: ${String(lastError?.message ?? 'unknown')}`);
|
|
296
|
+
}
|
|
297
|
+
void exited;
|
|
298
|
+
this.external = false;
|
|
299
|
+
this.connection = connection;
|
|
300
|
+
return { connection, external: false };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Ask the CDP endpoint who it is; undefined when it is not a CDP endpoint. */
|
|
304
|
+
async #probeVersion() {
|
|
305
|
+
try {
|
|
306
|
+
const version = await getJson(`http://${this.host}:${String(this.port)}/json/version`, 3000);
|
|
307
|
+
return typeof version.Browser === 'string' ? version : undefined;
|
|
308
|
+
} catch {
|
|
309
|
+
return undefined;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Open a WebSocket to the first page target and enable the domains we use. */
|
|
314
|
+
async #connect() {
|
|
315
|
+
if (!sessionSupported()) throw new Error(sessionUnsupportedReason());
|
|
316
|
+
const targets = await getJson(`http://${this.host}:${String(this.port)}/json/list`, 5000);
|
|
317
|
+
const page = Array.isArray(targets) ? targets.find(t => t.type === 'page' || t.type === undefined) : undefined;
|
|
318
|
+
if (page === undefined || typeof page.webSocketDebuggerUrl !== 'string') {
|
|
319
|
+
throw new Error(`no page target on http://${this.host}:${String(this.port)}/json/list`);
|
|
320
|
+
}
|
|
321
|
+
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
|
322
|
+
await new Promise((resolve, reject) => {
|
|
323
|
+
const timer = setTimeout(() => reject(new Error('the CDP WebSocket did not open in time')), 10000);
|
|
324
|
+
ws.addEventListener('open', () => { clearTimeout(timer); resolve(); }, { once: true });
|
|
325
|
+
ws.addEventListener('error', () => { clearTimeout(timer); reject(new Error('the CDP WebSocket failed to open')); }, { once: true });
|
|
326
|
+
});
|
|
327
|
+
const connection = new CdpConnection(ws, page.id);
|
|
328
|
+
for (const domain of ['Page.enable', 'Runtime.enable', 'DOM.enable']) {
|
|
329
|
+
await connection.send(domain, {}, 10000);
|
|
330
|
+
}
|
|
331
|
+
return connection;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Stop the server if we started it. A reused external server is left alone. */
|
|
335
|
+
async close() {
|
|
336
|
+
const connection = this.connection;
|
|
337
|
+
this.connection = undefined;
|
|
338
|
+
if (connection !== undefined) connection.close();
|
|
339
|
+
const child = this.child;
|
|
340
|
+
this.child = undefined;
|
|
341
|
+
this.pid = undefined;
|
|
342
|
+
const wasExternal = this.external;
|
|
343
|
+
this.external = false;
|
|
344
|
+
if (child === undefined || child.exitCode !== null) {
|
|
345
|
+
return { stopped: false, external: wasExternal };
|
|
346
|
+
}
|
|
347
|
+
child.kill('SIGTERM');
|
|
348
|
+
const deadline = Date.now() + 5000;
|
|
349
|
+
while (child.exitCode === null && Date.now() < deadline) await sleep(100);
|
|
350
|
+
if (child.exitCode === null) child.kill('SIGKILL');
|
|
351
|
+
return { stopped: true, external: false, logPath: this.logPath };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Diagnostic view of the session, for the tool's `status` action. */
|
|
355
|
+
async status() {
|
|
356
|
+
const running = this.started || this.external;
|
|
357
|
+
const base = {
|
|
358
|
+
running,
|
|
359
|
+
ownedByThisPlugin: this.started,
|
|
360
|
+
external: this.external,
|
|
361
|
+
pid: this.pid,
|
|
362
|
+
endpoint: `http://${this.host}:${String(this.port)}`,
|
|
363
|
+
logPath: this.logPath,
|
|
364
|
+
};
|
|
365
|
+
if (!running) return base;
|
|
366
|
+
const connection = this.connection;
|
|
367
|
+
if (connection === undefined) return base;
|
|
368
|
+
const url = await connection.evaluate('location.href');
|
|
369
|
+
const title = await connection.evaluate('document.title');
|
|
370
|
+
return { ...base, url: url.value, title: title.value };
|
|
371
|
+
}
|
|
372
|
+
}
|
package/package.json
CHANGED