dsh-moli 1.1.10 → 1.1.12
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 +125 -0
- package/README.zh.md +112 -0
- package/index.js +299 -1
- package/lib/actions.js +460 -0
- package/lib/session.js +632 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -223,6 +223,102 @@ 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
|
+
### Choosing a browser: `moli` or `chromium`
|
|
251
|
+
|
|
252
|
+
`moli_session` can drive either engine, and you choose which — there is **no automatic fallback**,
|
|
253
|
+
because switching engines when one click fails would make the timing and the behaviour depend on which
|
|
254
|
+
operation happened to fail, and you could not tell which browser answered.
|
|
255
|
+
|
|
256
|
+
```yaml
|
|
257
|
+
config:
|
|
258
|
+
sessionBrowser: moli # moli (default) or chromium
|
|
259
|
+
sessionBrowserPath: '' # optional path to chrome.exe / msedge.exe / brave
|
|
260
|
+
sessionPort: 9222
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
**Why the choice exists.** Moli's element geometry is document-absolute and does not follow scrolling,
|
|
264
|
+
and its hit test is bounded by the viewport height. Measured on Moli 1.1.16 and Chrome 155, same page,
|
|
265
|
+
same element:
|
|
266
|
+
|
|
267
|
+
| | `rect.y` at scrollY 0 / 400 / 800 | click a control below the first viewport |
|
|
268
|
+
|---|---|---|
|
|
269
|
+
| **moli** | 1008 / 1008 / 1008 — never moves | **impossible**; synthetic input cannot reach it |
|
|
270
|
+
| **chromium** | 1008 / 608 / 208 — follows the scroll | **works**; the browser scrolls to it |
|
|
271
|
+
|
|
272
|
+
So moli is the good default — it is the engine this plugin wraps, it is lighter, and it is already
|
|
273
|
+
installed — and a real Chromium is what you switch to when a task needs an element further down a long
|
|
274
|
+
page. When moli refuses, it says so, names the element, and points at both routes:
|
|
275
|
+
|
|
276
|
+
```
|
|
277
|
+
moli_session action=click selector="#far-down"
|
|
278
|
+
→ "#far-down" is below the viewport (y=2548) and this engine cannot send input there
|
|
279
|
+
|
|
280
|
+
# either click from inside the page (reaches it, but skips hover and focus behaviour)
|
|
281
|
+
moli_session action=eval expression="document.querySelector('#far-down').click()"
|
|
282
|
+
|
|
283
|
+
# or use a real browser for that session
|
|
284
|
+
config: sessionBrowser: chromium
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
With `chromium` the plugin starts the browser it finds (Chrome, Edge or Brave, or `sessionBrowserPath`)
|
|
288
|
+
in `--headless=new` mode with its own profile, so it does not touch your normal browsing profile.
|
|
289
|
+
|
|
290
|
+
### Driving a browser you already have open
|
|
291
|
+
|
|
292
|
+
Pass `cdpEndpoint` to attach to a CDP port instead of starting anything. This is the only way to inherit
|
|
293
|
+
your own cookies and logins: start your browser with `--remote-debugging-port=9222`, then
|
|
294
|
+
|
|
295
|
+
```
|
|
296
|
+
moli_session action=open url=https://example.com cdpEndpoint=http://127.0.0.1:9222
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
The endpoint applies to that call only. A browser this plugin did not start is **never stopped by
|
|
300
|
+
`close`**, and `status` reports `external: true` so you can tell.
|
|
301
|
+
|
|
302
|
+
### `cdp` — the raw protocol escape hatch
|
|
303
|
+
|
|
304
|
+
Everything the named actions do is an abstraction chosen in advance. `action=cdp` is not:
|
|
305
|
+
|
|
306
|
+
```
|
|
307
|
+
moli_session action=cdp method=Browser.getVersion
|
|
308
|
+
moli_session action=cdp method=Network.setExtraHTTPHeaders params={"headers":{"X-Thing":"1"}}
|
|
309
|
+
moli_session action=cdp method=DOM.setFileInputFiles params={"files":["/abs/a.png"],"nodeId":42}
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
It reaches what the wrapper does not: other targets, cross-origin frames, file inputs, extra headers,
|
|
313
|
+
network interception. `params` may be an object or a JSON string.
|
|
314
|
+
|
|
315
|
+
The session needs **Node 21 or newer** for the built-in `WebSocket`; on Node 20 the tool answers with
|
|
316
|
+
that reason instead of failing obscurely. `moli_fetch` and `moli_probe` still work on 20.12.
|
|
317
|
+
|
|
318
|
+
`moli_session` also takes `port` (default 9222). If that port is already served by a `moli serve` you
|
|
319
|
+
started yourself, the tool connects to it rather than failing to bind — and `close` then leaves it
|
|
320
|
+
alone, because it is not ours to stop.
|
|
321
|
+
|
|
226
322
|
## Relationship to Moli's own agent skills
|
|
227
323
|
|
|
228
324
|
Moli ships official agent skills in its repository — [`moli-webfetch`](https://github.com/lexmount/moli/tree/main/skills/moli-webfetch),
|
|
@@ -310,12 +406,26 @@ Override any field under the plugin's `config` entry in the profile patch layer.
|
|
|
310
406
|
maxChars: 200000 # inline text truncation
|
|
311
407
|
proxy: 'http://127.0.0.1:7890' # forward proxy for moli, downloads, and the installer's engine download
|
|
312
408
|
insecureTls: true # see the TLS notes below
|
|
409
|
+
sessionBrowser: moli # or chromium, for the session tool
|
|
410
|
+
sessionBrowserPath: '' # explicit chrome.exe / msedge.exe / brave
|
|
411
|
+
sessionPort: 9222 # CDP port for moli_session
|
|
412
|
+
blockPrivateNetworks: false # refuse private addresses (see the note below)
|
|
413
|
+
blockCidrs: [] # extra CIDRs to refuse
|
|
313
414
|
```
|
|
314
415
|
|
|
315
416
|
The plugin-level `proxy` above is the default; `moli_fetch` also takes a `proxy` argument, so a single
|
|
316
417
|
call can route through a different proxy without changing the profile. A loopback URL always bypasses
|
|
317
418
|
whichever proxy is in effect.
|
|
318
419
|
|
|
420
|
+
`blockPrivateNetworks` is **off by default, matching the engine**: `moli fetch http://10.0.0.1/` reaches
|
|
421
|
+
a private address, so the plugin does not silently narrow what the moli you installed already does.
|
|
422
|
+
Turn it on, or list `blockCidrs`, when the agent is following links on pages you do not control and
|
|
423
|
+
you want the engine to refuse private destinations. Two caveats worth knowing before you rely on it:
|
|
424
|
+
moli documents the policy as applying to **directly connected** targets, so a configured `proxy`
|
|
425
|
+
routes around it, and only `blockCidrs` (which the proxy must enforce) covers that path. The sharpest
|
|
426
|
+
target this protects is the cloud metadata address `169.254.169.254`, which hands out instance
|
|
427
|
+
credentials on a VM.
|
|
428
|
+
|
|
319
429
|
Without `exePath`, the plugin probes `exePath` → `MOLI_BIN` → `%LOCALAPPDATA%\Moli\bin\moli.exe` →
|
|
320
430
|
`%USERPROFILE%\.local\bin\moli.exe`, then scans inside `%LOCALAPPDATA%\Moli` for a `moli.exe`, so an
|
|
321
431
|
installation under a versioned subdirectory is still found.
|
|
@@ -378,6 +488,21 @@ Four suites live in [`verify/`](verify/), and all are runnable against a working
|
|
|
378
488
|
`dump` discards its value, an expression that throws is classified as such instead of being blamed
|
|
379
489
|
on the installation, truncation keeps the tail as well as the head, `proxy` is a per-call parameter,
|
|
380
490
|
and `moli_probe` states which process its `httpsOk` describes.
|
|
491
|
+
- `session-verify.mjs` drives `moli_session` end to end against a `data:` URL fixture, so it needs no
|
|
492
|
+
network and no site stays still underneath it: lifecycle and port release, snapshot, click, type
|
|
493
|
+
with and without clearing, press, scroll, `waitFor` in both directions, eval including a throwing
|
|
494
|
+
expression, screenshot byte signature, navigation by clicking a link, restart after close, and the
|
|
495
|
+
viewport limit described above — which is asserted as a limit, with the `eval` route that works
|
|
496
|
+
around it asserted next to it. It skips when no engine is installed.
|
|
497
|
+
- `session-backends.mjs` is the comparison that justifies the second backend: the same page, the same
|
|
498
|
+
control below the viewport, asserted to be **refused on moli and clicked on Chromium**, plus the
|
|
499
|
+
`cdp` action on both. The Chromium half skips when the machine has no Chromium-family browser.
|
|
500
|
+
- `session-attach-verify.mjs` covers external endpoints and ownership: attaching to a browser this
|
|
501
|
+
plugin did not start, identifying it correctly, refusing to stop it on `close`, and — the trap that
|
|
502
|
+
a behavioural probe got wrong — classifying an external **moli** as moli even though it answers
|
|
503
|
+
`/json/version` with `"Browser":"Chrome/145.0.0.0"`. It also asserts that naming a per-call endpoint
|
|
504
|
+
does not latch it, which an earlier version did: one call against a dead endpoint left the session
|
|
505
|
+
pointing at port 9 for the rest of the process.
|
|
381
506
|
|
|
382
507
|
`.github/workflows/verify.yml` runs the first two on Linux, Windows and macOS (they need no engine and
|
|
383
508
|
no network) and attempts the engine-dependent suites on Linux and Windows, reporting a missing engine
|
package/README.zh.md
CHANGED
|
@@ -209,6 +209,96 @@ 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
|
+
### 选哪个浏览器:`moli` 还是 `chromium`
|
|
235
|
+
|
|
236
|
+
`moli_session` 可以驱动两种引擎,由你选择——**没有自动降级**。因为"某个点击失败就换引擎"会让工具的耗时和
|
|
237
|
+
行为取决于哪一步恰好失败,而且你无法知道最后是哪个浏览器在回答。
|
|
238
|
+
|
|
239
|
+
```yaml
|
|
240
|
+
config:
|
|
241
|
+
sessionBrowser: moli # moli(默认)或 chromium
|
|
242
|
+
sessionBrowserPath: '' # 可选,指定 chrome.exe / msedge.exe / brave
|
|
243
|
+
sessionPort: 9222
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
**为什么需要这个选择。** moli 的元素几何是**文档绝对坐标且不随滚动更新**,命中测试又受视口高度限制。在
|
|
247
|
+
Moli 1.1.16 与 Chrome 155 上,同一页面、同一元素实测:
|
|
248
|
+
|
|
249
|
+
| | `rect.y` 在 scrollY 0 / 400 / 800 | 点击首个视口以下的控件 |
|
|
250
|
+
|---|---|---|
|
|
251
|
+
| **moli** | 1008 / 1008 / 1008 —— 纹丝不动 | **做不到**,合成输入触达不了 |
|
|
252
|
+
| **chromium** | 1008 / 608 / 208 —— 跟随滚动 | **可以**,浏览器会滚动到它 |
|
|
253
|
+
|
|
254
|
+
所以 moli 是合适的默认值——它是本插件包装的引擎、更轻、而且你已经装了;而当任务需要长页面下方的元素时,
|
|
255
|
+
就换成真实 Chromium。moli 拒绝时会把原因、元素和两条出路都讲清楚:
|
|
256
|
+
|
|
257
|
+
```
|
|
258
|
+
moli_session action=click selector="#far-down"
|
|
259
|
+
→ "#far-down" is below the viewport (y=2548) and this engine cannot send input there
|
|
260
|
+
|
|
261
|
+
# 要么在页面内部点击(能触达,但会跳过 hover 与 focus 行为)
|
|
262
|
+
moli_session action=eval expression="document.querySelector('#far-down').click()"
|
|
263
|
+
|
|
264
|
+
# 要么这个会话改用真实浏览器
|
|
265
|
+
config: sessionBrowser: chromium
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
选 `chromium` 时,插件会在 `--headless=new` 模式下用**独立 profile** 启动它找到的浏览器(Chrome、Edge 或
|
|
269
|
+
Brave,也可用 `sessionBrowserPath` 指定),不会碰你日常使用的浏览器配置。
|
|
270
|
+
|
|
271
|
+
### 驱动你自己已经开着的浏览器
|
|
272
|
+
|
|
273
|
+
传 `cdpEndpoint` 即可连接到已有的 CDP 端口,什么都不启动。这是**继承你自己 cookie 与登录态的唯一途径**:
|
|
274
|
+
用 `--remote-debugging-port=9222` 启动你的浏览器,然后
|
|
275
|
+
|
|
276
|
+
```
|
|
277
|
+
moli_session action=open url=https://example.com cdpEndpoint=http://127.0.0.1:9222
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
该端点**只对当次调用生效**。不是本插件启动的浏览器**永远不会被 `close` 关掉**,并且 `status` 会报
|
|
281
|
+
`external: true` 让你能分辨。
|
|
282
|
+
|
|
283
|
+
### `cdp` —— 原始协议逃生舱
|
|
284
|
+
|
|
285
|
+
具名动作全都是"预先替你决定好的抽象",而 `action=cdp` 不是:
|
|
286
|
+
|
|
287
|
+
```
|
|
288
|
+
moli_session action=cdp method=Browser.getVersion
|
|
289
|
+
moli_session action=cdp method=Network.setExtraHTTPHeaders params={"headers":{"X-Thing":"1"}}
|
|
290
|
+
moli_session action=cdp method=DOM.setFileInputFiles params={"files":["/绝对/a.png"],"nodeId":42}
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
它能做到包装层做不到的事:切换 target、跨域 iframe、文件上传、额外的请求头、网络拦截。`params` 可以是对象
|
|
294
|
+
或 JSON 字符串。
|
|
295
|
+
|
|
296
|
+
本工具需要 **Node 21 或更新版本**(内置 `WebSocket`);在 Node 20 上会直接说明原因,而不是给出费解的失败。
|
|
297
|
+
`moli_fetch` 与 `moli_probe` 仍支持 20.12。
|
|
298
|
+
|
|
299
|
+
`moli_session` 也接受 `port`(默认 9222)。如果该端口已经由你自己启动的 `moli serve` 占用,工具会连接它
|
|
300
|
+
而不是绑定失败——此时 `close` **不会**停掉它,因为那不是我们的进程。
|
|
301
|
+
|
|
212
302
|
## 与 Moli 官方 agent skill 的关系
|
|
213
303
|
|
|
214
304
|
Moli 仓库自带官方 agent skill:[`moli-webfetch`](https://github.com/lexmount/moli/tree/main/skills/moli-webfetch)、
|
|
@@ -284,11 +374,22 @@ cookie 驱动的查询参数。那次实测同时覆盖了代理下载路由与
|
|
|
284
374
|
maxChars: 200000 # 内联文本截断阈值
|
|
285
375
|
proxy: 'http://127.0.0.1:7890' # 转给本地正向代理(moli、下载、安装器内部引擎下载都走它)
|
|
286
376
|
insecureTls: true # 见下方 TLS 说明
|
|
377
|
+
sessionBrowser: moli # 或 chromium,供 session 工具使用
|
|
378
|
+
sessionBrowserPath: '' # 显式指定 chrome.exe / msedge.exe / brave
|
|
379
|
+
sessionPort: 9222 # moli_session 的 CDP 端口
|
|
380
|
+
blockPrivateNetworks: false # 拒绝私网地址(见下方说明)
|
|
381
|
+
blockCidrs: [] # 额外拒绝的 CIDR
|
|
287
382
|
```
|
|
288
383
|
|
|
289
384
|
上面配置里的 `proxy` 是默认值;`moli_fetch` 也能接收 `proxy` 参数,因此单次调用可以走另一个代理而不必改
|
|
290
385
|
profile。无论生效的是哪个代理,loopback 地址一律绕过它。
|
|
291
386
|
|
|
387
|
+
`blockPrivateNetworks` **默认关闭,与引擎一致**:`moli fetch http://10.0.0.1/` 本来就能访问私网地址,插件
|
|
388
|
+
不会悄悄收窄你已安装的 moli 的能力。当 agent 会在你无法控制的页面上跟着链接走、而你希望引擎拒绝私网目标时,
|
|
389
|
+
再打开它或配置 `blockCidrs`。依赖它之前有两点需要知道:moli 文档写明该策略只作用于**直连**目标,因此配置了
|
|
390
|
+
`proxy` 就会绕过它,只有 `blockCidrs`(由代理执行)能覆盖那条路径。这个开关保护的最锐利目标是云元数据地址
|
|
391
|
+
`169.254.169.254`——在虚拟机上它会交出实例凭据。
|
|
392
|
+
|
|
292
393
|
未设置 `exePath` 时探测顺序:`exePath` → 环境变量 `MOLI_BIN` → `%LOCALAPPDATA%\Moli\bin\moli.exe`
|
|
293
394
|
→ `%USERPROFILE%\.local\bin\moli.exe`,随后还会在 `%LOCALAPPDATA%\Moli` 内递归查找 `moli.exe`,
|
|
294
395
|
所以装在带版本号的子目录里也能找到。
|
|
@@ -335,6 +436,17 @@ profile。无论生效的是哪个代理,loopback 地址一律绕过它。
|
|
|
335
436
|
- `report-verify.mjs` 守住一份使用体验报告里的发现:`eval` 与 `dump` 同用会丢弃表达式值(已在参数描述里
|
|
336
437
|
写明)、表达式抛异常时被正确归类而不是甩锅给"安装有问题"、截断同时保留头尾、`proxy` 是可按调用传入的参数、
|
|
337
438
|
以及 `moli_probe` 会说明它的 `httpsOk` 描述的是哪个进程。
|
|
439
|
+
- `session-verify.mjs` 用 `data:` URL 夹具端到端驱动 `moli_session`,因此不依赖网络、也不会因为站点变化而
|
|
440
|
+
失效:生命周期与端口释放、snapshot、click、带与不带清空的 type、press、scroll、两个方向的 `waitFor`、
|
|
441
|
+
含抛异常的 eval、截图字节签名、点击链接导航、关闭后重启,以及上面那条视口限制——限制被当作**限制**断言,
|
|
442
|
+
紧随其后断言真正能绕过它的 `eval` 路径。未安装引擎时跳过。
|
|
443
|
+
- `session-backends.mjs` 是**证明第二个后端存在必要性**的对照:同一页面、同一个视口以下的控件,断言它
|
|
444
|
+
**在 moli 上被拒绝、在 Chromium 上被点中**,并在两者上都验证 `cdp` 动作。机器上没有 Chromium 系浏览器时
|
|
445
|
+
跳过 Chromium 那半。
|
|
446
|
+
- `session-attach-verify.mjs` 覆盖外部端点与归属权:连接不是本插件启动的浏览器、正确识别它、`close` 时拒绝
|
|
447
|
+
停掉它;以及那个**行为探测会搞错的陷阱**——外部端点是 **moli** 时仍判定为 moli,尽管它在 `/json/version`
|
|
448
|
+
里报的是 `"Browser":"Chrome/145.0.0.0"`。它还断言按调用传入的端点**不会粘住**——早期版本会:一次对死端点
|
|
449
|
+
的调用会让整个进程后续都指向 9 端口。
|
|
338
450
|
|
|
339
451
|
`.github/workflows/verify.yml` 在 Linux、Windows、macOS 上跑前两个(无需引擎、无需网络),在 Linux 与 Windows
|
|
340
452
|
上尝试依赖引擎的几套;若 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,174 @@ 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', 'cdp', '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. cdp: send a raw Chrome DevTools Protocol command. '
|
|
252
|
+
+ 'screenshot: write a PNG. status: report the session and which browser it uses. close: stop the browser.',
|
|
253
|
+
},
|
|
254
|
+
url: { type: 'string', description: 'open: absolute http:// or https:// URL to navigate to.' },
|
|
255
|
+
selector: { type: 'string', description: 'CSS selector for click, type, scroll and waitFor.' },
|
|
256
|
+
text: { type: 'string', description: 'type: the text to enter.' },
|
|
257
|
+
submit: { type: 'boolean', description: 'type: press Enter after entering the text.' },
|
|
258
|
+
clear: { type: 'boolean', description: 'type: empty the field before entering text.' },
|
|
259
|
+
key: { type: 'string', description: 'press: key name, e.g. Enter, Tab, Escape, ArrowDown.' },
|
|
260
|
+
dy: { type: 'number', description: 'scroll: pixels to move down (negative moves up). Defaults to 800.' },
|
|
261
|
+
block: { type: 'string', enum: ['start', 'center', 'end', 'nearest'], description: 'scroll: where to align the element. Defaults to center.' },
|
|
262
|
+
state: { type: 'string', enum: ['attached', 'detached'], description: 'waitFor: wait for the selector to appear (attached) or vanish (detached).' },
|
|
263
|
+
expression: { type: 'string', description: 'eval: a JavaScript expression; promises are awaited and the value is returned.' },
|
|
264
|
+
method: { type: 'string', description: 'cdp: the protocol method to invoke, e.g. "Network.enable" or "DOM.setFileInputFiles".' },
|
|
265
|
+
params: { description: 'cdp: the method parameters, as an object or a JSON string. Optional.' },
|
|
266
|
+
writeTo: { type: 'string', description: 'screenshot: absolute path for the PNG. Required, since images are not returned inline.' },
|
|
267
|
+
format: { type: 'string', enum: ['png', 'jpeg'], description: 'screenshot: image format. Defaults to png.' },
|
|
268
|
+
includeHtml: { type: 'boolean', description: 'snapshot: also return the rendered HTML.' },
|
|
269
|
+
maxChars: { type: 'integer', description: 'snapshot and cdp: truncate text output to this many characters.' },
|
|
270
|
+
timeoutMs: { type: 'integer', description: 'Deadline for this action. Defaults to the plugin configuration.' },
|
|
271
|
+
port: { type: 'integer', description: 'start: CDP port. Defaults to the plugin configuration (9222).' },
|
|
272
|
+
cdpEndpoint: {
|
|
273
|
+
type: 'string',
|
|
274
|
+
description: 'Attach to a browser that is already running, e.g. "http://127.0.0.1:9222", instead of starting one. This is how you drive your own Chrome with its logins: start it with --remote-debugging-port and pass that URL.',
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const SESSION_OUTPUT_SCHEMA = {
|
|
280
|
+
type: 'object',
|
|
281
|
+
additionalProperties: true,
|
|
282
|
+
required: ['ok'],
|
|
283
|
+
properties: {
|
|
284
|
+
ok: { type: 'boolean' },
|
|
285
|
+
action: { type: 'string' },
|
|
286
|
+
error: { type: 'string' },
|
|
287
|
+
hint: { type: 'string' },
|
|
288
|
+
url: { type: 'string' },
|
|
289
|
+
title: { type: 'string' },
|
|
290
|
+
text: { type: 'string' },
|
|
291
|
+
chars: { type: 'integer' },
|
|
292
|
+
truncated: { type: 'boolean' },
|
|
293
|
+
links: { type: 'array', items: { type: 'object', additionalProperties: true } },
|
|
294
|
+
fields: { type: 'array', items: { type: 'object', additionalProperties: true } },
|
|
295
|
+
html: { type: 'string' },
|
|
296
|
+
selector: { type: 'string' },
|
|
297
|
+
clicked: { type: 'object', additionalProperties: true },
|
|
298
|
+
navigated: { type: 'boolean' },
|
|
299
|
+
submitted: { type: 'boolean' },
|
|
300
|
+
value: {},
|
|
301
|
+
serialized: { type: 'string' },
|
|
302
|
+
key: { type: 'string' },
|
|
303
|
+
y: { type: 'number' },
|
|
304
|
+
height: { type: 'number' },
|
|
305
|
+
viewport: { type: 'number' },
|
|
306
|
+
state: { type: 'string' },
|
|
307
|
+
waitedMs: { type: 'integer' },
|
|
308
|
+
path: { type: 'string' },
|
|
309
|
+
bytes: { type: 'integer' },
|
|
310
|
+
format: { type: 'string' },
|
|
311
|
+
method: { type: 'string' },
|
|
312
|
+
result: {},
|
|
313
|
+
bytes: { type: 'integer' },
|
|
314
|
+
backend: { type: 'string' },
|
|
315
|
+
geometry: { type: 'string' },
|
|
316
|
+
browserVersion: { type: 'string' },
|
|
317
|
+
running: { type: 'boolean' },
|
|
318
|
+
ownedByThisPlugin: { type: 'boolean' },
|
|
319
|
+
external: { type: 'boolean' },
|
|
320
|
+
pid: { type: 'integer' },
|
|
321
|
+
endpoint: { type: 'string' },
|
|
322
|
+
logPath: { type: 'string' },
|
|
323
|
+
settled: { type: 'boolean' },
|
|
324
|
+
settledMs: { type: 'integer' },
|
|
325
|
+
stopped: { type: 'boolean' },
|
|
326
|
+
scope: { type: 'string' },
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Render a `moli_session` value for the model.
|
|
332
|
+
*
|
|
333
|
+
* A snapshot is the one result worth formatting rather than dumping as JSON: the
|
|
334
|
+
* link and field tables are what the next action usually depends on.
|
|
335
|
+
*
|
|
336
|
+
* @param _args - the call arguments, unused.
|
|
337
|
+
* @param value - the canonical result value.
|
|
338
|
+
* @returns result content blocks.
|
|
339
|
+
*/
|
|
340
|
+
function renderSession(_args, value) {
|
|
341
|
+
if (value.ok !== true) {
|
|
342
|
+
const lines = [`moli_session failed${typeof value.action === 'string' ? ` (${value.action})` : ''}: ${value.error ?? 'unknown error'}`];
|
|
343
|
+
if (typeof value.hint === 'string' && value.hint !== '') lines.push(value.hint);
|
|
344
|
+
if (typeof value.bytes === 'number') lines.push(`(the image was ${String(value.bytes)} bytes but could not be written)`);
|
|
345
|
+
return [{ type: 'text', text: lines.join('\n') }];
|
|
346
|
+
}
|
|
347
|
+
if (value.action === 'snapshot') {
|
|
348
|
+
const lines = [`${String(value.title)} — ${String(value.url)}`, ''];
|
|
349
|
+
lines.push(String(value.text ?? ''));
|
|
350
|
+
if (Array.isArray(value.links) && value.links.length > 0) {
|
|
351
|
+
lines.push('', `Links (${String(value.links.length)}):`);
|
|
352
|
+
for (const link of value.links.slice(0, 25)) lines.push(` ${String(link.text)} -> ${String(link.href)}`);
|
|
353
|
+
}
|
|
354
|
+
if (Array.isArray(value.fields) && value.fields.length > 0) {
|
|
355
|
+
lines.push('', `Fields (${String(value.fields.length)}):`);
|
|
356
|
+
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)}`}>`);
|
|
357
|
+
}
|
|
358
|
+
return [{ type: 'text', text: lines.join('\n') }];
|
|
359
|
+
}
|
|
360
|
+
if (value.action === 'status') {
|
|
361
|
+
if (value.running !== true) {
|
|
362
|
+
return [{ type: 'text', text: 'No session is running. Call moli_session with action "start" or "open" to launch one.' }];
|
|
363
|
+
}
|
|
364
|
+
const lines = [
|
|
365
|
+
`Session running: ${String(value.backend)} (${String(value.browserVersion ?? 'version unknown')}) at ${String(value.endpoint)}`,
|
|
366
|
+
];
|
|
367
|
+
if (value.geometry === 'absolute') {
|
|
368
|
+
lines.push('Geometry: moli\'s document-absolute frame — element input cannot reach below the first viewport. Use `browser: "chromium"` to lift that, or eval to click in the page.');
|
|
369
|
+
}
|
|
370
|
+
if (value.external === true) lines.push('Started outside this plugin, so close will not stop it.');
|
|
371
|
+
if (value.url !== undefined) lines.push(`Current page: ${String(value.title)} — ${String(value.url)}`);
|
|
372
|
+
return [{ type: 'text', text: lines.join('\n') }];
|
|
373
|
+
}
|
|
374
|
+
if (value.action === 'cdp') {
|
|
375
|
+
return [{ type: 'text', text: `${String(value.method)} → ${String(value.serialized ?? '').slice(0, 4000)}` }];
|
|
376
|
+
}
|
|
377
|
+
if (value.action === 'click') {
|
|
378
|
+
return [{
|
|
379
|
+
type: 'text',
|
|
380
|
+
text: `Clicked ${String(value.clicked?.tag ?? 'element')}${value.clicked?.text === undefined || value.clicked.text === '' ? '' : ` (“${String(value.clicked.text)}”)`}`
|
|
381
|
+
+ `${value.navigated === true ? `, which navigated to ${String(value.url)}` : '; the page did not navigate'}`,
|
|
382
|
+
}];
|
|
383
|
+
}
|
|
384
|
+
if (value.action === 'type') {
|
|
385
|
+
return [{ type: 'text', text: `Entered text${value.submitted === true ? ' and pressed Enter' : ''}; the field now reads ${JSON.stringify(String(value.value ?? ''))}` }];
|
|
386
|
+
}
|
|
387
|
+
if (value.action === 'screenshot') {
|
|
388
|
+
return [{ type: 'text', text: `Screenshot written to ${String(value.path)} (${String(value.bytes)} bytes). Read that file to see it.` }];
|
|
389
|
+
}
|
|
390
|
+
if (value.action === 'eval') {
|
|
391
|
+
return [{ type: 'text', text: String(value.serialized ?? '') }];
|
|
392
|
+
}
|
|
393
|
+
if (value.action === 'close') {
|
|
394
|
+
return [{ type: 'text', text: value.stopped === true ? 'Session stopped.' : 'Nothing to stop: no session was running that this plugin started.' }];
|
|
395
|
+
}
|
|
396
|
+
return [{ type: 'text', text: JSON.stringify(value) }];
|
|
397
|
+
}
|
|
398
|
+
|
|
229
399
|
/**
|
|
230
400
|
* Register the Moli tools.
|
|
231
401
|
*
|
|
@@ -355,5 +525,133 @@ export function apply(ctx, config) {
|
|
|
355
525
|
},
|
|
356
526
|
});
|
|
357
527
|
|
|
358
|
-
|
|
528
|
+
// ---------------------------------------------------------------------------
|
|
529
|
+
// moli_session: an interactive browser, for the work a one-shot fetch cannot do
|
|
530
|
+
// ---------------------------------------------------------------------------
|
|
531
|
+
//
|
|
532
|
+
// This is the plugin's only stateful tool. `moli serve` is a long-lived child
|
|
533
|
+
// that has to outlive a single call, so one manager is created per plugin
|
|
534
|
+
// instance and its lifecycle is explicit (`start` implicitly, `close` by hand).
|
|
535
|
+
// Registration stays synchronous: the server is spawned on first use, never at
|
|
536
|
+
// load time, so a plugin load cannot delay Host startup or open a port.
|
|
537
|
+
const session = new SessionManager({
|
|
538
|
+
get exe() { return cachedExe; },
|
|
539
|
+
// Which browser drives a session. Explicit, never automatic: switching engines
|
|
540
|
+
// because one click failed would make the timing and the behaviour depend on
|
|
541
|
+
// which operation happened to fail, and the caller could not tell which browser
|
|
542
|
+
// it was talking to.
|
|
543
|
+
browser: options.sessionBrowser ?? 'moli',
|
|
544
|
+
browserPath: options.sessionBrowserPath,
|
|
545
|
+
cdpEndpoint: options.sessionCdpEndpoint,
|
|
546
|
+
port: options.sessionPort ?? 9222,
|
|
547
|
+
host: '127.0.0.1',
|
|
548
|
+
insecureTls,
|
|
549
|
+
proxy: options.proxy,
|
|
550
|
+
userAgent: options.sessionUserAgent,
|
|
551
|
+
profileDir: options.profileDir,
|
|
552
|
+
resources: options.sessionResources === true,
|
|
553
|
+
// Off, matching the engine's own default: `moli fetch <private-ip>` can reach
|
|
554
|
+
// a private address, so the plugin does not silently narrow what a user's
|
|
555
|
+
// installed engine already does. Set it to true, or pass blockCidrs, to have
|
|
556
|
+
// the engine refuse private addresses for directly connected targets.
|
|
557
|
+
blockPrivateNetworks: options.blockPrivateNetworks === true,
|
|
558
|
+
blockCidrs: Array.isArray(options.blockCidrs) ? options.blockCidrs : [],
|
|
559
|
+
startTimeoutMs: options.sessionStartTimeoutMs ?? 20000,
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
ctx.tools.register({
|
|
563
|
+
name: 'moli_session',
|
|
564
|
+
description: 'Drive an interactive browser through a persistent session, for what a one-shot fetch cannot'
|
|
565
|
+
+ ' do: click through consent walls, fill and submit a search box, scroll a lazy list, wait for a'
|
|
566
|
+
+ ' client-side route change, or run a script in the page. Keeps one browser alive across calls, so'
|
|
567
|
+
+ ' state (cookies, navigation, form input) persists until you stop it with action "close".'
|
|
568
|
+
+ ' Start with "open"; read the page with "snapshot"; interact with "click", "type", "press" and'
|
|
569
|
+
+ ' "scroll"; use "eval" as the escape hatch. Elements are addressed by CSS selector, never by pixel'
|
|
570
|
+
+ ' coordinates.',
|
|
571
|
+
parameters: SESSION_PARAMETERS,
|
|
572
|
+
output: { schema: SESSION_OUTPUT_SCHEMA, render: renderSession },
|
|
573
|
+
// One browser, one page: overlapping actions would interleave input events and
|
|
574
|
+
// navigate the page out from under each other.
|
|
575
|
+
isConcurrencySafe: () => false,
|
|
576
|
+
async execute(args, exec) {
|
|
577
|
+
// A per-call endpoint is honoured by every action, including the ones that
|
|
578
|
+
// never reach `performAction`. Routing `close` through it is not cosmetic:
|
|
579
|
+
// `withEndpoint` is what marks the connection external, and `close` refuses
|
|
580
|
+
// to stop an external browser. Answering `close` before that mark existed
|
|
581
|
+
// meant a call that had just attached to the user's own Chrome could shut it
|
|
582
|
+
// down -- the one thing this design promises not to do.
|
|
583
|
+
const withCallEndpoint = work => (typeof args.cdpEndpoint === 'string' && args.cdpEndpoint !== ''
|
|
584
|
+
? session.withEndpoint(args.cdpEndpoint, work)
|
|
585
|
+
: work());
|
|
586
|
+
|
|
587
|
+
// `status` and `close` are meaningful with nothing running, so they are
|
|
588
|
+
// answered before the engine is required.
|
|
589
|
+
if (args.action === 'close') {
|
|
590
|
+
return withCallEndpoint(async () => ({ ok: true, action: 'close', ...(await session.close()) }));
|
|
591
|
+
}
|
|
592
|
+
if (args.action === 'status') {
|
|
593
|
+
return withCallEndpoint(async () => ({ ok: true, action: 'status', ...(await session.status()) }));
|
|
594
|
+
}
|
|
595
|
+
if (args.action === 'start') {
|
|
596
|
+
if (!sessionSupported()) return { ok: false, action: 'start', error: sessionUnsupportedReason() };
|
|
597
|
+
return withCallEndpoint(async () => {
|
|
598
|
+
const { external, backend, geometry } = await session.ensure();
|
|
599
|
+
const state = await session.status();
|
|
600
|
+
return { ok: true, action: 'start', started: true, external, backend, geometry, ...state };
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (engine() === undefined && (options.sessionBrowser ?? 'moli') === 'moli'
|
|
605
|
+
&& (options.sessionCdpEndpoint === undefined || options.sessionCdpEndpoint === '')) {
|
|
606
|
+
return {
|
|
607
|
+
ok: false,
|
|
608
|
+
action: String(args.action),
|
|
609
|
+
error: 'moli was not found, so a moli session cannot be started',
|
|
610
|
+
hint: 'Call moli_probe with action "install", set the plugin config field `exePath`, or set `sessionBrowser: "chromium"` to drive the browser you already have.',
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
if (!sessionSupported()) return { ok: false, action: String(args.action), error: sessionUnsupportedReason() };
|
|
614
|
+
|
|
615
|
+
if (exec?.signal?.aborted === true) return { ok: false, action: String(args.action), error: 'the call was aborted before it started' };
|
|
616
|
+
|
|
617
|
+
try {
|
|
618
|
+
const perform = async ({ connection, geometry, backend }) => {
|
|
619
|
+
try {
|
|
620
|
+
return await performAction(connection, args, { maxChars, geometry, backend });
|
|
621
|
+
} finally {
|
|
622
|
+
// A closed socket means the browser went away under us; drop it so the
|
|
623
|
+
// next call starts a fresh one instead of failing forever.
|
|
624
|
+
if (connection.closed === true) void session.close();
|
|
625
|
+
}
|
|
626
|
+
};
|
|
627
|
+
// A per-call endpoint drives a browser the caller already has open, which is
|
|
628
|
+
// the only way to inherit their logins. It is scoped to this call: an
|
|
629
|
+
// endpoint that latched would make one bad call poison every later one.
|
|
630
|
+
if (typeof args.cdpEndpoint === 'string' && args.cdpEndpoint !== '') {
|
|
631
|
+
return await session.withEndpoint(args.cdpEndpoint, perform);
|
|
632
|
+
}
|
|
633
|
+
return await perform(await session.ensure());
|
|
634
|
+
} catch (error) {
|
|
635
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
636
|
+
const hint = /timed out/u.test(message)
|
|
637
|
+
? 'The action exceeded its deadline. Raise timeoutMs, or check `waitFor` for a selector that never appears.'
|
|
638
|
+
: /no Chromium-family browser/u.test(message)
|
|
639
|
+
? 'Install Chrome, Edge or Brave, or set the plugin config field `sessionBrowserPath` to a browser executable.'
|
|
640
|
+
: /ECONNREFUSED|closed|WebSocket/u.test(message)
|
|
641
|
+
? 'The browser connection was lost. Call moli_session with action "status", then "start" to relaunch it.'
|
|
642
|
+
: undefined;
|
|
643
|
+
return { ok: false, action: String(args.action), error: message, ...(hint === undefined ? {} : { hint }) };
|
|
644
|
+
}
|
|
645
|
+
},
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
// A serve process or a headless browser outlives a tool call, so it must be tied
|
|
649
|
+
// to the plugin's lifetime rather than left to the OS. Cordis disposes listeners
|
|
650
|
+
// registered through `ctx.on` together with the plugin's fiber, so registering
|
|
651
|
+
// one is enough; the returned disposer is not needed here.
|
|
652
|
+
if (typeof ctx.on === 'function') {
|
|
653
|
+
ctx.on('dispose', () => { void session.close(); });
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
ctx.logger?.info?.(`dsh-moli: registered moli_fetch, moli_probe and moli_session (${cachedExe ?? 'moli missing'})`);
|
|
359
657
|
}
|