dsh-browser-playwright-codex 0.7.2 → 0.7.5
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/CHANGELOG.md +74 -0
- package/README.md +17 -5
- package/SECURITY.md +5 -1
- package/lib/browser-lifecycle.d.ts +13 -1
- package/lib/browser-lifecycle.js +57 -6
- package/lib/cache-clean.d.ts +90 -0
- package/lib/cache-clean.js +386 -0
- package/lib/challenge.d.ts +8 -0
- package/lib/challenge.js +16 -0
- package/lib/client.js +165 -0
- package/lib/errors.d.ts +12 -0
- package/lib/errors.js +16 -0
- package/lib/index.d.ts +4 -2
- package/lib/index.js +3 -2
- package/lib/navigation-guard.d.ts +48 -0
- package/lib/navigation-guard.js +48 -0
- package/lib/page-actions.js +6 -0
- package/lib/page-snapshot.js +5 -0
- package/lib/playwright.d.ts +1 -1
- package/lib/playwright.js +5 -2
- package/lib/provider.d.ts +56 -0
- package/lib/provider.js +117 -5
- package/lib/runtime-state.d.ts +20 -0
- package/lib/runtime-state.js +53 -0
- package/lib/session.d.ts +33 -3
- package/lib/session.js +86 -5
- package/lib/settings-card.js +129 -0
- package/lib/tool-render.js +3 -2
- package/lib/tool.js +13 -8
- package/lib/url-policy.d.ts +13 -0
- package/lib/url-policy.js +24 -2
- package/package.json +1 -1
- package/scripts/dsh-browser-verify.mjs +34 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,77 @@
|
|
|
1
|
+
## 0.7.5 — 2026-10-26
|
|
2
|
+
|
|
3
|
+
一句话摘要:**两条安全洞补上,同时唤醒不再多开浏览器** —— 白名单从"门口打一次勾"变成"装在浏览器的请求上",`allowEvaluate` / `allowCdp` 从工具层下沉到会话层(绕过工具直接拿服务也拒),两个任务同时要浏览器时共用一次启动,默认持久化模式下「最多几个会话」也真的算数了。
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **白名单可被绕过(安全)**:入口检查只覆盖 `navigate` / 带 `href` 的点击 / 切标签 / 开标签,页面自己 `location.href='外站'`、meta refresh、`<form action="外站">` 提交、网页自开的弹窗全都绕得过去。实测把名单设成 `127.0.0.1` 后,页面脚本一跳就落在了 `localhost` 上。现在新增 `src/navigation-guard.ts`,把名单**装在这个浏览器 context 的请求上**:发往名单外主机的**导航**当场掐断并报 `URL_NOT_ALLOWED`,**数据请求**(`fetch` / `xhr` / `EventSource`)同样掐断;`script` / 图片 / CSS / 字体 / 媒体这类渲染资源放行,否则页面只会看起来是坏的。空名单(默认)完全不接管路由,所以默认行为一个字没变。
|
|
8
|
+
- **302 跳转是请求拦截看不见的那一跳**:Chromium 在它自己的网络栈里跟随重定向,`context.route()` 根本不会为那一跳触发(实测)。所以每个动作收尾时还会核对页面**最终落在哪个主机**——落在名单外就报 `URL_NOT_ALLOWED`,并把标签页重置成 `about:blank`,绝不留一个可以继续驱动的越界页面。
|
|
9
|
+
- **evaluate / CDP 的开关只在工具层挡(安全)**:此前只有 `tool.ts` 检查 `allowEvaluate` / `allowCdp`,任何拿到浏览器服务的代码(兄弟插件、脚本)都能直接调 `session.evaluate` 跑任意页面 JS,或用 `session.cdp` 发白名单外的 `Runtime.evaluate`(两条都实测跑通)。现在 `session.evaluate` / `session.cdp` 自己就拒(`EVALUATE_DISABLED` / `CDP_DISABLED`),CDP 还要再过一道白名单(`CDP_DENIED`)。**没发布过的门禁算关闭**——部署没说「可以」就是「不可以」。
|
|
10
|
+
- **两个任务同时唤醒会多开浏览器**:`ensureContext` / `ensureBrowser` 没有在途合并,两个会话同时醒来都看到"还没有 context",于是各自 `launchPersistentContext` 同一个 profile。实测后果比"多开"更难看:第二个死在 Chrome 的单例锁上,恢复流程**把正在运行的那个浏览器的锁文件删了**,而调用方收到的错误是「你没装浏览器」——锁被删正是之后关不干净、残留进程的来路。现在并发调用共享同一次启动,并且在途启动结束就清空;恢复路径也不再动活浏览器的锁文件。
|
|
11
|
+
- **`maxSessions` 在默认(持久化)模式不生效**:这个上限只写在非持久化分支里,而默认就是持久化模式,所以形同虚设。现在它按**会话句柄**生效:最久没用、且当前没有操作在跑的句柄会被回收,共用窗口一动不动,那个 owner 下次拿到一个新句柄(同一批标签页,索引回到 0)。顺便修掉 `browser-lifecycle.ts` 一处复制粘贴多出来的重复赋值。
|
|
12
|
+
- **设置卡可能把关闭的门禁显示成开着**:`publishGates` 原来上报的是「工具是否注册」,而 `registerDisabledTools: true` 会故意把关着的工具也注册(好让模型能发现它并读到说明),于是卡片把关闭的能力显示为开启。现在上报的是能力值本身。
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- **`src/navigation-guard.ts`**:名单的请求层实现,含"文档请求 / 数据请求 / 渲染资源"三分类与拒绝记录。
|
|
17
|
+
- **公开导出(只增不减)**:`isHostAllowed`、`installHostGuard`、`BlockedRequest`、`evaluateDisabled`、`cdpDisabled`。
|
|
18
|
+
- **`tests/host-guard.test.ts`(4 条,真浏览器)**:空名单不插手(默认行为不变)、302 越界被拒并报出主机名、页面自己跳转永远到不了名单外、`fetch` 到名单外**服务器一个请求都没收到**。
|
|
19
|
+
- **`tests/gates-enforcement.test.ts`(5 条,不需要浏览器)**:没发布门禁时 `evaluate` / `cdp` 都拒、开了 CDP 门禁 `Runtime.evaluate` 仍被白名单拒、门禁开关对会话层依旧权威、`getGates()` 报的是能力值。
|
|
20
|
+
- **`tests/concurrency.test.ts`(3 条,真浏览器)**:三个会话同时唤醒共用一个窗口、两个会话同时唤醒共用一个浏览器、持久化模式下 `maxSessions` 按 LRU 回收句柄且窗口不动。
|
|
21
|
+
- **随包验收新增 `[0]` 段(3 条)**:不经工具层直接调 `session.evaluate` / `session.cdp` 必须被拒;开了门禁后 `Runtime.evaluate` 仍报 `CDP_DENIED`。
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- **行为变化**:代码层调用 `evaluate` / `cdp` 现在也要过门禁,且未发布门禁 = 关闭。走工具层的老路径行为不变(工具层先给出同样口径的错误)。
|
|
26
|
+
- **`allowedDomains` 的语义收紧**:填了名单,就不再只是"插件不去",而是"这个浏览器不去"。
|
|
27
|
+
- 三个测试文件(`provider` / `regressions` / `scenarios`,另加 `live-smoke`)显式发布门禁——它们本来就直接调 `session.evaluate`,等于把自己声明成"开了开关的部署"。
|
|
28
|
+
|
|
29
|
+
### Tests
|
|
30
|
+
|
|
31
|
+
- 全套 243 → **255 条**(+12):通过 252、跳过 3、失败 0。
|
|
32
|
+
- `npm run verify` 57 → **60 项**:`[0]` 段三条是新增的门禁回归闸。
|
|
33
|
+
|
|
34
|
+
## 0.7.4 — 2026-10-23
|
|
35
|
+
|
|
36
|
+
一句话摘要:**设置卡可以切换有头 / 无头** —— 运行时开关,不用改 YAML、不用重启 DSH;切换在**下一次操作**时生效(先导出登录态,再按新模式重开窗口)。默认仍是有头。
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
|
|
40
|
+
- **「浏览器窗口」开关**:设置卡新增单选「显示浏览器窗口(有头)」/「后台运行(无头)」,选择存进 `~/.dsh/dsh-browser-playwright.state.json`(与页面识别方式同一套 runtime-state),重启后仍记住。改完在**下一次浏览器操作**时重开窗口生效——不会打断正在进行的调用;代价是标签页和未提交的表单会丢,登录状态会先导出、不会丢。
|
|
41
|
+
- **`POST /api/browser-playwright/window { headless }`**:校验布尔值,返回生效值与 `changed`;`GET /state` 同时返回 `headless` / `headlessUserChosen` / `headlessConfigured`,卡片据此显示「当前 / 你选的」。
|
|
42
|
+
- **界面明确写清无头的代价**:无头模式下「看得见、能随时接手」的前提不成立——验证码/二次验证只剩提示,没人能在窗口里替你点;只在无人值守且接受这个代价时用。
|
|
43
|
+
|
|
44
|
+
### Changed
|
|
45
|
+
|
|
46
|
+
- **provider 按「期望模式 vs 实际启动模式」懒重开**:`desiredHeadless()` 读运行时选择(无选择时回退 `launch.headless`);每次 acquire 前比较 `launchedHeadless`,不一致就先 `dispose()`(导出登录态、关窗)再按新模式启动。已经在用的 owner 切换模式也会被重建,而不是「下次重启才生效」。
|
|
47
|
+
- **`launch.headless` 仍是配置默认**:配置里写什么就是没在卡片上选过时的默认值;两处启动路径(持久 context / legacy browser)都改为读解析后的值。
|
|
48
|
+
|
|
49
|
+
### Fixed
|
|
50
|
+
|
|
51
|
+
- **挑战页提示语随窗口模式变化**:无头模式下原提示仍说「浏览器窗口就在你桌面上,等人去点」——那个窗口不存在。现在无头时改为明确说「HEADLESS、没有窗口可完成验证;要么把窗口模式切回『显示浏览器窗口』让人处理,要么换一种方式」,并保留「不要盲目重试」的纪律。有头模式的措辞不变。
|
|
52
|
+
|
|
53
|
+
### Tests
|
|
54
|
+
|
|
55
|
+
- 运行时状态:窗口模式持久化、configured 默认只在没有用户选择时生效(2 条)。
|
|
56
|
+
- provider:`desiredHeadless()` 用户选择优先于 `launch.headless`(1 条,无浏览器启动)。
|
|
57
|
+
- 真机验收:有头启动 → 切无头(重开、会话句柄替换)→ 切回有头,状态文件落盘。
|
|
58
|
+
|
|
59
|
+
## 0.7.3 — 2026-10-20
|
|
60
|
+
|
|
61
|
+
一句话摘要:**设置卡新增「缓存与磁盘」** —— 把浏览器 profile 的纯缓存、%TEMP% 里的 dsh-* 残留、更新器残留、请求图缓存做成可预览、可逐项清理的面板;顺手修掉三处测试把临时目录泄进 %TEMP% 的根因。运行时代码零改动。
|
|
62
|
+
|
|
63
|
+
### Added
|
|
64
|
+
|
|
65
|
+
- **设置卡「缓存与磁盘」区块**:四类目标(浏览器缓存 / 临时残留 / 更新器残留 / 图片变体缓存)显示各自字节数与文件数,勾选后先「预演」看能释放多少,再「确认清理」;结果逐项报告释放量与跳过原因(占用中 / 权限 / 其它版本)。
|
|
66
|
+
- **`src/cache-clean.ts`**:纯 `node:fs` 模块,两个入口 `scan()` / `clean()`。安全边界写在代码里而不是提示词里:目标是**枚举 id**(`browser-cache` / `temp-residue` / `updater-residue` / `attachment-cache`),路径一律由 DSH_HOME 与已知系统位置推导,请求体里的字符串永远不拼进路径;浏览器 profile 只删 Chromium 的纯缓存目录(Cache / Code Cache / GPUCache / ShaderCache / GrShaderCache / DawnCache / DawnGraphiteCache / ScriptCache / `*_crx_cache`),Cookies、Local Storage、Service Worker、IndexedDB、Network、Preferences 一律不碰。
|
|
67
|
+
- **`GET /api/browser-playwright/disk/stat`** 与 **`POST /api/browser-playwright/disk/clean`**:沿用已有的 `x-dsh-plugin: browser-toggle` 防伪头(GET 与 POST 都验)、JSON 校验、枚举校验;失败逐条返回 `{ path, reason }`,不折成一个笼统错误。`~/.dsh/cache/attachments` 可清,`~/.dsh/attachments/v1/objects`(会话引用的原图)永不在清理范围内,只在统计里报大小。
|
|
68
|
+
- **`tests/cache-clean.test.ts`**:9 条 fixture 用例——白名单内删干净、Cookies 与 Local Storage 一个不动、`dryRun` 不落盘、新临时目录预演时标 `in-use`、只清当前版本的更新器文件、附件缓存清空而会话原图保留、目标 id 拒绝任意路径、路径推导、字节格式化。
|
|
69
|
+
|
|
70
|
+
### Fixed
|
|
71
|
+
|
|
72
|
+
- **三处测试泄漏 %TEMP%**:`runtime-state.test.ts`、`provider.test.ts`、`login-state.test.ts` 用 `mkdtempSync` 创建的 `dsh-browser-state-*` / `dsh-provider-state-*` / `dsh-login-state-*` 目录从来没删,跑一次测试就在临时目录里留一堆空壳;现在各自登记并在 `after()` 里删掉。
|
|
73
|
+
- **Windows 只读文件**:清理时先尝试普通删除,失败再 `chmod` 去掉只读位重试;**删成功之后才计入释放量**,不再出现"报了删除但一个没删"的假账。
|
|
74
|
+
|
|
1
75
|
## 0.7.2 — 2026-10-19
|
|
2
76
|
|
|
3
77
|
一句话摘要:**设置卡推倒重做** —— 用户要求按 DSH 设置页规范重新设计:首屏只留「主开关 + 三个分区摘要」,所有长说明收进折叠。0.7.1 因卡在 npm staged 状态作废(版本号让给这一版)。
|
package/README.md
CHANGED
|
@@ -232,7 +232,7 @@ All tunables are patchable through the profile's `cordis.patch.yml` (later layer
|
|
|
232
232
|
ignoreHTTPSErrors: false
|
|
233
233
|
allowedDomains: [] # e.g. ['github.com'] restricts navigation
|
|
234
234
|
idleTimeoutMs: 0 # shipped value: never auto-close (schema default 600000 = 10 min)
|
|
235
|
-
maxSessions: 8 #
|
|
235
|
+
maxSessions: 8 # live-session cap; persistent mode caps handles, still one window
|
|
236
236
|
snapshot:
|
|
237
237
|
engine: legacy # 'legacy' (default) or 'aria' (Playwright ariaSnapshot, refs aligned back)
|
|
238
238
|
maxNodes: 500 # token budget per snapshot tree
|
|
@@ -281,6 +281,16 @@ Both are honest trades, not free wins: a diff is smaller but requires the model
|
|
|
281
281
|
tree, and an interactive-only snapshot hides the prose the model may want to read. Measure before
|
|
282
282
|
turning them on in a deployment that shares prompts across many users.
|
|
283
283
|
|
|
284
|
+
### Window mode (headful / headless)
|
|
285
|
+
|
|
286
|
+
The Settings card has a **浏览器窗口** choice: show the window (headful, the default) or run it in the background (headless). The pick is stored in `~/.dsh/dsh-browser-playwright.state.json` and survives restarts; `launch.headless` stays the configured default for anyone who never picks.
|
|
287
|
+
|
|
288
|
+
Switching takes effect on the **next browser operation**: the provider compares the mode it launched with against the desired one and relaunches (login state is exported first, so the login survives; tabs and unsaved page state do not). Headless is honest about its cost — with no visible window, the “watch it and take over” guarantee for sign-ins and CAPTCHAs no longer holds, so use it only for unattended work where you accept that.
|
|
289
|
+
|
|
290
|
+
### Cache and disk
|
|
291
|
+
|
|
292
|
+
The Settings card also carries a **缓存与磁盘** section: Chromium's pure caches inside the plugin's profile, `dsh-*` residue in `%TEMP%`, updater installers of the installed version, and the request-image cache are listed with their sizes; you preview first (“先预演”) and only then confirm. Cookies, Local Storage, Service Worker, IndexedDB, Network, Preferences and `~/.dsh/attachments` are never in scope — the target list is a closed enum in `src/cache-clean.ts`, and no path is ever taken from the request.
|
|
293
|
+
|
|
284
294
|
### Prefer a switch to editing config?
|
|
285
295
|
|
|
286
296
|
The Settings card ships **inside this package** (it moved out of the companion package `dsh-browser-toggle`, which is retired) and puts
|
|
@@ -320,7 +330,7 @@ npm install
|
|
|
320
330
|
npm test # full suite: engine/action integration on real Chrome, policy, rendering,
|
|
321
331
|
# schema assembly, plus the realistic scenario suites below
|
|
322
332
|
npm run verify # naming/folding + click/hover/dialog acceptance; self-contained, prints its own count
|
|
323
|
-
# (
|
|
333
|
+
# (60 in a checkout; from an npm install it prints 59 + "1 skipped", because the
|
|
324
334
|
# `lib/`-vs-`src/` check has no `src/` to compare against)
|
|
325
335
|
npm run verify:live # same, plus a live sample of a real page (needs network)
|
|
326
336
|
npm run doctor # does the installed harness still export everything this plugin imports?
|
|
@@ -416,7 +426,7 @@ links below go to GitHub rather than to files inside the package:
|
|
|
416
426
|
- **Headless servers must opt in** — the bundle defaults to `headless: false`; on a headless/remote host set `launch.headless: true`.
|
|
417
427
|
- **Selector-free, not vision-free** — interaction is a11y-tree based; purely visual widgets (canvas, WebGL, custom-drawn controls) may expose no refs. `browser_screenshot` + an image-capable model is the fallback.
|
|
418
428
|
- **Cross-origin iframes** appear as leaf `(frame)` nodes without refs; same-origin frames are walked up to two levels deep.
|
|
419
|
-
- **Evaluate gate is config, not approval** — enabling `allowEvaluate` trusts the model with arbitrary page JavaScript; compose it with the harness approval/permission policy for stricter control.
|
|
429
|
+
- **Evaluate gate is config, not approval** — enabling `allowEvaluate` trusts the model with arbitrary page JavaScript; compose it with the harness approval/permission policy for stricter control. The gate is enforced in **two** places: `tool.ts` refuses before a browser is even launched, and the session itself refuses too (`EVALUATE_DISABLED` / `CDP_DISABLED`), so a sibling plugin or a script holding the browser service cannot go around it. A gate that was never published counts as **off**. `browser_cdp` additionally passes the CDP allow-list at the session layer, so `Runtime.evaluate` is rejected even with the switch on.
|
|
420
430
|
- **The package ships both power gates OFF, and that is deliberate** — `cordis.patch.yml` is part of the package, so anything it enables is enabled for *everyone who installs this*. It therefore sets `allowEvaluate: false` and `allowCdp: false` (the code defaults), and each deployment opts in through its **own profile patch** (`~/.dsh/profiles/<name>/cordis.patch.yml`). A package that shipped them on would be handing every installer a backdoor they never asked for. Snippet:
|
|
421
431
|
```yaml
|
|
422
432
|
# dsh replaces an entry's config object wholesale — list every key you rely on.
|
|
@@ -434,7 +444,7 @@ links below go to GitHub rather than to files inside the package:
|
|
|
434
444
|
- **`launch.viewport` only applies headless** — the visible window is a real window: it tracks the desktop size (`browser-lifecycle.ts` passes `viewport: null` in headful), so setting 1280×800 there changes nothing. Pin the size by running `headless: true`, or resize the window itself.
|
|
435
445
|
- **Chromium family only** — Firefox/WebKit channels are not probed; providers are swappable if another engine is needed.
|
|
436
446
|
- **Extract needs a dedicated model route** — it does not reuse the main request's route; misconfiguration fails loudly at call time.
|
|
437
|
-
-
|
|
447
|
+
- **`allowedDomains` is enforced on the wire, not just at the entry points** — the host list is installed on the browser context itself (`navigation-guard.ts`), so a navigation nobody vetted is refused by the browser: `location.href = …`, `meta refresh`, a `<form>` posting to another host and a page-opened popup all stop at the request. Data requests (`fetch` / `xhr` / `EventSource`) to a host outside the list are refused too, so page script cannot move data off-site; rendering assets (script / image / CSS / font / media) are left alone, because blocking them only makes a page look broken. `browser_navigate`, `browser_open_tab`, every navigating click and `browser_switch_tab` still check first — that is what produces the friendly error before anything happens. One case needs a second look, because Chromium follows a redirect inside its own network stack where request interception never sees the hop: after every action the page's final URL is checked, and a landing outside the list is refused with `URL_NOT_ALLOWED` and the tab is reset to `about:blank`. Entry-point checks remain as the second layer for a tab that already existed on a disallowed host (a profile from an older version): the agent simply refuses to drive it. For hard isolation from the whole host stack, still deploy an external network guard.
|
|
438
448
|
- **Stability waits are bounded by design** — a perpetually animating element falls back to a forced click on its current rect rather than stalling; a page that never settles is snapshotted after the cap. This is intentional: the agent must never hang.
|
|
439
449
|
- **Challenge detection reads the responses it has actually seen** — the response-header tier applies to a document response this session observed after it attached to the tab. A tab that was already sitting on a challenge page when the session took it over falls through to the widget and wording tiers and, failing those, to the no-progress backstop: it reports a *possible* wall instead of guessing the vendor. A page that is not recognised still never goes silent: a non-2xx document carries an `[http NNN]` note, and a page with no clickable elements and almost no text carries a `[nothing here]` note.
|
|
440
450
|
|
|
@@ -444,7 +454,7 @@ The scenario suites originally surfaced four hazards, now fixed and regression-t
|
|
|
444
454
|
|
|
445
455
|
- **Stale refs fail fast** — refs carry a per-snapshot nonce, so after a client-side re-render a stale ref matches nothing and `browser_click` fails with `REF_NOT_FOUND` instead of silently acting on a different element.
|
|
446
456
|
- **Link clicks respect `allowedDomains`** — every click path is checked before any input is delivered: `browser_click` — ref mode (the element's `href`), `precise` ref mode, and raw-coordinate mode (the enclosing link found at the point). A disallowed host is rejected with `URL_NOT_ALLOWED`. `javascript:` links are exempt because they run in-page and navigate nowhere; other non-http(s) schemes stay refused.
|
|
447
|
-
- **A page-opened tab outside `allowedDomains` cannot be driven** —
|
|
457
|
+
- **A page-opened tab outside `allowedDomains` cannot be driven** — two layers: the request-level guard refuses the popup's document (so it never loads the disallowed host), and `browser_switch_tab` still refuses to enter a tab that already sits on one (the current tab is kept).
|
|
448
458
|
- **Idle disposal defers during operations** — a navigation slower than `idleTimeoutMs` completes normally; disposal only fires when the session is truly idle.
|
|
449
459
|
- **Mid-navigation snapshots settle** — a `browser_snapshot` racing a JS navigation waits for the new document and retries instead of surfacing a raw "Execution context was destroyed" error.
|
|
450
460
|
|
|
@@ -496,3 +506,5 @@ MIT
|
|
|
496
506
|
- **新增两只诊断工具**:`browser_console_messages`(控制台报错)与 `browser_network_requests`(网络请求),用于"点了没反应"时快速定位原因。
|
|
497
507
|
- **接口与实现同步**:`BrowserSession` 补齐 9 个实现里已有的方法;`errors.ts` 补齐 12 个实际在用的错误码;`BrowserSnapshot` 补 `dialogNote` / `landingNote`;`TabInfo` 补 `active`;`runtime-state` / `cdp-policy` 新增类型声明。
|
|
498
508
|
- **依赖范围修正**:`devDependencies` 与 `peerDependencies` 对齐,可在当前 DSH 版本下安装。
|
|
509
|
+
|
|
510
|
+
**有问题直接去[讨论区](https://github.com/miqian-nomad/dsh-browser-playwright-codex/discussions)开个帖说,用得顺手也可以来吭一声——就我一个人维护,回得慢,但每条都看。**
|
package/SECURITY.md
CHANGED
|
@@ -11,6 +11,10 @@ this package ships switched off** — it is not registered at all until a deploy
|
|
|
11
11
|
|
|
12
12
|
---
|
|
13
13
|
|
|
14
|
+
## 0. Window mode
|
|
15
|
+
|
|
16
|
+
Headful is the default and the safety story depends on it: the window is visible, you can watch it and take over for sign-ins and CAPTCHAs. **Headless removes that**: the agent still parks native dialogs and reports challenge pages as notes, but nobody can act in a window that does not exist. Treat headless as an unattended mode, not as a quieter headful one.
|
|
17
|
+
|
|
14
18
|
## 1. What it can and cannot do
|
|
15
19
|
|
|
16
20
|
**It drives your browser, as you.** The provider opens a visible, profile-backed Chromium-family
|
|
@@ -110,7 +114,7 @@ Two honest notes:
|
|
|
110
114
|
npm test # 219 tests: contracts, tool schemas, scenarios,
|
|
111
115
|
# aria engine, CDP policy, diff, click/tab allow-lists, snapshot rendering,
|
|
112
116
|
# prompt budget, docs-vs-reality consistency
|
|
113
|
-
npm run verify #
|
|
117
|
+
npm run verify # 60 self-contained acceptance checks in a checkout (59 checks + 1 skipped
|
|
114
118
|
# from an npm install, where there is no src/): no DSH, no network, local fixture server
|
|
115
119
|
npm run doctor # the host packages this plugin imports are still exporting what it uses
|
|
116
120
|
npm run cost # resident prompt cost of the registered tool surface
|
|
@@ -4,9 +4,21 @@ import type { PlaywrightProvider } from './provider.ts';
|
|
|
4
4
|
* profile-backed window is launched once and reused until closed by the
|
|
5
5
|
* last owner, idle disposal, or an external close; login state lives in
|
|
6
6
|
* the profile plus the exported state file, so reopening is seamless.
|
|
7
|
+
*
|
|
8
|
+
* Concurrent callers share ONE launch. Without this, two sessions waking up at
|
|
9
|
+
* the same moment both saw "no context" and both called launchPersistentContext
|
|
10
|
+
* on the same profile directory: the loser died on Chrome's profile singleton,
|
|
11
|
+
* the recovery path deleted the running browser's lock files, and the caller was
|
|
12
|
+
* told "install a Chromium-family browser" (measured 2026-10-26).
|
|
7
13
|
*/
|
|
8
14
|
export declare function ensureContext(provider: PlaywrightProvider): Promise<import("playwright-core").BrowserContext>;
|
|
9
15
|
/** Remove stale Chrome singleton lock files inside the profile directory. */
|
|
10
16
|
export declare function clearChromeLocks(provider: PlaywrightProvider): Promise<void>;
|
|
11
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* Launch the browser once, probing the configured or auto-detected channel.
|
|
19
|
+
*
|
|
20
|
+
* Concurrent callers share one launch, for the same reason ensureContext does:
|
|
21
|
+
* otherwise two sessions wake up, both see `browser === undefined`, and two
|
|
22
|
+
* browsers are launched (the reported "两个任务同时唤醒会多开浏览器").
|
|
23
|
+
*/
|
|
12
24
|
export declare function ensureBrowser(provider: PlaywrightProvider): Promise<import("playwright-core").Browser>;
|
package/lib/browser-lifecycle.js
CHANGED
|
@@ -14,8 +14,26 @@ import { BrowserError, launchFailed } from "./errors.js";
|
|
|
14
14
|
* profile-backed window is launched once and reused until closed by the
|
|
15
15
|
* last owner, idle disposal, or an external close; login state lives in
|
|
16
16
|
* the profile plus the exported state file, so reopening is seamless.
|
|
17
|
+
*
|
|
18
|
+
* Concurrent callers share ONE launch. Without this, two sessions waking up at
|
|
19
|
+
* the same moment both saw "no context" and both called launchPersistentContext
|
|
20
|
+
* on the same profile directory: the loser died on Chrome's profile singleton,
|
|
21
|
+
* the recovery path deleted the running browser's lock files, and the caller was
|
|
22
|
+
* told "install a Chromium-family browser" (measured 2026-10-26).
|
|
17
23
|
*/
|
|
18
24
|
export async function ensureContext(provider) {
|
|
25
|
+
const inFlight = provider.contextLaunch;
|
|
26
|
+
if (inFlight !== undefined)
|
|
27
|
+
return await inFlight;
|
|
28
|
+
const launch = launchContext(provider).finally(() => {
|
|
29
|
+
if (provider.contextLaunch === launch)
|
|
30
|
+
provider.contextLaunch = undefined;
|
|
31
|
+
});
|
|
32
|
+
provider.contextLaunch = launch;
|
|
33
|
+
return await launch;
|
|
34
|
+
}
|
|
35
|
+
/** The actual launch, shared by every concurrent {@link ensureContext} caller. */
|
|
36
|
+
async function launchContext(provider) {
|
|
19
37
|
if (provider.context !== undefined) {
|
|
20
38
|
try {
|
|
21
39
|
const browser = provider.context.browser();
|
|
@@ -28,7 +46,8 @@ export async function ensureContext(provider) {
|
|
|
28
46
|
provider.context = undefined;
|
|
29
47
|
}
|
|
30
48
|
fs.mkdirSync(provider.profileDir, { recursive: true });
|
|
31
|
-
const { executablePath, channel
|
|
49
|
+
const { executablePath, channel } = provider.config.launch;
|
|
50
|
+
const headless = provider.desiredHeadless();
|
|
32
51
|
const headful = !headless;
|
|
33
52
|
const attempts = [];
|
|
34
53
|
const baseOptions = {
|
|
@@ -56,14 +75,22 @@ export async function ensureContext(provider) {
|
|
|
56
75
|
const start = async (extra) => {
|
|
57
76
|
try {
|
|
58
77
|
provider.context = await chromium.launchPersistentContext(provider.profileDir, { ...baseOptions, ...extra });
|
|
78
|
+
provider.launchedHeadless = headless;
|
|
59
79
|
}
|
|
60
80
|
catch (error) {
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
81
|
+
// Another launch may have won while this one was starting (an explicit
|
|
82
|
+
// `dispose()` racing a fresh acquire, a second provider on the same
|
|
83
|
+
// profile). Hand back that window: clearing the lock files it is running
|
|
84
|
+
// on would let a later launch start a SECOND browser on this profile.
|
|
85
|
+
if (provider.context !== undefined)
|
|
86
|
+
return provider.context;
|
|
87
|
+
// A stale Chrome singleton lock from a dirty shutdown breaks a relaunch
|
|
88
|
+
// into the same profile; clear the lock files and retry once before
|
|
89
|
+
// surfacing the failure.
|
|
64
90
|
if (error instanceof Error && /user data directory|SingletonLock|ProcessSingleton/i.test(error.message)) {
|
|
65
91
|
await provider.clearChromeLocks();
|
|
66
92
|
provider.context = await chromium.launchPersistentContext(provider.profileDir, { ...baseOptions, ...extra });
|
|
93
|
+
provider.launchedHeadless = headless;
|
|
67
94
|
}
|
|
68
95
|
else {
|
|
69
96
|
throw error;
|
|
@@ -91,6 +118,9 @@ export async function ensureContext(provider) {
|
|
|
91
118
|
catch {
|
|
92
119
|
/* malformed state file must not block startup */
|
|
93
120
|
}
|
|
121
|
+
// Allow-list on the wire: registered before the first navigation, so a
|
|
122
|
+
// page cannot leave the list by itself (navigation-guard.ts).
|
|
123
|
+
await provider.installHostGuard(ctx);
|
|
94
124
|
await ctx.addInitScript(SNAPSHOT_SCRIPT);
|
|
95
125
|
// The initial blank page predates addInitScript: reload so the
|
|
96
126
|
// snapshot engine and restored localStorage apply on first use.
|
|
@@ -143,15 +173,35 @@ export async function clearChromeLocks(provider) {
|
|
|
143
173
|
}
|
|
144
174
|
}
|
|
145
175
|
}
|
|
146
|
-
/**
|
|
176
|
+
/**
|
|
177
|
+
* Launch the browser once, probing the configured or auto-detected channel.
|
|
178
|
+
*
|
|
179
|
+
* Concurrent callers share one launch, for the same reason ensureContext does:
|
|
180
|
+
* otherwise two sessions wake up, both see `browser === undefined`, and two
|
|
181
|
+
* browsers are launched (the reported "两个任务同时唤醒会多开浏览器").
|
|
182
|
+
*/
|
|
147
183
|
export async function ensureBrowser(provider) {
|
|
184
|
+
const inFlight = provider.browserLaunch;
|
|
185
|
+
if (inFlight !== undefined)
|
|
186
|
+
return await inFlight;
|
|
187
|
+
const launch = launchBrowser(provider).finally(() => {
|
|
188
|
+
if (provider.browserLaunch === launch)
|
|
189
|
+
provider.browserLaunch = undefined;
|
|
190
|
+
});
|
|
191
|
+
provider.browserLaunch = launch;
|
|
192
|
+
return await launch;
|
|
193
|
+
}
|
|
194
|
+
/** The actual launch, shared by every concurrent {@link ensureBrowser} caller. */
|
|
195
|
+
async function launchBrowser(provider) {
|
|
148
196
|
if (provider.browser !== undefined)
|
|
149
197
|
return provider.browser;
|
|
150
|
-
const { executablePath, channel
|
|
198
|
+
const { executablePath, channel } = provider.config.launch;
|
|
199
|
+
const headless = provider.desiredHeadless();
|
|
151
200
|
const attempts = [];
|
|
152
201
|
if (executablePath !== undefined) {
|
|
153
202
|
try {
|
|
154
203
|
provider.browser = await chromium.launch({ executablePath, headless, ...HUMANIZED_LAUNCH });
|
|
204
|
+
provider.launchedHeadless = headless;
|
|
155
205
|
return provider.browser;
|
|
156
206
|
}
|
|
157
207
|
catch (error) {
|
|
@@ -164,6 +214,7 @@ export async function ensureBrowser(provider) {
|
|
|
164
214
|
for (const candidate of channels) {
|
|
165
215
|
try {
|
|
166
216
|
provider.browser = await chromium.launch({ channel: candidate, headless, ...HUMANIZED_LAUNCH });
|
|
217
|
+
provider.launchedHeadless = headless;
|
|
167
218
|
return provider.browser;
|
|
168
219
|
}
|
|
169
220
|
catch (error) {
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/** The four buckets the card can report and clean. */
|
|
2
|
+
export type TargetId = 'browser-cache' | 'temp-residue' | 'updater-residue' | 'attachment-cache';
|
|
3
|
+
/** Display order and labels for every target. */
|
|
4
|
+
export declare const TARGETS: readonly {
|
|
5
|
+
id: TargetId;
|
|
6
|
+
label: string;
|
|
7
|
+
}[];
|
|
8
|
+
/** Is this value one of the enum ids? Unknown strings are rejected, never joined into a path. */
|
|
9
|
+
export declare function isTargetId(value: unknown): value is TargetId;
|
|
10
|
+
/** Absolute locations this module may inspect. */
|
|
11
|
+
export interface CachePaths {
|
|
12
|
+
/** DSH home (`~/.dsh` unless DSH_HOME overrides it). */
|
|
13
|
+
dshHome: string;
|
|
14
|
+
/** Persistent Chromium profile directory of the browser capability. */
|
|
15
|
+
browserProfileDir: string;
|
|
16
|
+
/** OS temp directory. */
|
|
17
|
+
tmpDir: string;
|
|
18
|
+
/** Electron updater residue directory for the DSH desktop app. */
|
|
19
|
+
updaterDir: string;
|
|
20
|
+
/** Version currently installed — used to refuse other versions' installers. */
|
|
21
|
+
installedVersion: string;
|
|
22
|
+
}
|
|
23
|
+
/** Resolve the default paths from the environment (no path ever comes from the request). */
|
|
24
|
+
export declare function resolvePaths(env: NodeJS.ProcessEnv | undefined, version: string, platform?: NodeJS.Platform): CachePaths;
|
|
25
|
+
/** One row of the disk panel. */
|
|
26
|
+
export interface DiskItem {
|
|
27
|
+
id: TargetId;
|
|
28
|
+
label: string;
|
|
29
|
+
bytes: number;
|
|
30
|
+
files: number;
|
|
31
|
+
/** Optional human note (e.g. "浏览器正在运行,整段跳过"). */
|
|
32
|
+
note?: string;
|
|
33
|
+
}
|
|
34
|
+
/** What `scan()` reports. */
|
|
35
|
+
export interface ScanReport {
|
|
36
|
+
items: DiskItem[];
|
|
37
|
+
totalBytes: number;
|
|
38
|
+
attachments: {
|
|
39
|
+
objectsBytes: number;
|
|
40
|
+
requestImagesBytes: number;
|
|
41
|
+
};
|
|
42
|
+
skipped: {
|
|
43
|
+
id: TargetId;
|
|
44
|
+
reason: string;
|
|
45
|
+
}[];
|
|
46
|
+
}
|
|
47
|
+
/** One skipped entry inside a target. */
|
|
48
|
+
export interface SkippedEntry {
|
|
49
|
+
path: string;
|
|
50
|
+
reason: 'in-use' | 'permission' | 'other-version' | 'unknown-version' | 'missing';
|
|
51
|
+
}
|
|
52
|
+
/** Per-target outcome of a clean pass. */
|
|
53
|
+
export interface CleanTargetReport {
|
|
54
|
+
id: TargetId;
|
|
55
|
+
freedBytes: number;
|
|
56
|
+
skipped: SkippedEntry[];
|
|
57
|
+
}
|
|
58
|
+
/** What `clean()` reports. */
|
|
59
|
+
export interface CleanReport {
|
|
60
|
+
dryRun: boolean;
|
|
61
|
+
freedBytes: number;
|
|
62
|
+
perTarget: CleanTargetReport[];
|
|
63
|
+
}
|
|
64
|
+
/** Options for one clean pass. */
|
|
65
|
+
export interface CleanOptions extends CachePaths {
|
|
66
|
+
targets: readonly TargetId[];
|
|
67
|
+
dryRun: boolean;
|
|
68
|
+
/** True when the capability is enabled — reported, not used to skip silently. */
|
|
69
|
+
browserActive?: boolean;
|
|
70
|
+
/** Clock override for tests. */
|
|
71
|
+
now?: number;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Inspect every bucket without touching anything.
|
|
75
|
+
* @param paths - resolved locations
|
|
76
|
+
* @returns per-target sizes plus the attachment totals kept apart from the cache
|
|
77
|
+
*/
|
|
78
|
+
export declare function scan(paths: CachePaths): Promise<ScanReport>;
|
|
79
|
+
/**
|
|
80
|
+
* Clean the requested buckets. With `dryRun` the same walk happens but nothing
|
|
81
|
+
* is renamed or removed, so the preview shows real numbers.
|
|
82
|
+
*
|
|
83
|
+
* @param options - resolved paths, enum targets and dry-run flag
|
|
84
|
+
* @returns per-target freed bytes and every skipped entry with a reason
|
|
85
|
+
*/
|
|
86
|
+
export declare function clean(options: CleanOptions): Promise<CleanReport>;
|
|
87
|
+
/** Human-readable size for the card and the CLI. */
|
|
88
|
+
export declare function formatBytes(bytes: number): string;
|
|
89
|
+
/** Basename helper re-exported for the route's skipped-entry display. */
|
|
90
|
+
export declare function displayName(path: string): string;
|