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 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
- ctx.logger?.info?.(`dsh-moli: registered moli_fetch and moli_probe (${cachedExe ?? 'moli missing'})`);
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
  }