dsh-browser-playwright-codex 0.7.4 → 0.7.6
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 +60 -0
- package/README.md +10 -10
- package/SECURITY.md +1 -1
- package/lib/browser-lifecycle.d.ts +13 -1
- package/lib/browser-lifecycle.js +49 -5
- package/lib/errors.d.ts +12 -0
- package/lib/errors.js +16 -0
- package/lib/gates-patch.js +0 -1
- package/lib/index.d.ts +6 -3
- package/lib/index.js +4 -2
- package/lib/legacy-config.d.ts +93 -0
- package/lib/legacy-config.js +238 -0
- 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 +3 -1
- package/lib/plugin.js +5 -0
- package/lib/provider.d.ts +42 -0
- package/lib/provider.js +80 -1
- package/lib/session.d.ts +40 -4
- package/lib/session.js +95 -5
- package/lib/settings-card.js +15 -4
- package/lib/tool.js +79 -54
- package/lib/types.d.ts +28 -0
- package/lib/url-policy.d.ts +13 -0
- package/lib/url-policy.js +24 -2
- package/lib/wait-conditions.d.ts +55 -0
- package/lib/wait-conditions.js +141 -0
- package/package.json +1 -1
- package/scripts/dsh-browser-verify.mjs +34 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,63 @@
|
|
|
1
|
+
## 0.7.6 — 2026-10-26
|
|
2
|
+
|
|
3
|
+
一句话摘要:**等待从"猜时长"变成"等条件",升级时旧配置不再静默降级** —— `browser_wait` 现在能等元素出现、等文字出现、等网络安静,条件达成那一刻就返回;同时 0.7.0 遗留的旧加载行第一次被主动报出来(此前它会让部署悄悄跑回默认值,两个强能力等于关着)。
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **旧加载行静默降级(升级坑)**:0.7.0 把三行 loader 并成一行 `- id: browser`,旧行不再被读取,但**没有任何提示**——补丁层里还写着 `- id: browser-tool` 的部署会直接按包内默认值运行(`allowEvaluate` / `allowCdp` 默认关闭),用户以为开着其实关着。实测在一个真实的 0.7.4 部署上就是这个状态:工具面只有 16 个,`evaluate` / `cdp` 全不在,而 YAML 里明明写着 `allowEvaluate: true`。新增 `src/legacy-config.ts`:启动时只读扫描 home 层与各 profile 层的 `cordis.patch.yml`,发现旧行就在宿主日志里报出**文件名 + 行号 + 改法**;同一个 DSH home 只报一次,读不到就静默跳过(这条检查永远不会阻止插件加载)。判据只认每行的 `name`(包名),**不认 `- id:`**——id 只是本地标签,别人的插件也可能把行叫成 `browser-tool`,按 id 报警会冤枉人。退役包(`dsh-browser-toggle` / `dsh-browser-playwright`)单独标一类:它们自带旧设置卡,会反复把配置改回旧写法,所以提示里点名"先去掉那一行"。
|
|
8
|
+
- **报错文案还在教旧名字**:`EVALUATE_DISABLED` / `CDP_DISABLED` 的消息此前写"去 browser-tool 插件上打开 allowEvaluate"。那个插件 0.7.0 就没了,照着改还是改不到。现在指向真正的位置:`cordis.patch.yml` 里单行 `- id: browser` 的 `tool.allowEvaluate` / `tool.allowCdp`,并提示设置卡会自动重写这一行。
|
|
9
|
+
- **快照工具的描述让模型去用一个不存在的工具**:合并 click / hover 之后描述里仍写着 `browser_hover`,模型照着调只会得到"没有这个工具"。改成 `browser_click` + `mode:"hover"`。
|
|
10
|
+
- **设置卡写回的 YAML 里 `diagnostics` 键重复**:`renderBrowserBlock` 既按参数写一行、又硬写一行 `diagnostics: false`。解析方各自取第一条所以之前没炸,但重复键在严格 YAML 里是非法的。删掉多余那行。
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`browser_wait` 支持条件等待**:`selector`(元素可见)、`text`(可见文字出现)、`networkIdle`(500ms 无请求),与旧的 `ms` 四选一。条件达成立刻返回,并回报"等了什么、花了多少毫秒";超时报 `ACTION_TIMEOUT` 并提示先取快照看状态,而不是盲目重试。旧行为完整保留:`session.wait(ms)` 一个字没动,`browser_wait { ms }` 照常可用。
|
|
15
|
+
- **`src/wait-conditions.ts`**:校验与执行分离,所以参数错误在**启动浏览器之前**就被拒;会话层自己也会校验一遍,程序化调用绕不过去。`timeoutMs` 默认 15000,上限由部署的 `maxWaitMs` 收紧。
|
|
16
|
+
- **公开导出(只增不减)**:`normalizeWaitCondition`、`describeWait`、`DEFAULT_WAIT_TIMEOUT_MS`、类型 `WaitCondition` / `WaitOutcome`;方法 `PlaywrightSession.waitForCondition()`,以及 `BrowserSession` 接口里对应的声明。
|
|
17
|
+
- **`tests/legacy-config.test.ts`(10 条)**:旧三行布局逐行报出、合并后的单行布局与注释掉的旧行都算干净、改名但沿用旧 `name` 的行也能抓、**别人的插件把行叫 `browser-tool` 不报**、退役包单独归类、警告文本含文件名/行号/替换写法与判据说明、home 与 profile 层都扫、干净的部署一声不响且同一 home 只报一次、home 不存在不抛错。
|
|
18
|
+
- **`tests/wait-conditions.test.ts`(9 条)**:无条件/双条件被拒、`ms` 边界不变、默认 15s 与部署上限、超时文案指向快照、selector / text / networkIdle 三条执行路径各一条(用假页面,不需要真浏览器)。
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- **说明书瘦身**:新增 4 个参数的同时把常驻成本**净减**——16 工具 11,179 → **11,095** 字符(−84),全量注册 15,782 → 15,698(−84);字符与 token 的对照见 README 的成本表。压缩的是重复与冗词:快照、填写、弹窗、导航、标签五处描述,加上点击工具的四个参数说明;点击工具那张"每条规则必须还在"的守卫表(30 条)一条没动。
|
|
23
|
+
|
|
24
|
+
### Tests
|
|
25
|
+
|
|
26
|
+
- 全套 255 → **274 条**(+19):通过 271、跳过 3、失败 0。
|
|
27
|
+
|
|
28
|
+
## 0.7.5 — 2026-10-26
|
|
29
|
+
|
|
30
|
+
一句话摘要:**两条安全洞补上,同时唤醒不再多开浏览器** —— 白名单从"门口打一次勾"变成"装在浏览器的请求上",`allowEvaluate` / `allowCdp` 从工具层下沉到会话层(绕过工具直接拿服务也拒),两个任务同时要浏览器时共用一次启动,默认持久化模式下「最多几个会话」也真的算数了。
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
|
|
34
|
+
- **白名单可被绕过(安全)**:入口检查只覆盖 `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 / 字体 / 媒体这类渲染资源放行,否则页面只会看起来是坏的。空名单(默认)完全不接管路由,所以默认行为一个字没变。
|
|
35
|
+
- **302 跳转是请求拦截看不见的那一跳**:Chromium 在它自己的网络栈里跟随重定向,`context.route()` 根本不会为那一跳触发(实测)。所以每个动作收尾时还会核对页面**最终落在哪个主机**——落在名单外就报 `URL_NOT_ALLOWED`,并把标签页重置成 `about:blank`,绝不留一个可以继续驱动的越界页面。
|
|
36
|
+
- **evaluate / CDP 的开关只在工具层挡(安全)**:此前只有 `tool.ts` 检查 `allowEvaluate` / `allowCdp`,任何拿到浏览器服务的代码(兄弟插件、脚本)都能直接调 `session.evaluate` 跑任意页面 JS,或用 `session.cdp` 发白名单外的 `Runtime.evaluate`(两条都实测跑通)。现在 `session.evaluate` / `session.cdp` 自己就拒(`EVALUATE_DISABLED` / `CDP_DISABLED`),CDP 还要再过一道白名单(`CDP_DENIED`)。**没发布过的门禁算关闭**——部署没说「可以」就是「不可以」。
|
|
37
|
+
- **两个任务同时唤醒会多开浏览器**:`ensureContext` / `ensureBrowser` 没有在途合并,两个会话同时醒来都看到"还没有 context",于是各自 `launchPersistentContext` 同一个 profile。实测后果比"多开"更难看:第二个死在 Chrome 的单例锁上,恢复流程**把正在运行的那个浏览器的锁文件删了**,而调用方收到的错误是「你没装浏览器」——锁被删正是之后关不干净、残留进程的来路。现在并发调用共享同一次启动,并且在途启动结束就清空;恢复路径也不再动活浏览器的锁文件。
|
|
38
|
+
- **`maxSessions` 在默认(持久化)模式不生效**:这个上限只写在非持久化分支里,而默认就是持久化模式,所以形同虚设。现在它按**会话句柄**生效:最久没用、且当前没有操作在跑的句柄会被回收,共用窗口一动不动,那个 owner 下次拿到一个新句柄(同一批标签页,索引回到 0)。顺便修掉 `browser-lifecycle.ts` 一处复制粘贴多出来的重复赋值。
|
|
39
|
+
- **设置卡可能把关闭的门禁显示成开着**:`publishGates` 原来上报的是「工具是否注册」,而 `registerDisabledTools: true` 会故意把关着的工具也注册(好让模型能发现它并读到说明),于是卡片把关闭的能力显示为开启。现在上报的是能力值本身。
|
|
40
|
+
|
|
41
|
+
### Added
|
|
42
|
+
|
|
43
|
+
- **`src/navigation-guard.ts`**:名单的请求层实现,含"文档请求 / 数据请求 / 渲染资源"三分类与拒绝记录。
|
|
44
|
+
- **公开导出(只增不减)**:`isHostAllowed`、`installHostGuard`、`BlockedRequest`、`evaluateDisabled`、`cdpDisabled`。
|
|
45
|
+
- **`tests/host-guard.test.ts`(4 条,真浏览器)**:空名单不插手(默认行为不变)、302 越界被拒并报出主机名、页面自己跳转永远到不了名单外、`fetch` 到名单外**服务器一个请求都没收到**。
|
|
46
|
+
- **`tests/gates-enforcement.test.ts`(5 条,不需要浏览器)**:没发布门禁时 `evaluate` / `cdp` 都拒、开了 CDP 门禁 `Runtime.evaluate` 仍被白名单拒、门禁开关对会话层依旧权威、`getGates()` 报的是能力值。
|
|
47
|
+
- **`tests/concurrency.test.ts`(3 条,真浏览器)**:三个会话同时唤醒共用一个窗口、两个会话同时唤醒共用一个浏览器、持久化模式下 `maxSessions` 按 LRU 回收句柄且窗口不动。
|
|
48
|
+
- **随包验收新增 `[0]` 段(3 条)**:不经工具层直接调 `session.evaluate` / `session.cdp` 必须被拒;开了门禁后 `Runtime.evaluate` 仍报 `CDP_DENIED`。
|
|
49
|
+
|
|
50
|
+
### Changed
|
|
51
|
+
|
|
52
|
+
- **行为变化**:代码层调用 `evaluate` / `cdp` 现在也要过门禁,且未发布门禁 = 关闭。走工具层的老路径行为不变(工具层先给出同样口径的错误)。
|
|
53
|
+
- **`allowedDomains` 的语义收紧**:填了名单,就不再只是"插件不去",而是"这个浏览器不去"。
|
|
54
|
+
- 三个测试文件(`provider` / `regressions` / `scenarios`,另加 `live-smoke`)显式发布门禁——它们本来就直接调 `session.evaluate`,等于把自己声明成"开了开关的部署"。
|
|
55
|
+
|
|
56
|
+
### Tests
|
|
57
|
+
|
|
58
|
+
- 全套 243 → **255 条**(+12):通过 252、跳过 3、失败 0。
|
|
59
|
+
- `npm run verify` 57 → **60 项**:`[0]` 段三条是新增的门禁回归闸。
|
|
60
|
+
|
|
1
61
|
## 0.7.4 — 2026-10-23
|
|
2
62
|
|
|
3
63
|
一句话摘要:**设置卡可以切换有头 / 无头** —— 运行时开关,不用改 YAML、不用重启 DSH;切换在**下一次操作**时生效(先导出登录态,再按新模式重开窗口)。默认仍是有头。
|
package/README.md
CHANGED
|
@@ -130,7 +130,7 @@ The default `toolPrefix` is `browser_`. Every action returns a fresh snapshot, s
|
|
|
130
130
|
| `browser_press` | Press a key (Enter, Tab, Escape, ArrowDown, …) on an element |
|
|
131
131
|
| `browser_scroll` | Scroll the page or bring a ref into view |
|
|
132
132
|
| `browser_back` / `browser_forward` | History navigation |
|
|
133
|
-
| `browser_wait` | Wait a bounded duration
|
|
133
|
+
| `browser_wait` | Wait for one condition — an element, visible text, a quiet network — or a bounded duration; returns what it waited for |
|
|
134
134
|
| `browser_tabs` / `browser_open_tab` / `browser_switch_tab` / `browser_close_tab` | Tab management; `browser_tabs` marks the `[active]` tab the other tools act on |
|
|
135
135
|
| `browser_screenshot` | PNG capture stored as a durable image attachment the model can view |
|
|
136
136
|
| `browser_extract` | Structured extraction: natural-language instruction → one parsed JSON value; **only registered when its capability is enabled** |
|
|
@@ -167,7 +167,7 @@ parameter or a wrong required flag fails the test, so it cannot drift from the c
|
|
|
167
167
|
- `browser_snapshot` — `interactiveOnly`?
|
|
168
168
|
- `browser_switch_tab` — `index`*
|
|
169
169
|
- `browser_tabs` — _none_
|
|
170
|
-
- `browser_wait` — `ms
|
|
170
|
+
- `browser_wait` — `ms`?, `selector`?, `text`?, `networkIdle`?, `timeoutMs`?
|
|
171
171
|
|
|
172
172
|
> **使用铁律**: 状态检查 / 无效不盲试 / 页面内容不可信 / 登录红线 / 风控分类等 Codex 文档纪律,
|
|
173
173
|
> 见 [`codex-rules.md`](https://github.com/miqian-nomad/dsh-browser-playwright-codex/blob/main/codex-rules.md)(A 层已内嵌进各工具 description; B 层供粘贴进 DSH profile / 系统提示)。
|
|
@@ -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
|
|
@@ -330,7 +330,7 @@ npm install
|
|
|
330
330
|
npm test # full suite: engine/action integration on real Chrome, policy, rendering,
|
|
331
331
|
# schema assembly, plus the realistic scenario suites below
|
|
332
332
|
npm run verify # naming/folding + click/hover/dialog acceptance; self-contained, prints its own count
|
|
333
|
-
# (
|
|
333
|
+
# (60 in a checkout; from an npm install it prints 59 + "1 skipped", because the
|
|
334
334
|
# `lib/`-vs-`src/` check has no `src/` to compare against)
|
|
335
335
|
npm run verify:live # same, plus a live sample of a real page (needs network)
|
|
336
336
|
npm run doctor # does the installed harness still export everything this plugin imports?
|
|
@@ -378,9 +378,9 @@ Measured with ``npm run cost`` (per-turn, resident in the system prompt):
|
|
|
378
378
|
|
|
379
379
|
| Surface | Tools | Schema characters | ≈ tokens/turn |
|
|
380
380
|
|---|---|---|---|
|
|
381
|
-
| Schema defaults (`Config({})`, no patch layer) | 16 | 11,
|
|
382
|
-
| Shipped bundle layer (this package's `cordis.patch.yml`: both power gates off) | 16 | 11,
|
|
383
|
-
| Everything registered (`registerDisabledTools: true`, plus `extract`) | 21 | 15,
|
|
381
|
+
| Schema defaults (`Config({})`, no patch layer) | 16 | 11,095 | ≈ 3,082 |
|
|
382
|
+
| Shipped bundle layer (this package's `cordis.patch.yml`: both power gates off) | 16 | 11,095 | ≈ 3,082 |
|
|
383
|
+
| Everything registered (`registerDisabledTools: true`, plus `extract`) | 21 | 15,698 | ≈ 4,361 |
|
|
384
384
|
|
|
385
385
|
This paragraph describes the 0.6.0 change; the table above is today's measurement. Three things changed
|
|
386
386
|
there, and only one of them was about size. (1) The click family was rewritten so each rule is stated once
|
|
@@ -426,7 +426,7 @@ links below go to GitHub rather than to files inside the package:
|
|
|
426
426
|
- **Headless servers must opt in** — the bundle defaults to `headless: false`; on a headless/remote host set `launch.headless: true`.
|
|
427
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.
|
|
428
428
|
- **Cross-origin iframes** appear as leaf `(frame)` nodes without refs; same-origin frames are walked up to two levels deep.
|
|
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.
|
|
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.
|
|
430
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:
|
|
431
431
|
```yaml
|
|
432
432
|
# dsh replaces an entry's config object wholesale — list every key you rely on.
|
|
@@ -444,7 +444,7 @@ links below go to GitHub rather than to files inside the package:
|
|
|
444
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.
|
|
445
445
|
- **Chromium family only** — Firefox/WebKit channels are not probed; providers are swappable if another engine is needed.
|
|
446
446
|
- **Extract needs a dedicated model route** — it does not reuse the main request's route; misconfiguration fails loudly at call time.
|
|
447
|
-
-
|
|
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.
|
|
448
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.
|
|
449
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.
|
|
450
450
|
|
|
@@ -454,7 +454,7 @@ The scenario suites originally surfaced four hazards, now fixed and regression-t
|
|
|
454
454
|
|
|
455
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.
|
|
456
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.
|
|
457
|
-
- **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).
|
|
458
458
|
- **Idle disposal defers during operations** — a navigation slower than `idleTimeoutMs` completes normally; disposal only fires when the session is truly idle.
|
|
459
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.
|
|
460
460
|
|
package/SECURITY.md
CHANGED
|
@@ -114,7 +114,7 @@ Two honest notes:
|
|
|
114
114
|
npm test # 219 tests: contracts, tool schemas, scenarios,
|
|
115
115
|
# aria engine, CDP policy, diff, click/tab allow-lists, snapshot rendering,
|
|
116
116
|
# prompt budget, docs-vs-reality consistency
|
|
117
|
-
npm run verify #
|
|
117
|
+
npm run verify # 60 self-contained acceptance checks in a checkout (59 checks + 1 skipped
|
|
118
118
|
# from an npm install, where there is no src/): no DSH, no network, local fixture server
|
|
119
119
|
npm run doctor # the host packages this plugin imports are still exporting what it uses
|
|
120
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();
|
|
@@ -60,14 +78,19 @@ export async function ensureContext(provider) {
|
|
|
60
78
|
provider.launchedHeadless = headless;
|
|
61
79
|
}
|
|
62
80
|
catch (error) {
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
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.
|
|
66
90
|
if (error instanceof Error && /user data directory|SingletonLock|ProcessSingleton/i.test(error.message)) {
|
|
67
91
|
await provider.clearChromeLocks();
|
|
68
92
|
provider.context = await chromium.launchPersistentContext(provider.profileDir, { ...baseOptions, ...extra });
|
|
69
93
|
provider.launchedHeadless = headless;
|
|
70
|
-
provider.launchedHeadless = headless;
|
|
71
94
|
}
|
|
72
95
|
else {
|
|
73
96
|
throw error;
|
|
@@ -95,6 +118,9 @@ export async function ensureContext(provider) {
|
|
|
95
118
|
catch {
|
|
96
119
|
/* malformed state file must not block startup */
|
|
97
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);
|
|
98
124
|
await ctx.addInitScript(SNAPSHOT_SCRIPT);
|
|
99
125
|
// The initial blank page predates addInitScript: reload so the
|
|
100
126
|
// snapshot engine and restored localStorage apply on first use.
|
|
@@ -147,8 +173,26 @@ export async function clearChromeLocks(provider) {
|
|
|
147
173
|
}
|
|
148
174
|
}
|
|
149
175
|
}
|
|
150
|
-
/**
|
|
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
|
+
*/
|
|
151
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) {
|
|
152
196
|
if (provider.browser !== undefined)
|
|
153
197
|
return provider.browser;
|
|
154
198
|
const { executablePath, channel } = provider.config.launch;
|
package/lib/errors.d.ts
CHANGED
|
@@ -23,3 +23,15 @@ export declare class BrowserError extends Error {
|
|
|
23
23
|
}
|
|
24
24
|
/** Wrap an unknown cause into a launch failure with the code preserved. */
|
|
25
25
|
export declare function launchFailed(cause: unknown): BrowserError;
|
|
26
|
+
/**
|
|
27
|
+
* "browser_evaluate is switched off". One wording, thrown by both layers that
|
|
28
|
+
* enforce it: the tool layer (before a browser is even launched) and the
|
|
29
|
+
* session itself (so a programmatic caller cannot go around the switch).
|
|
30
|
+
* @returns the denial, ready to throw.
|
|
31
|
+
*/
|
|
32
|
+
export declare function evaluateDisabled(): BrowserError;
|
|
33
|
+
/**
|
|
34
|
+
* "browser_cdp is switched off". Same two-layer rule as {@link evaluateDisabled}.
|
|
35
|
+
* @returns the denial, ready to throw.
|
|
36
|
+
*/
|
|
37
|
+
export declare function cdpDisabled(): BrowserError;
|
package/lib/errors.js
CHANGED
|
@@ -19,3 +19,19 @@ export function launchFailed(cause) {
|
|
|
19
19
|
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
20
20
|
return new BrowserError('BROWSER_LAUNCH_FAILED', 'failed to launch the browser: ' + detail);
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* "browser_evaluate is switched off". One wording, thrown by both layers that
|
|
24
|
+
* enforce it: the tool layer (before a browser is even launched) and the
|
|
25
|
+
* session itself (so a programmatic caller cannot go around the switch).
|
|
26
|
+
* @returns the denial, ready to throw.
|
|
27
|
+
*/
|
|
28
|
+
export function evaluateDisabled() {
|
|
29
|
+
return new BrowserError('EVALUATE_DISABLED', "browser_evaluate is disabled; set tool.allowEvaluate: true on the single '- id: browser' row of cordis.patch.yml (the Settings card rewrites that row for you) to enable arbitrary page JavaScript");
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* "browser_cdp is switched off". Same two-layer rule as {@link evaluateDisabled}.
|
|
33
|
+
* @returns the denial, ready to throw.
|
|
34
|
+
*/
|
|
35
|
+
export function cdpDisabled() {
|
|
36
|
+
return new BrowserError('CDP_DISABLED', "browser_cdp is disabled; set tool.allowCdp: true on the single '- id: browser' row of cordis.patch.yml (the Settings card rewrites that row for you) to enable raw CDP inspection commands");
|
|
37
|
+
}
|
package/lib/gates-patch.js
CHANGED
|
@@ -75,7 +75,6 @@ export function renderBrowserBlock(allowEvaluate, allowCdp, diagnostics) {
|
|
|
75
75
|
' allowCdp: ' + (allowCdp ? 'true' : 'false'),
|
|
76
76
|
' diagnostics: ' + (diagnostics ? 'true' : 'false'),
|
|
77
77
|
' registerDisabledTools: false',
|
|
78
|
-
' diagnostics: false',
|
|
79
78
|
' maxWaitMs: 60000',
|
|
80
79
|
END,
|
|
81
80
|
'',
|
package/lib/index.d.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* session-scoped browser contexts, screenshots as attachments.
|
|
5
5
|
* @module dsh-browser-playwright-codex
|
|
6
6
|
*/
|
|
7
|
-
export { BrowserError, launchFailed } from './errors.ts';
|
|
7
|
+
export { BrowserError, cdpDisabled, evaluateDisabled, launchFailed } from './errors.ts';
|
|
8
8
|
export type { BrowserErrorCode } from './errors.ts';
|
|
9
9
|
export { ALWAYS_ON_SUFFIXES, ERROR_CODES, GATED_SUFFIXES, TOOL_SUFFIXES, registeredSuffixes, registeredToolNames, toolNames, } from './contract.ts';
|
|
10
10
|
export type { ToolSuffix, ToolSurfaceConfig } from './contract.ts';
|
|
@@ -15,10 +15,13 @@ export type { SnapshotOptions, PageDataOptions } from './injected.ts';
|
|
|
15
15
|
export { default as BrowserRuntime } from './service.ts';
|
|
16
16
|
export type { BrowserRuntimeConfig } from './service.ts';
|
|
17
17
|
export { PlaywrightProvider } from './provider.ts';
|
|
18
|
-
export { assertAllowedUrl } from './url-policy.ts';
|
|
18
|
+
export { assertAllowedUrl, isHostAllowed } from './url-policy.ts';
|
|
19
|
+
export { installHostGuard } from './navigation-guard.ts';
|
|
20
|
+
export type { BlockedRequest } from './navigation-guard.ts';
|
|
21
|
+
export { DEFAULT_WAIT_TIMEOUT_MS, describeWait, normalizeWaitCondition } from './wait-conditions.ts';
|
|
19
22
|
export { Config as PlaywrightConfigSchema } from './config.ts';
|
|
20
23
|
export type { PlaywrightConfig, PageData } from './config.ts';
|
|
21
|
-
export type { BrowserNode, BrowserProvider, BrowserSession, BrowserSnapshot, LoadState, ScreenshotCapture, TabInfo, } from './types.ts';
|
|
24
|
+
export type { BrowserNode, BrowserProvider, BrowserSession, BrowserSnapshot, LoadState, ScreenshotCapture, TabInfo, WaitCondition, WaitOutcome, } from './types.ts';
|
|
22
25
|
export type { ToolConfig } from './tool.ts';
|
|
23
26
|
export { Config as BrowserPluginConfigSchema, apply, name } from './plugin.ts';
|
|
24
27
|
export type { BrowserPluginConfig } from './plugin.ts';
|
package/lib/index.js
CHANGED
|
@@ -4,13 +4,15 @@
|
|
|
4
4
|
* session-scoped browser contexts, screenshots as attachments.
|
|
5
5
|
* @module dsh-browser-playwright-codex
|
|
6
6
|
*/
|
|
7
|
-
export { BrowserError, launchFailed } from "./errors.js";
|
|
7
|
+
export { BrowserError, cdpDisabled, evaluateDisabled, launchFailed } from "./errors.js";
|
|
8
8
|
export { ALWAYS_ON_SUFFIXES, ERROR_CODES, GATED_SUFFIXES, TOOL_SUFFIXES, registeredSuffixes, registeredToolNames, toolNames, } from "./contract.js";
|
|
9
9
|
export { CallId, toolCallId } from "./compat.js";
|
|
10
10
|
export { SNAPSHOT_SCRIPT } from "./injected.js";
|
|
11
11
|
export { default as BrowserRuntime } from "./service.js";
|
|
12
12
|
export { PlaywrightProvider } from "./provider.js";
|
|
13
|
-
export { assertAllowedUrl } from "./url-policy.js";
|
|
13
|
+
export { assertAllowedUrl, isHostAllowed } from "./url-policy.js";
|
|
14
|
+
export { installHostGuard } from "./navigation-guard.js";
|
|
15
|
+
export { DEFAULT_WAIT_TIMEOUT_MS, describeWait, normalizeWaitCondition } from "./wait-conditions.js";
|
|
14
16
|
export { Config as PlaywrightConfigSchema } from "./config.js";
|
|
15
17
|
// Single loader row (2026-10-19): the package root is the plugin. The three
|
|
16
18
|
// sub-entries below stay importable, but the loader mounts this one.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
2
|
+
/** Row ids that 0.7.0 stopped reading. */
|
|
3
|
+
export declare const LEGACY_ROW_IDS: readonly ["browser-playwright", "browser-tool", "browser-playwright-service"];
|
|
4
|
+
/**
|
|
5
|
+
* Subpath entries that 0.7.0 stopped mounting. Rows naming one of these are
|
|
6
|
+
* stale even when the user gave them some other id.
|
|
7
|
+
*/
|
|
8
|
+
export declare const LEGACY_ENTRY_NAMES: readonly ["dsh-browser-playwright-codex/tool", "dsh-browser-playwright-codex/playwright", "dsh-browser-playwright-codex/service"];
|
|
9
|
+
/**
|
|
10
|
+
* Retired package names this project owns on npm. A row mounting one of them
|
|
11
|
+
* keeps a second, older settings card alive — and that card rewrites the patch
|
|
12
|
+
* layer in the pre-0.7.0 shape, so the fix keeps reverting.
|
|
13
|
+
*
|
|
14
|
+
* Matching is by package NAME, never by row id: `- id:` is a local label the
|
|
15
|
+
* user picks, so another plugin may legitimately call its row `browser-tool`.
|
|
16
|
+
* Only a row whose `name:` is one of ours is reported.
|
|
17
|
+
*/
|
|
18
|
+
export declare const RETIRED_PACKAGES: readonly ["dsh-browser-playwright", "dsh-browser-toggle"];
|
|
19
|
+
/** One loader row that no longer takes effect. */
|
|
20
|
+
export interface LegacyRow {
|
|
21
|
+
/** The row id, or `(no id)` when the row only carried a legacy `name`. */
|
|
22
|
+
readonly id: string;
|
|
23
|
+
/** The row's `name:` value when it had one. */
|
|
24
|
+
readonly name?: string;
|
|
25
|
+
/** 1-based line number of the row's `- id:` line. */
|
|
26
|
+
readonly line: number;
|
|
27
|
+
/**
|
|
28
|
+
* `entry`: a row mounting a subpath entry this package no longer exposes.
|
|
29
|
+
* `package`: a row mounting a whole package that 0.7.0 retired.
|
|
30
|
+
*/
|
|
31
|
+
readonly target: 'entry' | 'package';
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Find loader rows in one patch file's text that 0.7.0 stopped reading.
|
|
35
|
+
*
|
|
36
|
+
* The evidence is always the row's `name:` — never its `- id:`, which is just a
|
|
37
|
+
* local label. A patch layer that happens to call some unrelated plugin's row
|
|
38
|
+
* `browser-tool` is not reported.
|
|
39
|
+
*
|
|
40
|
+
* Pure and text-only so the rule can be tested without touching a real patch
|
|
41
|
+
* layer. Comments are ignored naturally: a commented row fails the anchored
|
|
42
|
+
* `- id:` / ` name:` patterns.
|
|
43
|
+
*
|
|
44
|
+
* @param text - contents of a cordis.patch.yml layer
|
|
45
|
+
* @returns every stale row, in file order
|
|
46
|
+
*/
|
|
47
|
+
export declare function findLegacyRowsIn(text: string): LegacyRow[];
|
|
48
|
+
/**
|
|
49
|
+
* The operator-facing warning for one patch file.
|
|
50
|
+
*
|
|
51
|
+
* Written as one self-contained paragraph pair on purpose: it lands in a log
|
|
52
|
+
* line, so it has to name the file, the rows, and the fix without the reader
|
|
53
|
+
* opening documentation first.
|
|
54
|
+
*
|
|
55
|
+
* @param file - absolute path of the patch layer
|
|
56
|
+
* @param rows - stale rows found in it
|
|
57
|
+
* @returns the warning text
|
|
58
|
+
*/
|
|
59
|
+
export declare function legacyRowWarning(file: string, rows: readonly LegacyRow[]): string;
|
|
60
|
+
/**
|
|
61
|
+
* Patch layers that can hold a browser row: the home layer plus every profile
|
|
62
|
+
* layer. Missing files and unreadable directories are skipped silently — this
|
|
63
|
+
* check must never be the reason a plugin fails to load.
|
|
64
|
+
*
|
|
65
|
+
* @param env - environment (`DSH_HOME` override, as the rest of the package uses)
|
|
66
|
+
* @returns candidate file paths, home layer first
|
|
67
|
+
*/
|
|
68
|
+
export declare function patchLayers(env?: NodeJS.ProcessEnv): Promise<string[]>;
|
|
69
|
+
/** Stale rows found in one patch file. */
|
|
70
|
+
export interface LegacyHit {
|
|
71
|
+
/** Absolute path of the patch layer. */
|
|
72
|
+
readonly file: string;
|
|
73
|
+
/** Stale rows inside it. */
|
|
74
|
+
readonly rows: readonly LegacyRow[];
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Scan every patch layer for stale rows.
|
|
78
|
+
*
|
|
79
|
+
* @param env - environment (`DSH_HOME` override)
|
|
80
|
+
* @returns one entry per file that still lists a retired row; empty when clean
|
|
81
|
+
*/
|
|
82
|
+
export declare function scanLegacyRows(env?: NodeJS.ProcessEnv): Promise<LegacyHit[]>;
|
|
83
|
+
/**
|
|
84
|
+
* Warn about stale loader rows, once per DSH home, never throwing.
|
|
85
|
+
*
|
|
86
|
+
* Call it from the plugin entry: it is advisory only, so a failure here must
|
|
87
|
+
* never affect mounting the capability.
|
|
88
|
+
*
|
|
89
|
+
* @param ctx - plugin context (its logger carries the message to the host log)
|
|
90
|
+
* @param env - environment (`DSH_HOME` override, as tests use)
|
|
91
|
+
* @param report - sink for the text (defaults to the context logger, then console)
|
|
92
|
+
*/
|
|
93
|
+
export declare function warnLegacyRows(ctx: Context, env?: NodeJS.ProcessEnv, report?: (text: string) => void): Promise<void>;
|