dsh-browser-playwright-codex 0.6.5 → 0.6.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 CHANGED
@@ -3,6 +3,34 @@
3
3
  Bilingual by intent: the version headings and the summary of each release are given in both
4
4
  languages, the individual entries follow the language the surrounding code and docs use.
5
5
 
6
+ ## 0.6.6 — 2026-10-17
7
+
8
+ 一句话摘要:**四份独立审查(文档漂移 / 代码对抗 / 发行打包 / 中性冷读)查出的问题集中修掉**——两条安全缺陷、几处"假成功/假失败"、随包自检的假承诺、发布门禁漏洞与一批文档漂移。
9
+
10
+ ### Security
11
+
12
+ - **点击白名单可被祖先 `<a>` 绕过**:`browser_click` 的 `ref` / `text` / `precise` 三条路径只校验**被点元素自身**的 href,外层 `<a href="http://外站/">` 包住它时点击会导航到站外并返回成功。现在改为「自身 → 最近祖先 `<a href>`(穿 shadow host)」,与裸坐标路径同层,统一交给 URL 策略。两处独立审查各自用本地 fixture 复现过。
13
+ - **密码/验证码/卡号曾进入 prompt**:`browser_extract` 把 `pageData()` 拼进辅助模型的 prompt,而 `pageData()` 采集输入框**当前值**——登录页的明文密码会随请求发出去(实测 prompt 里出现 `hunter2`)。现在 `type=password` 与 `autocomplete` 为 `current-password` / `new-password` / `one-time-code` / `cc-number` / `cc-csc` / `cc-exp` 的字段一律置空值,**字段本身保留**(模型仍知道"这里有个码要填")。
14
+
15
+ ### Fixed
16
+
17
+ - **填表核对两头错**:上一版把回读改成严格相等后,页面自动改写格式的输入框(全大写、千分位 `1,000`)被误判成"没写进去",而 `browser_fill` 的描述恰恰把 input-mask / currency 列为预期场景;更早的版本则相反,`ABCD→ZZZZ`、`John→Jane` 会被判成功。现在的归一化只放过"页面改写、不是我们写错":NFKC + 去大小写 + 去掉非字母数字;`1000≡1,000`、`abc≡ABC` 通过,`ABCD↔ZZZZ`、`1234↔12345` 仍然失败。contenteditable 也不再假失败(改读 `textContent`/`innerText`)。
18
+ - **挑战页识别的两处回归**(本轮自查引入的):给控件层加"页面得够空"的门控后,**正常登录页里嵌的真 reCAPTCHA / hCaptcha / Turnstile 完全不再识别**(登录页正是主要场景),已移除该门控;`CAPTCHA_ROLES` 曾删掉 `iframe`,导致名字就叫「验证码」的非厂商 iframe 不被识别,已恢复。
19
+ - **`[nothing here]` 提示三类误报**:静默页扫描深度 4 → 8 层(正文藏得深的正常页曾被判"几乎没有文字",与同一结果树里的正文自相矛盾);无标题的裸文档(`about:blank`、直接打开图片)不再提示;有挂起弹窗时不出这条(否则与 `[dialog]` 指令冲突)。
20
+ - **提示互斥**:已判定为拦截页时不再叠 `[http NNN]`;已识别为挑战时不再叠行为兜底提示。
21
+
22
+ ### Added
23
+
24
+ - **测试 159 → 220**(+61):挑战识别 50 条(含真实页面测得的门控数值与反例:讲 hCaptcha 的文章、`验证码说明` 帮助链接、`获取验证码` 按钮、百度搜索页标题)、真浏览器端到端 6 条(真实 `cf-mitigated` 头、无签名拦截面被行为兜底抓住、`interactiveOnly` 下 iframe 仍识别、单页应用改 URL 后旧响应头不复用、真实 404 显示状态码、真实访客门显示静默页提示)、安全回归 5 条(外链祖先点击被拒、密码/OTP 不进 prompt、contenteditable 成功、同长度不同值失败)、本机路径守卫。
25
+
26
+ ### Changed
27
+
28
+ - **随包自检不再在 npm 安装形态崩溃**:`scripts/dsh-browser-verify.mjs` 的「`lib/` 与 `src/` 同步」一组在没有 `src/` 时**跳过**并打印说明,退出码 0 —— 安装形态显示「全部通过 (56 项,跳过 1 项)」,仓库形态仍是「(57 项)」。README / CHANGELOG 此前承诺的"装上即可跑"现在才真的成立。
29
+ - **依赖不再全是 `*`**:`@deepseek-ai/schemastery` 固定 `^3.18.4`,peer `cordis >=4.0.4`、`dsh-llm` / `dsh-tools >=0.2.0-rc.1`。修前空项目安装会解析到 `dsh-tools 0.0.1-rc.1`、`dsh-llm 0.0.1-rc.5`(远旧于测试基线);修后解析到 `0.2.0-rc.2`。代价:不同版本元组的预发布版(如 `0.2.1-rc.*`)会收到 **peer 警告**(不是失败,pnpm 默认不严格)。
30
+ - **发布门禁补齐**:CI 增加 `npm run typecheck` 与 `npm run preflight`(与 `prepublishOnly` 对齐);CI 与 publish 都新增「`npm run build` + `git diff --exit-code -- lib`」守卫——以前先 build 再比 mtime,`src` 改完忘重建也能全绿,而 `dsh plugin add github:…` 的安装路径会拿到旧 `lib/`。
31
+ - **文档漂移**:独立审查列出 13 条硬错误 / 11 条过期 / 8 条矛盾 / 8 条缺失,全部修掉——随包文档死链由 12 条清零(改 GitHub 绝对链接)、测试计数与成本数字按实际命令重取、`npm test:live` 与 `browser-plugin/dev.cordis.yml` 这类跑不通/不存在的引用纠正、URL 白名单不再声称覆盖 `hover`(实际不覆盖)、补写 `snapshot.engine` 说明,并在 `中文说明.md` 新增 **§4.14 挑战页识别**(四层判定与两条结果提示,明确"产出提示不是阻断,认不出来就停下来问人")。许可表述收敛为:代码与说明文档 MIT,CC BY-NC-SA 仅限 `FOCUS-STEALING.md` / `MODE-TRADE-OFFS.md` 两篇分析长文。
32
+ - **`.gitignore` 补登录态**:`dsh-storage-state.json` / `browser-profiles/` / `profile/`。默认写在 `~/.dsh/browser-profiles/`(仓库外),但只要有人把 `launch.profileDir` 指进 checkout,那个文件里就是明文 cookie。
33
+
6
34
  ## 0.6.5 — 2026-10-16
7
35
 
8
36
  一句话摘要:**挑战页识别不再靠一份关键词表** —— 判定改成「厂商自有的机器信号优先,认不出来就问一句『页面变了吗』」,所以新出现的验证方式也能被接住;顺带清掉漏网的 4 处本机路径,并把 npm 包里 12 个用不上的开发脚本收成 2 个。
package/NOTICE.md CHANGED
@@ -6,26 +6,25 @@ This repository mixes work from three different origins. They are **not** under
6
6
 
7
7
  ## 1. The plugin — MIT (`./LICENSE`)
8
8
 
9
- Covers everything that is part of the plugin: `src/`, `tests/`, `scripts/`, `cordis.patch.yml`, `tsconfig.json`, `package.json`, `CHANGELOG.md`, and the plugin's own documentation (`PLUGIN-README.md`, `DIALOG-POLICY.md`, `SAFETY-RULES.md`, `SNAPSHOT-RULES.md`, `codex-rules.md`).
9
+ Covers everything that is part of the plugin: `src/`, `tests/`, `scripts/`, `cordis.patch.yml`, `tsconfig.json`, `package.json`, `CHANGELOG.md`, and the plugin's own documentation (`README.md`, `SECURITY.md`, `NOTICE.md`, `PLUGIN-README.md`, `DIALOG-POLICY.md`, `SAFETY-RULES.md`, `SNAPSHOT-RULES.md`, `codex-rules.md`, `中文说明.md`, `对比与优势.md`, and the articles under `docs/articles/`).
10
10
 
11
11
  | Origin | What | Licence |
12
12
  |---|---|---|
13
13
  | [ChenyuHeee/dsh-browser-playwright](https://github.com/ChenyuHeee/dsh-browser-playwright) v0.1.1 | The accessibility-snapshot engine (`src/injected.ts`, `src/snapshot-render.ts`), the tool family (`src/tool.ts`), the service/provider seams (`src/service.ts`, `src/playwright.ts`), the URL policy, and the test-suite shape. Copyright (c) 2026 dsh-browser-playwright contributors. | **MIT** |
14
- | This fork's merged layer | Geometric click (`browser_click` with `precise: true`) with landing-element reporting, the CDP policy layer (`src/cdp-policy.ts`), the persistent profile + login-state export (`src/runtime-state.ts`, `persistState`/`restoreState`), dialog parking, the console/network diagnostics tools, the contract/compat modules, and the fixes listed in [`CHANGELOG.md`](CHANGELOG.md) | **MIT** |
14
+ | This fork's merged layer | Geometric click (`browser_click` with `precise: true`) with landing-element reporting, the CDP policy layer (`src/cdp-policy.ts`), the persistent profile + login-state export (`src/provider.ts`, `persistState`/`restoreState`), dialog parking, the console/network diagnostics tools, the contract/compat modules, and the fixes listed in [`CHANGELOG.md`](https://github.com/miqian-nomad/dsh-browser-playwright-codex/blob/main/CHANGELOG.md) | **MIT** |
15
15
 
16
16
  **Why the plugin cannot be non-commercial:** it is a derivative of MIT-licensed work. MIT grants everyone the right to use, modify and redistribute the software **including commercially**, and a redistributor cannot add a non-commercial restriction to it. Any non-commercial claim over `src/` would be an unauthorised restriction — worse for downstream users than leaving it MIT. The MIT text in `./LICENSE` (including the upstream copyright notice) is unchanged.
17
17
 
18
18
  ## 2. This repository's analysis prose — CC BY-NC-SA 4.0 (`./LICENSE-DOCS`)
19
19
 
20
- Covers only the writing produced *for this repository*:
20
+ Covers only the two analysis documents produced *for this repository*:
21
21
 
22
- - `README.md` (this repository's landing page)
23
22
  - `FOCUS-STEALING.md`
24
23
  - `MODE-TRADE-OFFS.md`
25
24
 
26
- Share and adapt for **non-commercial** purposes with attribution and share-alike; commercial use is not permitted. Legal code: [`LICENSE-DOCS`](LICENSE-DOCS).
25
+ Share and adapt for **non-commercial** purposes with attribution and share-alike; commercial use is not permitted. Legal code: [`LICENSE-DOCS`](https://github.com/miqian-nomad/dsh-browser-playwright-codex/blob/main/LICENSE-DOCS) (the licence text lives in the repository; it is not part of the npm tarball).
27
26
 
28
- Note the deliberate boundary: the plugin's *own* documentation is not in this set — it ships with the plugin under MIT, so that the whole plugin stays redistributable on MIT terms.
27
+ Note the deliberate boundary: these two documents are **not shipped in the npm package** — only `README.md`, `CHANGELOG.md`, `SECURITY.md` and `NOTICE.md` travel with the plugin, and all four are MIT (§1). So everything an npm installer actually receives stays redistributable on MIT terms.
29
28
 
30
29
  ## 3. Third-party content that is **not** redistributed here
31
30
 
package/README.md CHANGED
@@ -2,12 +2,13 @@
2
2
 
3
3
  > [![dsh.so 安装验证 · harness 0.2.0-rc.1](https://www.dsh.so/badge/install/dsh-browser-playwright-codex@0.2.0-rc.1.svg)](https://www.dsh.so/zh/artifact/dsh-browser-playwright-codex/) [![dsh.so 风险](https://www.dsh.so/badge/dsh-browser-playwright-codex.svg)](https://www.dsh.so/zh/artifact/dsh-browser-playwright-codex/)
4
4
 
5
- **一句话**:让 AI 用你**已经登录的账号**在真实网页上干活 —— 填表、抓取列表、多标签查后台;浏览器窗口就在你桌面上、你随时看得见;遇到登录、验证码、原生弹框,它会**停下来等你**,绝不替你点「确定」。
5
+ **一句话**:让 AI 用你**已经登录的账号**在真实网页上干活 —— 填表、抓取列表、多标签查后台;浏览器窗口就在你桌面上、你随时看得见。原生弹框会被挂起、**绝不替你点「确定」**;验证码/登录页会被识别出来并把情况报给你、请你本人接手(识别不出来时也会停下来问人,而不是硬闯)。
6
6
 
7
7
  **In one line**: the agent drives a **visible, profile-backed browser window** with the logins you
8
8
  already have — filling in forms, scraping lists and working across tabs in real pages — through
9
- accessibility snapshots with stable element refs, and it stops for logins, captchas and native dialogs
10
- instead of guessing. No CSS-selector guessing, no full-DOM dumps.
9
+ accessibility snapshots with stable element refs. Native dialogs are parked and reported instead of
10
+ auto-accepted; a login, 2FA or challenge page is handed back to you rather than guessed at. No
11
+ CSS-selector guessing, no full-DOM dumps.
11
12
 
12
13
  ```sh
13
14
  # from npm
@@ -22,14 +23,16 @@ dsh plugin --profile <name> add github:miqian-nomad/dsh-browser-playwright-codex
22
23
  - **登录一次,一直用** — a profile-backed window plus an exported session state, so a closed window or
23
24
  a restarted harness comes back logged in.
24
25
  - **全程看得见,也不抢你的焦点** — a real window you can watch, minimise or take over; the provider
25
- keeps working while it is minimised ([FOCUS-STEALING.md](FOCUS-STEALING.md)).
26
+ keeps working while it is minimised ([FOCUS-STEALING.md](https://github.com/miqian-nomad/dsh-browser-playwright-codex/blob/main/FOCUS-STEALING.md)).
26
27
  - **遇事停下来叫人** — `alert` / `confirm` / `prompt` are parked and reported, answered only by an
27
- explicit `browser_dialog` call; a login, 2FA or captcha is handed back to you
28
- ([DIALOG-POLICY.md](DIALOG-POLICY.md)). A challenge page is recognised from the interceptor's own
29
- response headers first (`cf-mitigated: challenge` and friends), then from its widget, and only then
30
- from wording; if none of those match, a page that stops changing after an action is reported as a
31
- possible wall instead of being retried
32
- ([docs/articles/dialogs-and-captcha.md](docs/articles/dialogs-and-captcha.md)).
28
+ explicit `browser_dialog` call ([DIALOG-POLICY.md](https://github.com/miqian-nomad/dsh-browser-playwright-codex/blob/main/DIALOG-POLICY.md)).
29
+ A challenge page is recognised from the interceptor's own response headers first
30
+ (`cf-mitigated: challenge` and friends; `x-iinfo` / `x-datadome*` additionally need a blocking
31
+ status), then from the vendor widget's accessible name, then from wording; if none of those match,
32
+ a page that stops changing after an action is reported as a possible wall instead of being retried.
33
+ Recognition adds a note that tells the agent to stop and hand the window back to you — that note is
34
+ advice to the model, not a hard block; the mechanism-level guarantee exists for native dialogs only
35
+ ([docs/articles/dialogs-and-captcha.md](https://github.com/miqian-nomad/dsh-browser-playwright-codex/blob/main/docs/articles/dialogs-and-captcha.md)).
33
36
  - **ref 失效会明说** — per-document nonce refs; a stale ref fails fast with an explanation instead of
34
37
  silently clicking a different element.
35
38
  - **强力工具默认关闭** — `browser_evaluate` / `browser_cdp` are **not registered at all** unless your
@@ -37,9 +40,11 @@ dsh plugin --profile <name> add github:miqian-nomad/dsh-browser-playwright-codex
37
40
 
38
41
  **安全一览 / Security at a glance** — dsh.so's scanner flags this repository's **one Critical finding**, and that finding **is** the
39
42
  implementation of `browser_evaluate` (its dynamic evaluation), which this package ships switched off.
40
- [SECURITY.md](SECURITY.md) maps every finding to the gate that contains it, and explains what the plugin
41
- can and cannot do with your logins (URL policy, CDP allow-list, dialog policy, no telemetry, and it never
42
- types passwords or 2FA codes). Counts move as the scanner and the code change — their own report is the
43
+ [SECURITY.md](https://github.com/miqian-nomad/dsh-browser-playwright-codex/blob/main/SECURITY.md) says what each
44
+ finding actually is, whether a capability gate makes it reachable (two of the warnings are not gate-controlled:
45
+ the login-state write and the local test server in the acceptance script), and explains what the plugin can and
46
+ cannot do with your logins (URL policy, CDP allow-list, dialog policy, no telemetry, and it never types
47
+ passwords or 2FA codes). Counts move as the scanner and the code change — their own report is the
43
48
  live number.
44
49
 
45
50
  ## Install
@@ -59,9 +64,15 @@ dsh plugin --profile <name> add github:miqian-nomad/dsh-browser-playwright-codex
59
64
  dsh plugin --profile <name> add link:<absolute path to this directory>
60
65
  ```
61
66
 
62
- **Compatibility.** Built and tested against DSH `0.2.0-rc.1` (`@deepseek-ai/*` peer ranges are deliberately
63
- `*` so a newer harness still installs). After a harness upgrade, `npm run doctor` re-checks that every named
64
- export this plugin imports still exists — that is the fast way to tell "the harness moved" from "the plugin broke".
67
+ **Compatibility.** Built and tested against DSH `0.2.0-rc.1`. The dependency ranges are **floors, not
68
+ wildcards**: peers `@deepseek-ai/cordis` `>=4.0.4`, `@deepseek-ai/dsh-llm` `>=0.2.0-rc.1`,
69
+ `@deepseek-ai/dsh-tools` `>=0.2.0-rc.1`, and the direct dependency `@deepseek-ai/schemastery` `^3.18.4`. The
70
+ floors exist so an empty project cannot resolve a very old `0.0.1-rc.*` build, while a newer harness still
71
+ installs. One cost of npm's prerelease rules: a prerelease whose `[major, minor, patch]` tuple differs from
72
+ `0.2.0` (for example `0.2.1-rc.*`) is outside a `>=0.2.0-rc.1` range, so you may see a **peer warning** — a
73
+ warning, not an install failure (pnpm does not enforce peer ranges strictly by default). After a harness
74
+ upgrade, `npm run doctor` re-checks that every named export this plugin imports still exists — that is the
75
+ fast way to tell "the harness moved" from "the plugin broke".
65
76
 
66
77
  `lib/` ships in the repository (and there is deliberately no `prepare` hook), so a fresh clone loads
67
78
  without a build step — `npm run build` is only needed after editing `src/`.
@@ -80,8 +91,9 @@ Install it and ask for something that leaves a visible result — there is nothi
80
91
 
81
92
  What you should see: a real browser window on your desktop doing the clicking, and the page coming back to
82
93
  the model as a `ref`-numbered list. The first time it meets a sign-in, a 2FA prompt or a verification page
83
- it **stops and tells you** — take the window over by hand and it continues from where it left off. The
84
- logins already in that profile are what make the rest work.
94
+ it **reports the page back to you** — take the window over by hand and it continues from where it left off.
95
+ Native dialogs are the one case that is enforced at the interface: they are parked and never auto-accepted.
96
+ The logins already in that profile are what make the rest work.
85
97
 
86
98
  Two habits worth having on day one:
87
99
 
@@ -104,7 +116,7 @@ Or pin a binary: `launch.executablePath` / `launch.channel` in the plugin config
104
116
 
105
117
  ## Tools
106
118
 
107
- The default `toolPrefix` is `browser_`. Every action returns a fresh snapshot, so refs always come from the latest result. The registered set is capability-gated: `browser_evaluate`, `browser_cdp` and `browser_extract` appear only when their capability is switched on (their schemas are resident in the system prompt on every turn, so a tool that could only answer "disabled" would be pure prompt cost). Count the live surface with `npm run cost` — the shipped `cordis.patch.yml` keeps **both power gates off** (since 0.4.7), so a profile mounting this bundle sees **18 tools**; 19 or 20 once your deployment opts into `evaluate` / `cdp`, 21 once `extract` is configured. How to opt in: [SECURITY.md](SECURITY.md) §1.
119
+ The default `toolPrefix` is `browser_`. Every action returns a fresh snapshot, so refs always come from the latest result. The registered set is capability-gated: `browser_evaluate`, `browser_cdp` and `browser_extract` appear only when their capability is switched on (their schemas are resident in the system prompt on every turn, so a tool that could only answer "disabled" would be pure prompt cost). Count the live surface with `npm run cost` — the shipped `cordis.patch.yml` keeps **both power gates off** (since 0.4.7), so a profile mounting this bundle sees **18 tools**; 19 or 20 once your deployment opts into `evaluate` / `cdp`, 21 once `extract` is configured. How to opt in: paste the snippet under Known Limitations below, or use the settings card in `dsh-browser-toggle`.
108
120
 
109
121
  | Tool | Purpose |
110
122
  |---|---|
@@ -118,10 +130,10 @@ The default `toolPrefix` is `browser_`. Every action returns a fresh snapshot, s
118
130
  | `browser_wait` | Wait a bounded duration for lazy content |
119
131
  | `browser_tabs` / `browser_open_tab` / `browser_switch_tab` / `browser_close_tab` | Tab management; `browser_tabs` marks the `[active]` tab the other tools act on |
120
132
  | `browser_screenshot` | PNG capture stored as a durable image attachment the model can view |
121
- | `browser_extract` | Structured extraction: natural-language instruction → one parsed JSON value | **Only registered when enabled.**
122
- | `browser_evaluate` | Run one JavaScript expression in the page (disabled by default) | **Only registered when enabled.**
133
+ | `browser_extract` | Structured extraction: natural-language instruction → one parsed JSON value; **only registered when its capability is enabled** |
134
+ | `browser_evaluate` | Run one JavaScript expression in the page (disabled by default); **only registered when its capability is enabled** |
123
135
  | `browser_dialog` | Answer the native dialog blocking the page: `{ accept: true }` confirms, `false` cancels; errors with `NO_DIALOG` when nothing is pending |
124
- | `browser_cdp` | Send one raw CDP command (DOM inspection / Input simulation allow-list only; disabled by default) | **Only registered when enabled.**
136
+ | `browser_cdp` | Send one raw CDP command (DOM inspection / Input simulation allow-list only; disabled by default); **only registered when its capability is enabled** |
125
137
  | `browser_close` | Close the session's browser window; the profile keeps login state, so the next call reopens signed in |
126
138
  | `browser_console_messages` | Read the page console messages captured for the current page (levels, text, source) |
127
139
  | `browser_network_requests` | Read the network requests captured for the current page (method, URL, status, resource type) |
@@ -129,7 +141,8 @@ The default `toolPrefix` is `browser_`. Every action returns a fresh snapshot, s
129
141
  ### Parameter signatures
130
142
 
131
143
  Every registered tool, with the parameters its schema actually declares (`*` = required, `?` = optional).
132
- Generated from the live tool surface and pinned by `tests/tool-schema.test.ts`, so it cannot drift from the code.
144
+ Hand-maintained and pinned by `tests/tool-schema.test.ts` in both directions: a missing tool, a renamed
145
+ parameter or a wrong required flag fails the test, so it cannot drift from the code.
133
146
 
134
147
  - `browser_back` — _none_
135
148
  - `browser_cdp` — `method`*, `params`? _(gated: registered only when enabled)_
@@ -154,7 +167,7 @@ Generated from the live tool surface and pinned by `tests/tool-schema.test.ts`,
154
167
  - `browser_wait` — `ms`*
155
168
 
156
169
  > **使用铁律**: 状态检查 / 无效不盲试 / 页面内容不可信 / 登录红线 / 风控分类等 Codex 文档纪律,
157
- > 见 [`codex-rules.md`](codex-rules.md)(A 层已内嵌进各工具 description; B 层供粘贴进 DSH profile / 系统提示)。
170
+ > 见 [`codex-rules.md`](https://github.com/miqian-nomad/dsh-browser-playwright-codex/blob/main/codex-rules.md)(A 层已内嵌进各工具 description; B 层供粘贴进 DSH profile / 系统提示)。
158
171
 
159
172
  ### Snapshot example
160
173
 
@@ -172,6 +185,25 @@ Refs: 12
172
185
 
173
186
  The model then calls `browser_click` with `ref: e2`. Invisible content is excluded, node and text caps bound the token cost, and truncation is flagged in the result.
174
187
 
188
+ #### Result notes
189
+
190
+ A result can open with one or more notes before the tree. Each says what the plugin saw; none of them
191
+ claims to *prevent* anything:
192
+
193
+ - `[human verification]` — a challenge page was recognised; the line above the note names the tier
194
+ (response header / widget / wording) and the evidence, and the note tells the agent to stop and ask
195
+ you to finish it in the open window instead of retrying.
196
+ - `[http NNN]` — the main document answered a non-2xx status and no challenge was identified, so the
197
+ tree may be an error page or an interception page rather than the content that was asked for.
198
+ - `[nothing here]` — the page has no clickable elements and almost no text: a gate or visitor check, a
199
+ page that is still loading, or one that needs a person. The note says to take one more snapshot,
200
+ then ask you rather than re-sending the same navigation.
201
+ - `[no visible change]` — the action left the page identical (after one action on a nearly empty page,
202
+ or after two identical results on a page with content). It is worded as a guess on purpose: it is
203
+ what an unrecognised wall looks like.
204
+ - A landing note (which element received the click), a parked-dialog note, and an engine-fallback note
205
+ can also appear.
206
+
175
207
  An actionable element with no text of its own is named from its descendants — `<a href="/set"><span>搜索设置</span></a>` renders as `link "搜索设置" [ref=e7]`, and `<a href="/b"><img alt="图标链接"></a>` as `link "图标链接" [ref=e8]` — so a visible label and the ref that carries it always appear on the same line. A non-actionable child that merely repeats an ancestor name is folded away, so each label appears once, on the line that carries the ref; container roles are never named from their content (a `<select>` shows its chosen option, the way a text input shows its value).
176
208
 
177
209
  ## Configuration
@@ -194,9 +226,10 @@ All tunables are patchable through the profile's `cordis.patch.yml` (later layer
194
226
  navigationTimeoutMs: 30000
195
227
  ignoreHTTPSErrors: false
196
228
  allowedDomains: [] # e.g. ['github.com'] restricts navigation
197
- idleTimeoutMs: 600000 # close idle windows after 10 min; 0 = never
229
+ idleTimeoutMs: 0 # shipped value: never auto-close (schema default 600000 = 10 min)
198
230
  maxSessions: 8 # LRU eviction (persistent mode: one window)
199
231
  snapshot:
232
+ engine: legacy # 'legacy' (default) or 'aria' (Playwright ariaSnapshot, refs aligned back)
200
233
  maxNodes: 500 # token budget per snapshot tree
201
234
  maxNameLength: 120
202
235
  maxTextLength: 300
@@ -217,7 +250,12 @@ All tunables are patchable through the profile's `cordis.patch.yml` (later layer
217
250
  maxOutputTokens: 2000
218
251
  ```
219
252
 
220
- `browser_extract` needs an auxiliary LLM route (`extract.provider` + `extract.model`); without one it fails with an actionable error. With a route configured, `extract.maxInputChars` bounds the page text in that prompt, and the prompt names the cap when it truncates. A gated tool is **not registered at all** while its capability is off, so its schema never reaches the system prompt; set `registerDisabledTools: true` to keep the whole surface discoverable (the tool then answers with the error naming the switch). The shipped layer in [`cordis.patch.yml`](cordis.patch.yml) keeps `allowEvaluate` and `allowCdp` **off**, matching the schema defaults and this sample: that file travels inside the package, so anything it enabled would be enabled for everyone who installs it. Opt in per deployment, in that deployment's own patch layer. `browser_evaluate` executes arbitrary page JavaScript; `browser_cdp` stays off until `allowCdp: true`, and even then only the allow-list in `lib/cdp-policy.js` passes — network, storage, cookie, security and arbitrary-JS (`Runtime.evaluate`) commands are rejected with `CDP_DENIED`.
253
+ `snapshot.engine` picks the page-reading engine: `'legacy'` (the default) walks the injected DOM,
254
+ `'aria'` uses Playwright's official `ariaSnapshot({ mode: 'ai' })` and aligns the result back onto the
255
+ same `ref` ids; an aria tree that cannot be parsed falls back to legacy rather than handing the model an
256
+ empty tree. It is a `snapshot` key, next to `maxNodes` / `maxNameLength` / `maxTextLength` / `diff`.
257
+
258
+ `browser_extract` needs an auxiliary LLM route (`extract.provider` + `extract.model`); without one it fails with an actionable error. With a route configured, `extract.maxInputChars` bounds the page text in that prompt, and the prompt names the cap when it truncates. A gated tool is **not registered at all** while its capability is off, so its schema never reaches the system prompt; set `registerDisabledTools: true` to keep the whole surface discoverable (the tool then answers with the error naming the switch). The shipped layer in [`cordis.patch.yml`](https://github.com/miqian-nomad/dsh-browser-playwright-codex/blob/main/cordis.patch.yml) keeps `allowEvaluate` and `allowCdp` **off**, matching the schema defaults and this sample: that file travels inside the package, so anything it enabled would be enabled for everyone who installs it. Opt in per deployment, in that deployment's own patch layer. `browser_evaluate` executes arbitrary page JavaScript; `browser_cdp` stays off until `allowCdp: true`, and even then only the allow-list in `lib/cdp-policy.js` passes — network, storage, cookie, security and arbitrary-JS (`Runtime.evaluate`) commands are rejected with `CDP_DENIED`.
221
259
 
222
260
  ### Two levers that shrink what tools *return*
223
261
 
@@ -277,6 +315,8 @@ npm install
277
315
  npm test # full suite: engine/action integration on real Chrome, policy, rendering,
278
316
  # schema assembly, plus the realistic scenario suites below
279
317
  npm run verify # naming/folding + click/hover/dialog acceptance; self-contained, prints its own count
318
+ # (57 in a checkout; from an npm install it prints 56 + "1 skipped", because the
319
+ # `lib/`-vs-`src/` check has no `src/` to compare against)
280
320
  npm run verify:live # same, plus a live sample of a real page (needs network)
281
321
  npm run doctor # does the installed harness still export everything this plugin imports?
282
322
  npm run cost # resident prompt cost of the tool schemas, per surface
@@ -288,6 +328,10 @@ but `lib/` and the harness: `node scripts/dsh-browser-verify.mjs` (the acceptanc
288
328
  `node scripts/doctor.mjs` (re-checks the harness exports after an upgrade). The rest are repository-only
289
329
  and run from a checkout — `measure-prompt-cost.mjs` reads `src/`, so it cannot work from a tarball.
290
330
 
331
+ One package-manager note: `package.json` declares `packageManager: pnpm@10.31.0`, while the commands above
332
+ (and CI) use **npm** with the committed `package-lock.json` (`npm ci`). Pick one and stay in it — mixing the
333
+ two in a checkout gives you two lockfiles and an ambiguous install.
334
+
291
335
  ### Test suites
292
336
 
293
337
  | Suite | Command | What it covers |
@@ -295,15 +339,15 @@ and run from a checkout — `measure-prompt-cost.mjs` reads `src/`, so it cannot
295
339
  | Unit + engine integration | `npm test` (included) | Snapshot rendering, schema assembly, URL policy, provider actions on real Chrome |
296
340
  | Realistic scenarios | `npm run test:scenarios` | Scripted-agent journeys over a realistic local store fixture: guest checkout with coupon and validation, login/session persistence, multi-tab research, lazy content, infinite scroll, modal dialogs, popups, screenshots, policy, owner isolation, idle disposal, snapshot budgets — at both the `BrowserSession` level and the model-facing `ctx.tools.execute` level (with fake attachments/LLM services) |
297
341
  | Regression verify | `npm run verify` | The executable spec of snapshot naming/folding plus ref/text click-hover, the pending-dialog protocol and the tool surface (expected names derived from `contract.ts`) |
298
- | Live smoke | `npm test:live` | Opt-in checks against the real internet (example.com, Wikipedia REST). Skipped unless `DSH_BROWSER_LIVE=1`; not part of CI |
342
+ | Live smoke | `npm run test:live` | Opt-in checks against the real internet (example.com, Wikipedia REST). Skipped unless `DSH_BROWSER_LIVE=1`; not part of CI |
299
343
 
300
344
  The scenario suites are the plugin's most realistic tests: they drive the browser the way an agent does — snapshot, find a `ref`, act, re-snapshot — and therefore double as executable documentation of the model experience.
301
345
 
302
- From a DeepSeek Harness source checkout, load this plugin from TypeScript:
303
-
304
- ```sh
305
- pnpm dsh web --patch ./browser-plugin/dev.cordis.yml
306
- ```
346
+ From a DeepSeek Harness source checkout, the bundle layer to load is this repository's
347
+ [`cordis.patch.yml`](https://github.com/miqian-nomad/dsh-browser-playwright-codex/blob/main/cordis.patch.yml) — the
348
+ same file the package ships. Register the checkout through your profile with
349
+ `dsh plugin --profile <name> add link:<absolute path to this directory>` (above); this repository carries no
350
+ separate dev patch file.
307
351
 
308
352
  ## Model Experience
309
353
 
@@ -323,7 +367,19 @@ Measured with ``npm run cost`` (per-turn, resident in the system prompt):
323
367
  | Shipped bundle layer (this package's `cordis.patch.yml`: both power gates off) | 18 | 12,755 | ≈ 3,543 |
324
368
  | Everything registered (`registerDisabledTools: true`, plus `extract`) | 21 | 15,608 | ≈ 4,336 |
325
369
 
326
- Three things changed here, and only one of them was about size. (1) The click family was rewritten so each rule is stated once instead of three times: -390 characters, with every rule, example and rationale pinned by `tests/tool-descriptions.test.ts`, so a trim that eats one fails the build. (2) A gated tool is registered only while its capability is live — `browser_extract` on this deployment: -456 characters ≈ 127 tokens/turn. (3) Four descriptions were corrected to match the code they describe (dialog blocking, the gated diagnostics `browser_fill` points at, persistent-mode `browser_close`, and the click family’s landing note), which added 211 characters back because the old wording promised behaviour the implementation did not have. Net: 18,065 → 17,886 characters with everything registered; the shipped layer's total is identical to the schema-defaults row above, because it keeps both power gates off — those schemas are not registered at all and cost nothing. What is left is per-tool prose rather than repetition, so a further cut trades away real guidance.
370
+ This paragraph describes the 0.6.0 change; the table above is today's measurement. Three things changed
371
+ there, and only one of them was about size. (1) The click family was rewritten so each rule is stated once
372
+ instead of three times: -390 characters, with every rule, example and rationale pinned by
373
+ `tests/tool-descriptions.test.ts`, so a trim that eats one fails the build. (2) A gated tool is registered only
374
+ while its capability is live: `browser_extract`'s schema alone is about 456 characters (≈ 127 tokens/turn),
375
+ which a deployment that leaves `extract` unconfigured does not pay — a separate comparison, so it is not part
376
+ of the net below. (3) Four descriptions were corrected to match the code they describe (dialog blocking, the
377
+ gated diagnostics `browser_fill` points at, persistent-mode `browser_close`, and the click family’s landing
378
+ note), which added 211 characters back because the old wording promised behaviour the implementation did not
379
+ have. Net for that release: 18,065 → 17,886 characters with everything registered. The shipped layer's total
380
+ is identical to the schema-defaults row above, because it keeps both power gates off — those schemas are not
381
+ registered at all and cost nothing. What is left is per-tool prose rather than repetition, so a further cut
382
+ trades away real guidance.
327
383
 
328
384
  #### KV Cache effect
329
385
 
@@ -335,15 +391,18 @@ The page state itself (DOM, cookies, storage) lives in the browser context and n
335
391
 
336
392
  ## Further reading
337
393
 
338
- Written for people deciding whether to hand an agent their browser:
394
+ Written for people deciding whether to hand an agent their browser. These live in the GitHub repository
395
+ (`docs/articles/`); the npm tarball ships only `README.md`, `CHANGELOG.md`, `SECURITY.md` and `NOTICE.md`, so the
396
+ links below go to GitHub rather than to files inside the package:
339
397
 
340
- - [让 AI 用你已经登录的账号干活](docs/articles/login-state.md) — why session cookies are the hard part, and
398
+ - [让 AI 用你已经登录的账号干活](https://github.com/miqian-nomad/dsh-browser-playwright-codex/blob/main/docs/articles/login-state.md) — why session cookies are the hard part, and
341
399
  what the export does to a minimised window.
342
- - [AI 开着浏览器抢你的焦点:三个根因](docs/articles/focus-stealing.md) — all three traced into source, with a
343
- 60-line reproduction script that needs no part of this plugin.
344
- - [遇到验证码和弹窗,为什么它不该替你点「确定」](docs/articles/dialogs-and-captcha.md) — mechanism-level
345
- dialog parking, and the same philosophy behind stale refs failing loudly.
346
- - [官方 Discussions 发帖草稿](docs/articles/discussions-post.md) — a compatibility report in the format the
400
+ - [AI 开着浏览器抢你的焦点:三个根因](https://github.com/miqian-nomad/dsh-browser-playwright-codex/blob/main/docs/articles/focus-stealing.md) — all three traced into source, with a
401
+ 113-line reproduction script that needs no part of this plugin.
402
+ - [遇到验证码和弹窗,为什么它不该替你点「确定」](https://github.com/miqian-nomad/dsh-browser-playwright-codex/blob/main/docs/articles/dialogs-and-captcha.md) — mechanism-level
403
+ dialog parking, what the challenge detector does and does not guarantee, and the same philosophy behind
404
+ stale refs failing loudly.
405
+ - [官方 Discussions 发帖草稿](https://github.com/miqian-nomad/dsh-browser-playwright-codex/blob/main/docs/articles/discussions-post.md) — a compatibility report in the format the
347
406
  community asks for, ready to paste.
348
407
 
349
408
  ## Known Limitations and Deferred Work
@@ -366,9 +425,9 @@ Written for people deciding whether to hand an agent their browser:
366
425
  ```
367
426
  - **Chromium family only** — Firefox/WebKit channels are not probed; providers are swappable if another engine is needed.
368
427
  - **Extract needs a dedicated model route** — it does not reuse the main request's route; misconfiguration fails loudly at call time.
369
- - **JS-driven navigations are not statically checkable** — `allowedDomains` covers `browser_navigate`, `browser_open_tab`, every click path (`browser_click`: ref, `precise`, raw-coordinate and `mode:"hover"` modes) and `browser_switch_tab`, but a button whose handler runs `location.href = …` can still leave the allowed hosts, and a tab that is already sitting on a disallowed host stays open (the agent simply refuses to drive it); deploy an external network guard for hard isolation.
428
+ - **JS-driven navigations are not statically checkable** — `allowedDomains` covers `browser_navigate`, `browser_open_tab`, every navigating click (`browser_click`: `ref`, `text`, `precise` and raw-coordinate modes) and `browser_switch_tab`; `mode: "hover"` reveals content without activating anything, so it is not checked against the URL policy. A button whose handler runs `location.href = …` can still leave the allowed hosts, and so can a `<form>` whose `action` points at another host (pressing its submit button is a navigation this check does not statically see), and a tab that is already sitting on a disallowed host stays open (the agent simply refuses to drive it); deploy an external network guard for hard isolation.
370
429
  - **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.
371
- - **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.
430
+ - **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.
372
431
 
373
432
  ### Ref and lifecycle safety (fixed)
374
433
 
@@ -388,7 +447,7 @@ MIT
388
447
  <!-- changelog:today -->
389
448
  ## 更新记录
390
449
 
391
- (此列表只记 **minor 里程碑**;每个补丁版本见 [CHANGELOG.md](CHANGELOG.md)。)
450
+ (此列表只记 **minor 里程碑**;每个补丁版本见 [CHANGELOG.md](https://github.com/miqian-nomad/dsh-browser-playwright-codex/blob/main/CHANGELOG.md)。)
392
451
 
393
452
  ### 0.6.0(2026-10-16)
394
453
 
@@ -419,7 +478,7 @@ MIT
419
478
 
420
479
  ### 0.3.0(2026-09-26)
421
480
 
422
- - **源码化**:插件重新变成有源码的工程 —— `src/` 10 个 TypeScript 模块是唯一真源,`lib/` 由 `npm run build` 生成(10 个 .js + 10 个 .d.ts),不再手改产物。
481
+ - **源码化**:插件重新变成有源码的工程 —— `src/` 10 个 TypeScript 模块(当时;现在 `src/` 是 32 个 `.ts`,`lib/` 是 32 个 `.js` + 32 个 `.d.ts`)是唯一真源,`lib/` 由 `npm run build` 生成,不再手改产物。
423
482
  - **代码规范**:引入 Prettier 配置(`.prettierrc.json`),`npm run format` / `npm run format:check` 统一格式;严格类型检查 0 错误。
424
483
  - **新增两只诊断工具**:`browser_console_messages`(控制台报错)与 `browser_network_requests`(网络请求),用于"点了没反应"时快速定位原因。
425
484
  - **接口与实现同步**:`BrowserSession` 补齐 9 个实现里已有的方法;`errors.ts` 补齐 12 个实际在用的错误码;`BrowserSnapshot` 补 `dialogNote` / `landingNote`;`TabInfo` 补 `active`;`runtime-state` / `cdp-policy` 新增类型声明。
package/SECURITY.md CHANGED
@@ -6,7 +6,7 @@ the thing worth being careful about. This file answers the two questions a reade
6
6
  1. **What can it do to me?**
7
7
  2. **Why does dsh.so's scanner report a Critical finding?**
8
8
 
9
- If you only read one line: **the two findings are the implementation of `browser_evaluate`, a tool
9
+ If you only read one line: **the Critical finding is the implementation of `browser_evaluate`, a tool
10
10
  this package ships switched off** — it is not registered at all until a deployment turns it on.
11
11
 
12
12
  ---
@@ -20,13 +20,13 @@ the agent is supposed to stop and hand the page back to you.
20
20
 
21
21
  **It is visible and interruptible.** The window lives on your desktop. You can watch every action,
22
22
  minimise it (the provider detects a minimised window and keeps working without raising it — see
23
- [FOCUS-STEALING.md](FOCUS-STEALING.md)), or close it: login state is exported first and restored on
23
+ [FOCUS-STEALING.md](https://github.com/miqian-nomad/dsh-browser-playwright-codex/blob/main/FOCUS-STEALING.md)), or close it: login state is exported first and restored on
24
24
  the next launch.
25
25
 
26
26
  **Native dialogs are never auto-accepted.** `alert` / `confirm` / `prompt` / `beforeunload` are
27
27
  parked as a pending state, reported to the model, and answered only by an explicit `browser_dialog`
28
28
  call — a click that raises `confirm()` returns the parked dialog instead of accepting it. See
29
- [DIALOG-POLICY.md](DIALOG-POLICY.md).
29
+ [DIALOG-POLICY.md](https://github.com/miqian-nomad/dsh-browser-playwright-codex/blob/main/DIALOG-POLICY.md).
30
30
 
31
31
  **Which hosts it may touch is policy, not goodwill.** `allowedDomains` is enforced on every path that
32
32
  can navigate: `browser_navigate`, `browser_open_tab`, every click variant (ref, text, geometric,
@@ -55,7 +55,8 @@ While a gate is off the tool is **not registered at all**, so it does not exist
55
55
  costs no prompt tokens. But this file travels inside the package, so if it enabled them, every
56
56
  installer would get a backdoor they never asked for; the opt-in belongs to the deployment, and it
57
57
  lives in that deployment's own patch layer (the bundled Settings card writes it, or edit your
58
- profile patch — see the configuration section of the [README](README.md)).
58
+ profile patch — see the configuration section of the
59
+ [README](https://github.com/miqian-nomad/dsh-browser-playwright-codex/blob/main/README.md#configuration)).
59
60
 
60
61
  **No telemetry.** The plugin does not phone home and does not upload page content anywhere. The only
61
62
  outbound traffic is what the page itself requests — plus one exception you control: `browser_extract`
@@ -71,8 +72,10 @@ Nothing else.
71
72
  ## 2. What the scanner reports, and which gate contains it
72
73
 
73
74
  dsh.so runs `dsh-plugin-vet` over the whole repository. Their scan of **0.6.0 (2026-10-16)** reports
74
- **1 Critical, 3 Warning, 25 Info** across 80 files — and it is **right about the capability**. Here is
75
- each one, with the gate that decides whether it is reachable at all.
75
+ **1 Critical, 3 Warning, 25 Info** across 80 files — and it is **right about the capability**. Below is what
76
+ each reported item actually is, and what decides whether it is reachable. Not every item sits behind a
77
+ capability gate: the login-state write happens whenever login persistence works, and the acceptance script's
78
+ local server exists only while that script runs.
76
79
 
77
80
  > For the record: their v0.4.9 scan — before this plugin was split into responsibility-scoped modules —
78
81
  > reported 2 Critical, 5 Warning, 26 Info across 57 files. Counts move as the scanner and the code
@@ -85,6 +88,9 @@ each one, with the gate that decides whether it is reachable at all.
85
88
  | Warning | HTTP request to a bare IP (`scripts/dsh-browser-verify.mjs`) | The acceptance suite starts a **local** `http.server` on `127.0.0.1` with a fixture page, so `npm run verify` needs no DSH and no internet. | Only when you run that script. |
86
89
  | Info ×25 | `localStorage` reads, `process.env` reads, hardcoded `127.0.0.1`, dev-only probe scripts | Restoring login state; test hooks (`DSH_BROWSER_STATE_FILE`, `PLAYWRIGHT_DOWNLOAD_HOST`); and scripts under `scripts/analysis/` that the scanner itself marks *"Dev-only file — not executed at install/runtime"*. | — |
87
90
 
91
+ The scanner counts the two `fs.writeFileSync` call sites (the login-state export and the small runtime-state
92
+ file) as **two separate Warning findings**; they share one row above because they are one persistence path.
93
+
88
94
  Two honest notes:
89
95
 
90
96
  - These numbers **will not go to zero**, because `browser_evaluate` exists in the code whether or not
@@ -92,22 +98,23 @@ Two honest notes:
92
98
  - We did **not** try to hide the finding (moving the call, obfuscating it, or suppressing the rule).
93
99
  That would be deceiving the tool that is trying to protect users, and it would make this file a lie.
94
100
  - Module names, not line numbers: this file used to cite a line number in `src/playwright.ts`, which the
95
- 0.5.x split made meaningless (the code lives in `src/session.ts` now, and that file is a 55-line
96
- entry point). File names survive refactors; line numbers do not.
101
+ 0.5.x split made meaningless (the code lives in `src/session.ts` now, and its entry point
102
+ `src/playwright.ts` is 56 lines). File names survive refactors; line numbers do not.
97
103
 
98
104
  ## 3. How to check all of this yourself
99
105
 
100
106
  ```sh
101
- npm test # 100+ unit, scenario and guard tests: contracts, tool schemas, scenarios,
107
+ npm test # 219 tests: contracts, tool schemas, scenarios,
102
108
  # aria engine, CDP policy, diff, click/tab allow-lists, snapshot rendering,
103
109
  # prompt budget, docs-vs-reality consistency
104
- npm run verify # 57 self-contained acceptance checks: no DSH, no network, local fixture server
110
+ npm run verify # 57 self-contained acceptance checks in a checkout (56 checks + 1 skipped
111
+ # from an npm install, where there is no src/): no DSH, no network, local fixture server
105
112
  npm run doctor # the host packages this plugin imports are still exporting what it uses
106
113
  npm run cost # resident prompt cost of the registered tool surface
107
114
  ```
108
115
 
109
116
  - CI runs the whole suite on **ubuntu-latest and windows-latest** for every push
110
- ([workflow](.github/workflows/ci.yml)).
117
+ ([workflow](https://github.com/miqian-nomad/dsh-browser-playwright-codex/blob/main/.github/workflows/ci.yml)).
111
118
  - The gates really are off in the package:
112
119
  `git show v0.4.7:cordis.patch.yml` (0.4.7 is where that changed).
113
120
  - Sandbox evidence, produced by someone else: [dsh.so's L4/L5 records](https://www.dsh.so/zh/artifact/dsh-browser-playwright-codex/)
@@ -47,6 +47,15 @@ export interface ChallengeSignature {
47
47
  readonly headers?: readonly HeaderRule[];
48
48
  /** Case-insensitive header-name prefixes (e.g. DataDome's `x-datadome*`). */
49
49
  readonly headerPrefixes?: readonly string[];
50
+ /**
51
+ * Status codes this signature must be corroborated by. Needed for vendors whose
52
+ * header is *not* challenge-specific: DataDome adds `x-datadome*` to every
53
+ * response (their docs say so, and they return `NA` when detection is off), and
54
+ * Imperva adds `x-iinfo` to everything it fronts. Measured 2026-10-17: a normal
55
+ * DataDome-fronted page answered 200 with `x-datadome: protected`, which an
56
+ * ungated rule would have reported as a wall.
57
+ */
58
+ readonly statuses?: readonly number[];
50
59
  /**
51
60
  * Accessible-name tokens only this vendor's widget carries. On a non-iframe
52
61
  * node a token must ALSO be corroborated by {@link WIDGET_CORROBORATION}.
@@ -53,16 +53,19 @@ export const CHALLENGE_SIGNATURES = Object.freeze([
53
53
  {
54
54
  id: 'imperva-incapsula',
55
55
  engine: 'Imperva',
56
- hint: 'Imperva (Incapsula) served this response from its edge, which is where its bot challenges live',
57
- // Presence only: X-IInfo is an opaque per-response trace that Imperva adds
58
- // to everything it fronts, so its value carries no comparable state.
56
+ hint: 'Imperva (Incapsula) blocked this request from its edge',
57
+ // Presence only: X-IInfo is an opaque per-response trace that Imperva adds to
58
+ // everything it fronts, so the header alone says nothing about a block — the
59
+ // status code has to agree.
59
60
  headers: [{ name: 'x-iinfo' }],
61
+ statuses: [403, 429, 503],
60
62
  },
61
63
  {
62
64
  id: 'datadome',
63
65
  engine: 'DataDome',
64
- hint: 'DataDome attached its own metadata to this response',
66
+ hint: 'DataDome blocked this request',
65
67
  headerPrefixes: ['x-datadome'],
68
+ statuses: [403, 429, 503],
66
69
  },
67
70
  {
68
71
  id: 'recaptcha',
@@ -58,3 +58,23 @@ export declare function detectNoProgress(input: {
58
58
  readonly unchangedStreak: number;
59
59
  readonly refs: number;
60
60
  }): string | null;
61
+ /**
62
+ * Appended when a page has nothing to click and almost nothing to read. This is
63
+ * the blind spot the no-progress backstop cannot cover: that tier needs an action
64
+ * to compare before and after, and on a page with zero actionable elements the
65
+ * agent has no action to take — so the emptiest, most wall-like pages used to
66
+ * produce no message at all. Measured 2026-10-17 on 微博's visitor gate: the model
67
+ * received 364 characters, all of them "URL / Title / Refs: 0 / (no visible
68
+ * elements)", and nothing said the page was a gate rather than an empty document.
69
+ */
70
+ export declare const SILENT_PAGE_NOTE = "[nothing here] this page has no clickable elements and almost no text: it may be a gate or visitor check that resolves by itself, a page that is still loading, or a page that needs a person. Wait a moment and take one more browser_snapshot; if it still looks like this, tell the user what you were trying to open and ask them to look at the open window, instead of re-sending the same navigation.";
71
+ /**
72
+ * Whether the page is a silent one: nothing to click and (almost) nothing to read.
73
+ * @param input - the action ref count and the accessible tree.
74
+ * @returns the advisory note, or null when the page has content worth reading.
75
+ */
76
+ export declare function detectSilentPage(input: {
77
+ readonly refs: number;
78
+ readonly nodes?: readonly ChallengeNode[] | undefined;
79
+ readonly title?: string | undefined;
80
+ }): string | null;
package/lib/challenge.js CHANGED
@@ -1,10 +1,27 @@
1
- import { CAPTCHA_ROLES, CAPTCHA_WORD, CHALLENGE_SIGNATURES, WIDGET_CORROBORATION, WORDING_COMMAND, WORDING_EXCLUDE, WORDING_TITLE, } from "./challenge-signatures.js";
1
+ import { CAPTCHA_ROLES, CAPTCHA_WORD, CHALLENGE_SIGNATURES, WORDING_COMMAND, WORDING_EXCLUDE, WORDING_TITLE, } from "./challenge-signatures.js";
2
2
  /** How many accessible names the wording tiers may look at. */
3
3
  const MAX_NAMES = 60;
4
- /** Cap on the joined text handed to the wording tiers. */
5
- const MAX_TEXT = 900;
4
+ /**
5
+ * The widget tier reads deeper than the wording tiers. Measured 2026-10-17 on
6
+ * nopecha.com/demo/hcaptcha: a real hCaptcha iframe named
7
+ * "包含 hCaptcha 安全挑战复选框的小部件" sits below depth 2, so a three-level scan
8
+ * never saw it and the real widget was reported as NO MATCH. Vendor widgets are
9
+ * usually mounted inside a wrapper stack; control labels are not.
10
+ */
11
+ const WIDGET_DEPTH = 8;
12
+ /** Cap on names the widget tier walks (deeper scan, so a larger budget). */
13
+ const MAX_WIDGET_NAMES = 150;
6
14
  /** A control label is short; a sentence that merely mentions captchas is not. */
7
15
  const MAX_CONTROL_LABEL = 32;
16
+ /**
17
+ * A title that mentions verification only counts on a page with (almost) nothing
18
+ * to click. Measured on real pages 2026-10-17: 百度's actual 安全验证 page has
19
+ * **0** actionable elements, while a Baidu *search result* page titled
20
+ * "安全验证_百度搜索" has 57 and "滑块验证_百度搜索" has 81. Without this gate the
21
+ * title tier fires on any search for those words — which is exactly the query a
22
+ * person investigating captchas types.
23
+ */
24
+ const TITLE_WORDING_MAX_REFS = 5;
8
25
  /** Cap on names folded into one page-state fingerprint. */
9
26
  const MAX_FINGERPRINT_NAMES = 200;
10
27
  /**
@@ -70,11 +87,25 @@ export function detectChallenge(snapshot) {
70
87
  const url = snapshot.url ?? '';
71
88
  const names = [];
72
89
  scan(snapshot.nodes, 0, 2, names, MAX_NAMES);
90
+ // The widget tier gets its own, deeper scan (see WIDGET_DEPTH).
91
+ const widgetNames = [];
92
+ scan(snapshot.nodes, 0, WIDGET_DEPTH, widgetNames, MAX_WIDGET_NAMES);
73
93
  const headers = snapshot.response?.headers;
94
+ const status = snapshot.response?.status;
95
+ const refs = snapshot.refs;
96
+ /**
97
+ * Some vendors put their header on every response, so the header alone is not a
98
+ * block: DataDome attaches `x-datadome*` to everything (their docs) and Imperva
99
+ * attaches `x-iinfo` to everything. A signature that lists `statuses` must have
100
+ * the status agree, or a perfectly good page is reported as a wall.
101
+ */
102
+ const statusAgrees = (required) => required === undefined || (status !== undefined && required.includes(status));
74
103
  // Tier 1 — the vendor's own response metadata. This is their published API:
75
104
  // Cloudflare documents cf-mitigated: challenge as the supported way to detect
76
105
  // a challenge page, so a rename breaks every customer integration, not just us.
77
106
  for (const signature of CHALLENGE_SIGNATURES) {
107
+ if (!statusAgrees(signature.statuses))
108
+ continue;
78
109
  for (const rule of signature.headers ?? []) {
79
110
  const evidence = headerEvidence(rule, headers);
80
111
  if (evidence !== null) {
@@ -98,18 +129,23 @@ export function detectChallenge(snapshot) {
98
129
  const tokens = signature.widgetTokens;
99
130
  if (tokens === undefined)
100
131
  continue;
101
- for (const node of names) {
132
+ // No ref-count gate here on purpose. An earlier version required the page to be
133
+ // nearly unusable before a vendor iframe counted, which suppressed a genuine
134
+ // reCAPTCHA / hCaptcha / Turnstile simply because the login page around it had
135
+ // links (measured: a busy login page stopped being recognised at all). Login
136
+ // pages are the main case for this check, so the frame wins: a v3 badge on an
137
+ // ordinary page is reported as "a captcha is embedded here", which is true.
138
+ for (const node of widgetNames) {
139
+ // Frames only. The vendor's own widget is a frame in every real sample measured
140
+ // (reCAPTCHA, hCaptcha, Turnstile, Arkose): the deep scan exists to reach it.
141
+ // Accepting a token on an ordinary node was a regression — an article titled
142
+ // "How hCaptcha works" has h2/p nodes with the vendor word in them, and the
143
+ // wording elsewhere on the page ("verification", "challenge") then corroborated
144
+ // it, so a perfectly ordinary blog post rendered as a captcha wall.
145
+ if (node.role !== 'iframe' && node.role !== 'frame')
146
+ continue;
102
147
  if (!tokens.some((token) => token.test(node.name)))
103
148
  continue;
104
- const onFrame = node.role === 'iframe' || node.role === 'frame';
105
- if (!onFrame) {
106
- const others = [title, url, ...names.filter((other) => other !== node).map((other) => other.name)]
107
- .join(' | ')
108
- .slice(0, MAX_TEXT);
109
- const corroborated = WIDGET_CORROBORATION.some((token) => token.test(others));
110
- if (!corroborated)
111
- continue;
112
- }
113
149
  return {
114
150
  engine: signature.engine,
115
151
  hint: signature.hint,
@@ -119,32 +155,41 @@ export function detectChallenge(snapshot) {
119
155
  }
120
156
  }
121
157
  // Tier 3 — wording, weakest of the three. A title is short and written by the
122
- // interceptor, so it stands alone; on a node we require a command form (or a
123
- // captcha control), which is the difference between a challenge and a help
124
- // page about captchas.
158
+ // interceptor, so it stands almost alone: the one extra condition is that the
159
+ // page must also be unusable (see TITLE_WORDING_MAX_REFS). A caller that does
160
+ // not report a ref count is treated as "nothing to click", because the
161
+ // alternative is silently switching this tier off.
162
+ // Fail closed: a caller that does not report a ref count gets no title tier at all.
163
+ // Leaving the gate open when refs was missing is how the false positive came back
164
+ // (the tests themselves passed a BrowserSnapshot, which has totalRefs but no refs).
165
+ const unusablePage = refs !== undefined && refs <= TITLE_WORDING_MAX_REFS;
125
166
  const head = title + ' ' + url;
126
- for (const signature of CHALLENGE_SIGNATURES) {
127
- for (const pattern of signature.titleWording ?? []) {
167
+ if (unusablePage) {
168
+ for (const signature of CHALLENGE_SIGNATURES) {
169
+ for (const pattern of signature.titleWording ?? []) {
170
+ if (!pattern.test(head))
171
+ continue;
172
+ return {
173
+ engine: signature.engine,
174
+ hint: signature.hint,
175
+ tier: 'wording',
176
+ evidence: 'title: ' + title.slice(0, 60),
177
+ };
178
+ }
179
+ }
180
+ for (const pattern of WORDING_TITLE) {
128
181
  if (!pattern.test(head))
129
182
  continue;
130
183
  return {
131
- engine: signature.engine,
132
- hint: signature.hint,
184
+ engine: 'human-verification',
185
+ hint: 'the page is asking to verify a human visitor',
133
186
  tier: 'wording',
134
187
  evidence: 'title: ' + title.slice(0, 60),
135
188
  };
136
189
  }
137
190
  }
138
- for (const pattern of WORDING_TITLE) {
139
- if (!pattern.test(head))
140
- continue;
141
- return {
142
- engine: 'human-verification',
143
- hint: 'the page is asking to verify a human visitor',
144
- tier: 'wording',
145
- evidence: 'title: ' + title.slice(0, 60),
146
- };
147
- }
191
+ // Control-level wording is not gated the same way: it needs a short label on a
192
+ // control role, which an ordinary results page does not carry.
148
193
  for (const node of names) {
149
194
  if (node.name.length > MAX_CONTROL_LABEL)
150
195
  continue;
@@ -223,3 +268,34 @@ export function detectNoProgress(input) {
223
268
  return null;
224
269
  return NO_PROGRESS_NOTE;
225
270
  }
271
+ /**
272
+ * Appended when a page has nothing to click and almost nothing to read. This is
273
+ * the blind spot the no-progress backstop cannot cover: that tier needs an action
274
+ * to compare before and after, and on a page with zero actionable elements the
275
+ * agent has no action to take — so the emptiest, most wall-like pages used to
276
+ * produce no message at all. Measured 2026-10-17 on 微博's visitor gate: the model
277
+ * received 364 characters, all of them "URL / Title / Refs: 0 / (no visible
278
+ * elements)", and nothing said the page was a gate rather than an empty document.
279
+ */
280
+ export const SILENT_PAGE_NOTE = '[nothing here] this page has no clickable elements and almost no text: it may be a gate or visitor check that resolves by itself, a page that is still loading, or a page that needs a person. Wait a moment and take one more browser_snapshot; if it still looks like this, tell the user what you were trying to open and ask them to look at the open window, instead of re-sending the same navigation.';
281
+ /** A page with no named node at all counts as "almost no text". Strict on purpose:
282
+ * one heading plus one paragraph is a normal page, not a silent one. */
283
+ const SILENT_PAGE_MAX_NAMES = 0;
284
+ /**
285
+ * Whether the page is a silent one: nothing to click and (almost) nothing to read.
286
+ * @param input - the action ref count and the accessible tree.
287
+ * @returns the advisory note, or null when the page has content worth reading.
288
+ */
289
+ export function detectSilentPage(input) {
290
+ if (input.refs > 0)
291
+ return null;
292
+ // A page with no title at all is a bare document (about:blank, a raw image):
293
+ // calling that "maybe a gate" is noise, not a warning.
294
+ if (input.title !== undefined && input.title.trim() === '')
295
+ return null;
296
+ const names = [];
297
+ // Read as deep as the widget scan: a page whose only text sits six levels down is
298
+ // a normal page, and a depth-4 scan called it "almost no text" (review, P1-3).
299
+ scan(input.nodes, 0, WIDGET_DEPTH, names, SILENT_PAGE_MAX_NAMES + 1);
300
+ return names.length <= SILENT_PAGE_MAX_NAMES ? SILENT_PAGE_NOTE : null;
301
+ }
@@ -50,6 +50,15 @@ export declare function resolveClickPoint(session: PlaywrightSession, locator: L
50
50
  * elements shared the label.
51
51
  */
52
52
  export declare function clickText(session: PlaywrightSession, text: string, signal: AbortSignal | undefined): Promise<import("./types.ts").BrowserSnapshot>;
53
+ /**
54
+ * The href a click on this element would navigate to, for the URL policy.
55
+ * Its own href is not enough: a button or span nested inside an off-domain
56
+ * `<a href>` has no href itself yet the click bubbles into that link and
57
+ * navigates. The raw-coordinate path already checks the closest ancestor
58
+ * link; ref and text clicks must apply the same rule (B2 in the audit).
59
+ * Returns null when no link is involved, which the policy lets through.
60
+ */
61
+ export declare function clickTargetHref(locator: Locator): Promise<string | null>;
53
62
  /**
54
63
  * Shared click core: enforce the URL policy for link targets, run the
55
64
  * Codex-style geometry ladder with the Playwright actionability fallback,
@@ -60,6 +69,17 @@ export declare function clickLocator(session: PlaywrightSession, locator: Locato
60
69
  export declare function clickAt(session: PlaywrightSession, x: number, y: number, signal: AbortSignal | undefined): Promise<import("./types.ts").BrowserSnapshot>;
61
70
  export declare function clickAtRef(session: PlaywrightSession, ref: string, signal: AbortSignal | undefined): Promise<import("./types.ts").BrowserSnapshot>;
62
71
  export declare function fill(session: PlaywrightSession, ref: string, text: string, signal: AbortSignal | undefined): Promise<import("./types.ts").BrowserSnapshot>;
72
+ /**
73
+ * Normalize a field value before comparing. Two levels of forgiveness, both of them
74
+ * things a page does to a value *after* we wrote it, not differences in what we wrote:
75
+ * whitespace is collapsed, and everything that is not a letter or a digit is dropped
76
+ * (`1,000` ≡ `1000`) with case folded (`abc` ≡ `ABC`). That covers input masks and
77
+ * currency fields — which `browser_fill`'s own description names — while still failing
78
+ * a genuinely different value: `ABCD`→`ZZZZ`, `John`→`Jane` and `1234`→`12345` all
79
+ * differ once normalized. The old readback accepted a length tolerance and containment,
80
+ * which is how those used to report success (audit B4).
81
+ */
82
+ export declare function normalizeFillText(value: string): string;
63
83
  /** Read the field back and decide whether the fill landed. */
64
84
  export declare function verifyFill(_session: PlaywrightSession, locator: Locator, tag: string, text: string): Promise<boolean>;
65
85
  export declare function press(session: PlaywrightSession, ref: string, key: string, signal: AbortSignal | undefined): Promise<import("./types.ts").BrowserSnapshot>;
@@ -3,7 +3,7 @@
3
3
  * 滚动、前进后退、标签切换,以及承载它们的可中断等待管道(withAbort)。
4
4
  * @module dsh-browser-playwright-codex/page-actions
5
5
  */
6
- import { hitTest } from "./page-probes.js";
6
+ import { clickTargetHrefProbe, hitTest } from "./page-probes.js";
7
7
  import { BrowserError } from "./errors.js";
8
8
  import { assertAllowedUrl } from "./url-policy.js";
9
9
  import { asTimeoutError, isAbortError } from "./page-diagnostics.js";
@@ -257,6 +257,17 @@ export async function clickText(session, text, signal) {
257
257
  }
258
258
  });
259
259
  }
260
+ /**
261
+ * The href a click on this element would navigate to, for the URL policy.
262
+ * Its own href is not enough: a button or span nested inside an off-domain
263
+ * `<a href>` has no href itself yet the click bubbles into that link and
264
+ * navigates. The raw-coordinate path already checks the closest ancestor
265
+ * link; ref and text clicks must apply the same rule (B2 in the audit).
266
+ * Returns null when no link is involved, which the policy lets through.
267
+ */
268
+ export async function clickTargetHref(locator) {
269
+ return (await locator.evaluate(clickTargetHrefProbe)) ?? null;
270
+ }
260
271
  /**
261
272
  * Shared click core: enforce the URL policy for link targets, run the
262
273
  * Codex-style geometry ladder with the Playwright actionability fallback,
@@ -264,7 +275,7 @@ export async function clickText(session, text, signal) {
264
275
  * in the landing note; shared is how many elements matched it (0 for refs).
265
276
  */
266
277
  export async function clickLocator(session, locator, signal, label, shared) {
267
- session.assertClickTargetAllowed(await locator.getAttribute('href'));
278
+ session.assertClickTargetAllowed(await clickTargetHref(locator));
268
279
  const page = session.currentPageSync();
269
280
  const urlBefore = page.url();
270
281
  let point;
@@ -396,8 +407,9 @@ export async function clickAtRef(session, ref, signal) {
396
407
  const page = session.currentPageSync();
397
408
  const urlBefore = page.url();
398
409
  // Ref mode resolves the element before clicking, so the link policy can
399
- // be enforced up front — the same rule browser_click applies.
400
- session.assertClickTargetAllowed(await locator.getAttribute('href'));
410
+ // be enforced up front — the same rule browser_click applies, including the
411
+ // ancestor link a nested control would navigate through.
412
+ session.assertClickTargetAllowed(await clickTargetHref(locator));
401
413
  try {
402
414
  // Codex-style geometry click with obstruction awareness:
403
415
  // scroll (centre → end → start), wait for the bounding rect to
@@ -492,9 +504,9 @@ export async function fill(session, ref, text, signal) {
492
504
  lastError = error;
493
505
  continue;
494
506
  }
495
- // Post-action verification: read the value back. Lenient match
496
- // so formatted/masked inputs do not false-positive, but a value
497
- // that never stuck gets one retry then a clear diagnostic.
507
+ // Post-action verification: read the value back and require it to match
508
+ // what was typed (whitespace-normalized only), so a value the page ignored
509
+ // or rewrote gets one retry then a clear diagnostic.
498
510
  written = await session.verifyFill(locator, tag, text);
499
511
  if (written)
500
512
  break;
@@ -507,24 +519,44 @@ export async function fill(session, ref, text, signal) {
507
519
  return session.settleAndSnapshot(page, false);
508
520
  });
509
521
  }
522
+ /**
523
+ * Normalize a field value before comparing. Two levels of forgiveness, both of them
524
+ * things a page does to a value *after* we wrote it, not differences in what we wrote:
525
+ * whitespace is collapsed, and everything that is not a letter or a digit is dropped
526
+ * (`1,000` ≡ `1000`) with case folded (`abc` ≡ `ABC`). That covers input masks and
527
+ * currency fields — which `browser_fill`'s own description names — while still failing
528
+ * a genuinely different value: `ABCD`→`ZZZZ`, `John`→`Jane` and `1234`→`12345` all
529
+ * differ once normalized. The old readback accepted a length tolerance and containment,
530
+ * which is how those used to report success (audit B4).
531
+ */
532
+ export function normalizeFillText(value) {
533
+ return String(value)
534
+ .normalize('NFKC')
535
+ .toLowerCase()
536
+ .replace(/[^\p{L}\p{N}]+/gu, '');
537
+ }
510
538
  /** Read the field back and decide whether the fill landed. */
511
539
  export async function verifyFill(_session, locator, tag, text) {
540
+ const expected = normalizeFillText(text);
512
541
  try {
513
542
  if (tag === 'select') {
514
543
  const label = await locator.evaluate((el) => {
515
544
  const o = el.selectedOptions[0];
516
545
  return o === undefined ? '' : o.label;
517
546
  });
518
- return label === text;
519
- }
520
- const value = await locator.inputValue();
521
- if (value === text)
522
- return true;
523
- // Lenient: formatted inputs (masks, prefixes) rewrite the display
524
- // value; accept containment or a near-equal length as "written".
525
- return (text.length > 0 &&
526
- value.length > 0 &&
527
- (value.includes(text) || text.includes(value) || Math.abs(value.length - text.length) <= 2));
547
+ return normalizeFillText(label) === expected;
548
+ }
549
+ if (tag === 'input' || tag === 'textarea') {
550
+ return normalizeFillText(await locator.inputValue()) === expected;
551
+ }
552
+ // contenteditable / role=textbox elements have no inputValue(); calling it
553
+ // throws, which used to turn a fill that HAD landed into VALUE_NOT_SET.
554
+ // Judge the text the page would show instead.
555
+ const contents = await locator.evaluate((el) => {
556
+ const node = el;
557
+ return [el.textContent ?? '', typeof node.innerText === 'string' ? node.innerText : ''];
558
+ });
559
+ return contents.some((candidate) => normalizeFillText(candidate) === expected);
528
560
  }
529
561
  catch {
530
562
  return false;
@@ -62,4 +62,9 @@ export declare function networkRequests(session: PlaywrightSession, args: {
62
62
  failure: string | null | undefined;
63
63
  }[];
64
64
  };
65
+ /**
66
+ * Bounded page data for browser_extract. Credential values (password fields and
67
+ * password-autocomplete fields) are blanked inside the page before the payload
68
+ * leaves the browser: the extract prompt is sent to an auxiliary model.
69
+ */
65
70
  export declare function pageData(session: PlaywrightSession): Promise<unknown>;
@@ -173,6 +173,11 @@ export function networkRequests(session, args) {
173
173
  })),
174
174
  };
175
175
  }
176
+ /**
177
+ * Bounded page data for browser_extract. Credential values (password fields and
178
+ * password-autocomplete fields) are blanked inside the page before the payload
179
+ * leaves the browser: the extract prompt is sent to an auxiliary model.
180
+ */
176
181
  export async function pageData(session) {
177
182
  return session.run(async () => {
178
183
  session.assertLive();
@@ -182,7 +187,38 @@ export async function pageData(session) {
182
187
  const fn = window.__dshPageData;
183
188
  if (typeof fn !== 'function')
184
189
  return { url: location.href, title: document.title, text: '', truncated: false, links: [], inputs: [] };
185
- return fn({ maxTextChars: 30000, maxLinks: 300, maxInputs: 200 });
190
+ const data = fn({ maxTextChars: 30000, maxLinks: 300, maxInputs: 200 });
191
+ // Credentials never leave the page. __dshPageData reads every field's current
192
+ // value, and browser_extract pastes that object into an auxiliary model's
193
+ // prompt, so a login or change-password page put the plaintext password into
194
+ // that prompt (audit B3). The field itself stays — "there is a password box
195
+ // here" is useful page structure — but its value is blanked. The collector and
196
+ // this pass use the same selector over the same document, so the i-th entry is
197
+ // the i-th field.
198
+ if (data !== null && Array.isArray(data.inputs)) {
199
+ const fields = document.querySelectorAll('input, select, textarea');
200
+ for (let index = 0; index < data.inputs.length; index += 1) {
201
+ const entry = data.inputs[index];
202
+ const field = fields[index];
203
+ if (entry === undefined || field === undefined)
204
+ continue;
205
+ const type = (field.getAttribute('type') ?? '').toLowerCase();
206
+ const autocomplete = (field.getAttribute('autocomplete') ?? '').toLowerCase();
207
+ if (type === 'password' ||
208
+ autocomplete.includes('current-password') ||
209
+ autocomplete.includes('new-password') ||
210
+ // One-time codes and card numbers are just as sensitive as passwords: a
211
+ // 2FA code in the prompt can be replayed, and a card number is payment
212
+ // data (independent review, P2-7). The field stays, its value does not.
213
+ autocomplete.includes('one-time-code') ||
214
+ autocomplete.includes('cc-number') ||
215
+ autocomplete.includes('cc-csc') ||
216
+ autocomplete.includes('cc-exp')) {
217
+ entry['value'] = '';
218
+ }
219
+ }
220
+ }
221
+ return data;
186
222
  };
187
223
  let data;
188
224
  try {
@@ -55,6 +55,18 @@ export declare const pageStabilityProbe: (arg: {
55
55
  frames: number;
56
56
  timeoutMs: number;
57
57
  }) => Promise<boolean>;
58
+ /**
59
+ * The href a click on this element would follow: the element's own href, or the
60
+ * nearest ancestor `<a href>` — crossing shadow-root boundaries, the same way the
61
+ * hit-test walks. Raw-coordinate clicks already check `closest('a[href]')`; ref and
62
+ * text clicks used to look only at the element itself, so a button/span nested in an
63
+ * off-domain `<a>` reached the outside host while every check passed. This probe is
64
+ * that missing layer: locator.evaluate(clickTargetHrefProbe).
65
+ *
66
+ * A policy check, not a navigation: `javascript:` hrefs are returned as-is and the
67
+ * caller's session policy keeps its existing exemption for them.
68
+ */
69
+ export declare const clickTargetHrefProbe: (el: Element) => string | null;
58
70
  /**
59
71
  * The click-point hit test, moved out of the session so it lives with the other page-side
60
72
  * functions: it is serialized into the page by locator.evaluate(), so the same
@@ -201,6 +201,39 @@ export const pageStabilityProbe = (arg) => new Promise((resolve) => {
201
201
  ];
202
202
  requestAnimationFrame(tick);
203
203
  });
204
+ /**
205
+ * The href a click on this element would follow: the element's own href, or the
206
+ * nearest ancestor `<a href>` — crossing shadow-root boundaries, the same way the
207
+ * hit-test walks. Raw-coordinate clicks already check `closest('a[href]')`; ref and
208
+ * text clicks used to look only at the element itself, so a button/span nested in an
209
+ * off-domain `<a>` reached the outside host while every check passed. This probe is
210
+ * that missing layer: locator.evaluate(clickTargetHrefProbe).
211
+ *
212
+ * A policy check, not a navigation: `javascript:` hrefs are returned as-is and the
213
+ * caller's session policy keeps its existing exemption for them.
214
+ */
215
+ export const clickTargetHrefProbe = (el) => {
216
+ const own = el.getAttribute('href');
217
+ if (own !== null && own !== '')
218
+ return own;
219
+ let node = el;
220
+ // Document order upward, hopping from a shadow root to its host when the
221
+ // element lives inside one. The seen guard makes a malformed cycle terminate.
222
+ const seen = [];
223
+ while (node !== null) {
224
+ if (seen.indexOf(node) !== -1)
225
+ break;
226
+ seen.push(node);
227
+ const anchor = node.closest('a[href]');
228
+ if (anchor !== null)
229
+ return anchor.getAttribute('href');
230
+ const root = node.getRootNode
231
+ ? node.getRootNode()
232
+ : null;
233
+ node = root !== null && root !== undefined && root.host !== undefined ? root.host : null;
234
+ }
235
+ return null;
236
+ };
204
237
  export const hitTest = (el) => {
205
238
  const r = el.getBoundingClientRect();
206
239
  const w = r.width;
@@ -110,9 +110,9 @@ export async function captureAria(session, page, opts) {
110
110
  const legacy = await session.captureLegacy(page, opts);
111
111
  return {
112
112
  ...legacy,
113
- engineNote: 'smart mode could not read session page, so it was rendered with the compatible engine. ' +
113
+ engineNote: 'smart mode could not read this page, so it was rendered with the compatible engine. ' +
114
114
  'The refs below belong to the compatible engine. Nothing is wrong with the page; ' +
115
- 'switch the page-reading mode in Settings if you want to stop seeing session.',
115
+ 'switch the page-reading mode in Settings if you would rather not see this fallback notice.',
116
116
  };
117
117
  }
118
118
  return {
@@ -1,4 +1,4 @@
1
- import { CHALLENGE_NOTE, detectChallenge } from "./challenge.js";
1
+ import { CHALLENGE_NOTE, detectChallenge, detectSilentPage } from "./challenge.js";
2
2
  import { renderSnapshot, renderSnapshotDiff } from "./snapshot-render.js";
3
3
  /**
4
4
  * Render one diagnostics payload as one line per entry. Diagnostics are read when
@@ -57,9 +57,32 @@ export function renderDiagnostics(kind, value) {
57
57
  }
58
58
  /** Project a provider snapshot into its canonical tool value. */
59
59
  export function snapshotValue(snapshot) {
60
- const challenge = detectChallenge(snapshot);
60
+ // The ref count travels with the snapshot on purpose: the wording tier needs it
61
+ // to tell a real wall (nothing to click) from a results page whose title merely
62
+ // contains the phrase. Measured on 百度: 0 refs on the challenge, 57/81 on the
63
+ // search pages titled "安全验证_百度搜索" / "滑块验证_百度搜索".
64
+ const challenge = detectChallenge({
65
+ url: snapshot.url,
66
+ title: snapshot.title,
67
+ nodes: snapshot.nodes,
68
+ refs: snapshot.totalRefs,
69
+ ...(snapshot.response !== undefined ? { response: snapshot.response } : {}),
70
+ });
61
71
  const tree = renderSnapshot(snapshot);
62
72
  const notes = [];
73
+ // The HTTP status was captured for challenge detection but never shown, so a
74
+ // 404/500 error page reached the model looking like an ordinary page — measured
75
+ // 2026-10-17 on a real MDN 404: status 404, 35 refs, and nothing in the result
76
+ // said so. Suppressed when a challenge was identified, because that note
77
+ // already names the reason.
78
+ const status = snapshot.response?.status;
79
+ if (challenge === null && status !== undefined && (status < 200 || status >= 300)) {
80
+ notes.push('[http ' +
81
+ String(status) +
82
+ '] the server answered ' +
83
+ String(status) +
84
+ ' for this document, so what follows may be an error page or an interception page rather than the content you asked for.');
85
+ }
63
86
  const dialog = snapshot.dialogNote;
64
87
  if (dialog !== undefined && dialog !== '')
65
88
  notes.push(dialog);
@@ -80,6 +103,18 @@ export function snapshotValue(snapshot) {
80
103
  const stall = snapshot.stallNote;
81
104
  if (challenge === null && stall !== undefined && stall !== '')
82
105
  notes.push(stall);
106
+ // The backstop above needs an action to compare before and after; a page with
107
+ // nothing to click never gets one, so the emptiest wall-like pages used to say
108
+ // nothing at all. Only on a successful status: a 4xx/5xx already has its own line.
109
+ const healthyStatus = status === undefined || (status >= 200 && status < 300);
110
+ // A parked dialog means the page is fine, it is the dialog that blocks us: the
111
+ // dialog note already says what to do, and adding "nothing here" contradicts it.
112
+ const dialogPending = snapshot.dialogNote !== undefined && snapshot.dialogNote !== '';
113
+ if (challenge === null && healthyStatus && !dialogPending) {
114
+ const silent = detectSilentPage({ refs: snapshot.totalRefs, nodes: snapshot.nodes, title: snapshot.title });
115
+ if (silent !== null)
116
+ notes.push(silent);
117
+ }
83
118
  const head = notes.length > 0 ? notes.join('\n\n') + '\n\n' : '';
84
119
  // Incremental diff rides along when the provider computed one (snapshot.diff).
85
120
  const diffBlock = snapshot.diff !== undefined ? '\n\n' + renderSnapshotDiff(snapshot.diff) : '';
@@ -53,6 +53,12 @@ export interface ChallengeSnapshot {
53
53
  readonly title?: string;
54
54
  readonly url?: string;
55
55
  readonly nodes?: readonly ChallengeNode[];
56
+ /**
57
+ * Actionable-element count. The wording tier uses it to tell a challenge page
58
+ * (nothing to click) from an ordinary page whose *title* merely contains the
59
+ * phrase — searching Baidu for "安全验证" produces exactly that false positive.
60
+ */
61
+ readonly refs?: number;
56
62
  /** Main document response metadata; absent when no navigation response was seen. */
57
63
  readonly response?: DocumentResponse;
58
64
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-browser-playwright-codex",
3
- "version": "0.6.5",
3
+ "version": "0.6.6",
4
4
  "description": "Browser automation for DeepSeek Harness: fill in forms, scrape lists and work across tabs in real pages, using the logins you already have. Runs in a visible window that stays quiet when minimised, and stops for sign-ins, CAPTCHAs and native dialogs instead of confirming for you.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -57,13 +57,13 @@
57
57
  "preflight": "node scripts/preflight.mjs ."
58
58
  },
59
59
  "dependencies": {
60
- "@deepseek-ai/schemastery": "*",
60
+ "@deepseek-ai/schemastery": "^3.18.4",
61
61
  "playwright-core": "~1.62.1"
62
62
  },
63
63
  "peerDependencies": {
64
- "@deepseek-ai/cordis": "*",
65
- "@deepseek-ai/dsh-llm": "*",
66
- "@deepseek-ai/dsh-tools": "*"
64
+ "@deepseek-ai/cordis": ">=4.0.4",
65
+ "@deepseek-ai/dsh-llm": ">=0.2.0-rc.1",
66
+ "@deepseek-ai/dsh-tools": ">=0.2.0-rc.1"
67
67
  },
68
68
  "devDependencies": {
69
69
  "@deepseek-ai/cordis": "4.0.4",
@@ -13,6 +13,8 @@
13
13
  *
14
14
  * 读 lib/,但**必须先 build**:npm run verify 会先跑 npm run build,直接
15
15
  * node scripts/... 跑则会撞上最后一组检查(lib/ 不旧于 src/)。
16
+ * npm 安装形态没有 src/(files 里不含它):最后一组自动**跳过并说明**,不算失败,
17
+ * 所以从 npm 装上之后这条命令照样能跑通 —— README 的「装到即可跑」必须是真的。
16
18
  *
17
19
  * 为什么不像测试那样用 tsx 直读 src/:tsx(esbuild keepNames)会把传给
18
20
  * page.evaluate 的函数包成 __name(...),而页面里没有这个符号 ——
@@ -20,7 +22,7 @@
20
22
  * 失效。lib/ 是编译产物,没有这层包裹,也正好是宿主真正加载的东西。
21
23
  */
22
24
  import http from 'node:http'
23
- import { readdirSync, statSync } from 'node:fs'
25
+ import { existsSync, readdirSync, statSync } from 'node:fs'
24
26
  import os from 'node:os'
25
27
  import path from 'node:path'
26
28
  import { PlaywrightProvider } from '../lib/provider.js'
@@ -32,12 +34,13 @@ import { getEffectiveEngine, setSnapshotEngine } from '../lib/runtime-state.js'
32
34
  // 设置页的「页面识别方式」存在 ~/.dsh/dsh-browser-playwright.state.json,运行时状态层
33
35
  // 允许用 DSH_BROWSER_STATE_FILE 把它指向别处(src/runtime-state.ts 里为测试留的开关)。
34
36
  // 指向一个不存在的文件 = engine 回到配置默认值 legacy,本脚本的期望值就是按它写的。
35
- // 不隔离的话:用户把开关拨到「智能模式」,这份 57 项验收就会假失败(2026-10-01 实测)。
37
+ // 不隔离的话:用户把开关拨到「智能模式」,这份验收就会假失败(2026-10-01 实测)。
36
38
  // 注意 [1]–[7] 因此都跑在兼容模式下;智能模式由 [8] 显式切过去跑(隔离状态文件里切,
37
39
  // 不碰用户的真实选择)。
38
40
  process.env.DSH_BROWSER_STATE_FILE = path.join(os.tmpdir(), 'dsh-browser-verify-state-isolated.json')
39
41
 
40
42
  let pass = 0
43
+ let skipped = 0
41
44
  const failures = []
42
45
  function check(name, ok, detail) {
43
46
  if (ok) {
@@ -455,22 +458,31 @@ await provider.dispose()
455
458
  server.close()
456
459
 
457
460
  // [7] lib/ 是宿主真正加载的产物:源码改完没 build,跑绿了也是白跑。
461
+ // npm 包里没有 src/(package.json 的 files 只收 lib/),安装形态下这一组无从比较:
462
+ // 跳过并说明原因,不记失败、不影响退出码 —— 否则装上 npm 包之后这条命令必崩,
463
+ // 与 README/CHANGELOG 的「装到即可跑」直接矛盾。
458
464
  console.log('[7] lib/ 与 src/ 同步(宿主加载 lib/)')
459
- const stale = []
460
- for (const file of readdirSync(new URL('../src', import.meta.url))) {
461
- if (!file.endsWith('.ts') || file.endsWith('.d.ts')) continue
462
- const src = statSync(new URL('../src/' + file, import.meta.url)).mtimeMs
463
- const built = statSync(new URL('../lib/' + file.replace(/\.ts$/, '.js'), import.meta.url), {
464
- throwIfNoEntry: false,
465
- })
466
- if (built === undefined || built.mtimeMs < src) stale.push(file.replace(/\.ts$/, '.js'))
465
+ if (!existsSync(new URL('../src', import.meta.url))) {
466
+ skipped += 1
467
+ console.log(' (跳过)没有 src/:这是 npm 安装形态')
468
+ } else {
469
+ const stale = []
470
+ for (const file of readdirSync(new URL('../src', import.meta.url))) {
471
+ if (!file.endsWith('.ts') || file.endsWith('.d.ts')) continue
472
+ const src = statSync(new URL('../src/' + file, import.meta.url)).mtimeMs
473
+ const built = statSync(new URL('../lib/' + file.replace(/\.ts$/, '.js'), import.meta.url), {
474
+ throwIfNoEntry: false,
475
+ })
476
+ if (built === undefined || built.mtimeMs < src) stale.push(file.replace(/\.ts$/, '.js'))
477
+ }
478
+ check('lib/ 不旧于 src/(旧了就先 npm run build)', stale.length === 0, stale.join(', '))
467
479
  }
468
- check('lib/ 不旧于 src/(旧了就先 npm run build)', stale.length === 0, stale.join(', '))
469
480
 
470
481
  console.log('')
482
+ const skipNote = skipped > 0 ? ',跳过 ' + skipped + ' 项' : ''
471
483
  console.log(
472
484
  failures.length === 0
473
- ? '全部通过 (' + pass + ' 项)'
485
+ ? '全部通过 (' + pass + ' 项' + skipNote + ')'
474
486
  : '失败 ' + failures.length + ' 项 / 共 ' + (pass + failures.length) + ' 项: ' + failures.join(', '),
475
487
  )
476
488
  process.exit(failures.length === 0 ? 0 : 1)