dsh-browser-playwright-codex 0.6.5 → 0.7.0
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 +49 -0
- package/NOTICE.md +5 -6
- package/README.md +165 -94
- package/SECURITY.md +24 -14
- package/cordis.patch.yml +76 -68
- package/lib/challenge-signatures.d.ts +9 -0
- package/lib/challenge-signatures.js +7 -4
- package/lib/challenge.d.ts +20 -0
- package/lib/challenge.js +106 -30
- package/lib/client.js +582 -0
- package/lib/contract.d.ts +5 -2
- package/lib/contract.js +12 -3
- package/lib/gates-patch.d.ts +46 -0
- package/lib/gates-patch.js +158 -0
- package/lib/index.d.ts +5 -0
- package/lib/index.js +6 -0
- package/lib/page-actions.d.ts +36 -0
- package/lib/page-actions.js +92 -18
- package/lib/page-diagnostics.d.ts +5 -0
- package/lib/page-diagnostics.js +37 -1
- package/lib/page-probes.d.ts +12 -0
- package/lib/page-probes.js +33 -0
- package/lib/page-snapshot.js +2 -2
- package/lib/plugin.d.ts +56 -0
- package/lib/plugin.js +31 -0
- package/lib/settings-card.d.ts +9 -0
- package/lib/settings-card.js +268 -0
- package/lib/snapshot-render.d.ts +12 -0
- package/lib/snapshot-render.js +99 -1
- package/lib/tool-render.js +37 -2
- package/lib/tool-types.d.ts +6 -0
- package/lib/tool.d.ts +6 -0
- package/lib/tool.js +58 -50
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +31 -19
- package/scripts/dsh-browser-verify.mjs +24 -12
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
|
|
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
|
---
|
|
@@ -15,18 +15,21 @@ this package ships switched off** — it is not registered at all until a deploy
|
|
|
15
15
|
|
|
16
16
|
**It drives your browser, as you.** The provider opens a visible, profile-backed Chromium-family
|
|
17
17
|
window (`~/.dsh/browser-profiles/playwright` by default) and reuses the logins already in it. It does
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
window (`~/.dsh/browser-profiles/playwright` by default) and reuses the logins already in it. It does
|
|
19
|
+
not have your passwords, and `browser_fill` **refuses** credential fields (`type=password`,
|
|
20
|
+
`autocomplete=current-password` / `new-password` / `one-time-code` / `cc-*`) — typing one is a
|
|
21
|
+
mechanism-level no, not a promise. Challenge pages are detected and reported as a note; there is
|
|
22
|
+
**no sign-in detector**, so a sign-in page is handled by the agent's discipline and you complete it in
|
|
23
|
+
the visible window.
|
|
21
24
|
**It is visible and interruptible.** The window lives on your desktop. You can watch every action,
|
|
22
25
|
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
|
|
26
|
+
[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
27
|
the next launch.
|
|
25
28
|
|
|
26
29
|
**Native dialogs are never auto-accepted.** `alert` / `confirm` / `prompt` / `beforeunload` are
|
|
27
30
|
parked as a pending state, reported to the model, and answered only by an explicit `browser_dialog`
|
|
28
31
|
call — a click that raises `confirm()` returns the parked dialog instead of accepting it. See
|
|
29
|
-
[DIALOG-POLICY.md](DIALOG-POLICY.md).
|
|
32
|
+
[DIALOG-POLICY.md](https://github.com/miqian-nomad/dsh-browser-playwright-codex/blob/main/DIALOG-POLICY.md).
|
|
30
33
|
|
|
31
34
|
**Which hosts it may touch is policy, not goodwill.** `allowedDomains` is enforced on every path that
|
|
32
35
|
can navigate: `browser_navigate`, `browser_open_tab`, every click variant (ref, text, geometric,
|
|
@@ -55,7 +58,8 @@ While a gate is off the tool is **not registered at all**, so it does not exist
|
|
|
55
58
|
costs no prompt tokens. But this file travels inside the package, so if it enabled them, every
|
|
56
59
|
installer would get a backdoor they never asked for; the opt-in belongs to the deployment, and it
|
|
57
60
|
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
|
|
61
|
+
profile patch — see the configuration section of the
|
|
62
|
+
[README](https://github.com/miqian-nomad/dsh-browser-playwright-codex/blob/main/README.md#configuration)).
|
|
59
63
|
|
|
60
64
|
**No telemetry.** The plugin does not phone home and does not upload page content anywhere. The only
|
|
61
65
|
outbound traffic is what the page itself requests — plus one exception you control: `browser_extract`
|
|
@@ -71,8 +75,10 @@ Nothing else.
|
|
|
71
75
|
## 2. What the scanner reports, and which gate contains it
|
|
72
76
|
|
|
73
77
|
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**.
|
|
75
|
-
each
|
|
78
|
+
**1 Critical, 3 Warning, 25 Info** across 80 files — and it is **right about the capability**. Below is what
|
|
79
|
+
each reported item actually is, and what decides whether it is reachable. Not every item sits behind a
|
|
80
|
+
capability gate: the login-state write happens whenever login persistence works, and the acceptance script's
|
|
81
|
+
local server exists only while that script runs.
|
|
76
82
|
|
|
77
83
|
> For the record: their v0.4.9 scan — before this plugin was split into responsibility-scoped modules —
|
|
78
84
|
> reported 2 Critical, 5 Warning, 26 Info across 57 files. Counts move as the scanner and the code
|
|
@@ -85,6 +91,9 @@ each one, with the gate that decides whether it is reachable at all.
|
|
|
85
91
|
| 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
92
|
| 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
93
|
|
|
94
|
+
The scanner counts the two `fs.writeFileSync` call sites (the login-state export and the small runtime-state
|
|
95
|
+
file) as **two separate Warning findings**; they share one row above because they are one persistence path.
|
|
96
|
+
|
|
88
97
|
Two honest notes:
|
|
89
98
|
|
|
90
99
|
- These numbers **will not go to zero**, because `browser_evaluate` exists in the code whether or not
|
|
@@ -92,22 +101,23 @@ Two honest notes:
|
|
|
92
101
|
- We did **not** try to hide the finding (moving the call, obfuscating it, or suppressing the rule).
|
|
93
102
|
That would be deceiving the tool that is trying to protect users, and it would make this file a lie.
|
|
94
103
|
- 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
|
|
96
|
-
|
|
104
|
+
0.5.x split made meaningless (the code lives in `src/session.ts` now, and its entry point
|
|
105
|
+
`src/playwright.ts` is 56 lines). File names survive refactors; line numbers do not.
|
|
97
106
|
|
|
98
107
|
## 3. How to check all of this yourself
|
|
99
108
|
|
|
100
109
|
```sh
|
|
101
|
-
npm test #
|
|
110
|
+
npm test # 219 tests: contracts, tool schemas, scenarios,
|
|
102
111
|
# aria engine, CDP policy, diff, click/tab allow-lists, snapshot rendering,
|
|
103
112
|
# prompt budget, docs-vs-reality consistency
|
|
104
|
-
npm run verify # 57 self-contained acceptance checks
|
|
113
|
+
npm run verify # 57 self-contained acceptance checks in a checkout (56 checks + 1 skipped
|
|
114
|
+
# from an npm install, where there is no src/): no DSH, no network, local fixture server
|
|
105
115
|
npm run doctor # the host packages this plugin imports are still exporting what it uses
|
|
106
116
|
npm run cost # resident prompt cost of the registered tool surface
|
|
107
117
|
```
|
|
108
118
|
|
|
109
119
|
- CI runs the whole suite on **ubuntu-latest and windows-latest** for every push
|
|
110
|
-
([workflow](.github/workflows/ci.yml)).
|
|
120
|
+
([workflow](https://github.com/miqian-nomad/dsh-browser-playwright-codex/blob/main/.github/workflows/ci.yml)).
|
|
111
121
|
- The gates really are off in the package:
|
|
112
122
|
`git show v0.4.7:cordis.patch.yml` (0.4.7 is where that changed).
|
|
113
123
|
- Sandbox evidence, produced by someone else: [dsh.so's L4/L5 records](https://www.dsh.so/zh/artifact/dsh-browser-playwright-codex/)
|
package/cordis.patch.yml
CHANGED
|
@@ -1,72 +1,80 @@
|
|
|
1
|
-
# dsh-browser-playwright-codex bundle layer
|
|
2
|
-
# provider, and the model-facing tool family. Applied as one insert over the
|
|
3
|
-
# profile's entry list; rows are patchable by id like every other plugin.
|
|
1
|
+
# dsh-browser-playwright-codex bundle layer — ONE row for the whole capability.
|
|
4
2
|
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
# that
|
|
9
|
-
#
|
|
3
|
+
# History worth keeping: this file used to insert three rows (`browser` service,
|
|
4
|
+
# `browser-playwright` provider, `browser-tool` tools) because the capability has
|
|
5
|
+
# three configurable concerns. `dsh-client-modules` rejects distinct active Loader
|
|
6
|
+
# sources that resolve to one package name, so a Settings card (which needs a
|
|
7
|
+
# client module) could not live in such a package — it shipped as a second plugin,
|
|
8
|
+
# dsh-browser-toggle. 2026-10-19: one row, three nested config sections, one plugin.
|
|
9
|
+
#
|
|
10
|
+
# The `name:` value is a package specifier resolved from the profile, so it must
|
|
11
|
+
# track package.json#name — a rename that misses this file fails at load with
|
|
12
|
+
# MODULE_NOT_FOUND (the 0.4.0 rename to *-codex did exactly that).
|
|
10
13
|
- insert:
|
|
11
14
|
- id: browser
|
|
12
|
-
name: 'dsh-browser-playwright-codex
|
|
13
|
-
config:
|
|
14
|
-
provider: playwright
|
|
15
|
-
- id: browser-playwright
|
|
16
|
-
name: 'dsh-browser-playwright-codex/playwright'
|
|
17
|
-
config:
|
|
18
|
-
launch:
|
|
19
|
-
# Visible profile-backed window (persistent mode): the agent drives a
|
|
20
|
-
# browser the user can watch and rescue. Set true for headless
|
|
21
|
-
# servers. profileDir defaults to ~/.dsh/browser-profiles/playwright.
|
|
22
|
-
headless: false
|
|
23
|
-
persistent: true
|
|
24
|
-
viewport:
|
|
25
|
-
width: 1280
|
|
26
|
-
height: 800
|
|
27
|
-
navigationTimeoutMs: 30000
|
|
28
|
-
ignoreHTTPSErrors: false
|
|
29
|
-
# Idle auto-close: ms of no agent activity before the window closes
|
|
30
|
-
# itself (login state is saved first, so it reopens logged-in). 0 =
|
|
31
|
-
# never auto-close; the window stays until the user closes it or DSH
|
|
32
|
-
# exits. User choice: never auto-close (2026-09-04).
|
|
33
|
-
idleTimeoutMs: 0
|
|
34
|
-
maxSessions: 8
|
|
35
|
-
# Response-size lever (off by default): return "+/-/~" ref diffs between
|
|
36
|
-
# snapshots instead of the whole tree. Smaller returns on pages that
|
|
37
|
-
# re-render often; the model then has to hold the previous tree itself.
|
|
38
|
-
snapshot:
|
|
39
|
-
diff: false
|
|
40
|
-
- id: browser-tool
|
|
41
|
-
name: 'dsh-browser-playwright-codex/tool'
|
|
15
|
+
name: 'dsh-browser-playwright-codex'
|
|
42
16
|
config:
|
|
43
|
-
#
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
#
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
#
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
17
|
+
# Browser seam: which registered provider to drive.
|
|
18
|
+
service:
|
|
19
|
+
provider: playwright
|
|
20
|
+
# Provider: window, launch, snapshot and session settings.
|
|
21
|
+
playwright:
|
|
22
|
+
launch:
|
|
23
|
+
# Visible profile-backed window (persistent mode): the agent drives a
|
|
24
|
+
# browser the user can watch and rescue. Set true for headless servers.
|
|
25
|
+
# profileDir defaults to ~/.dsh/browser-profiles/playwright.
|
|
26
|
+
headless: false
|
|
27
|
+
persistent: true
|
|
28
|
+
viewport:
|
|
29
|
+
width: 1280
|
|
30
|
+
height: 800
|
|
31
|
+
navigationTimeoutMs: 30000
|
|
32
|
+
ignoreHTTPSErrors: false
|
|
33
|
+
# Idle auto-close: ms of no agent activity before the window closes
|
|
34
|
+
# itself (login state is saved first, so it reopens logged-in). 0 =
|
|
35
|
+
# never auto-close. User choice: never auto-close (2026-09-04).
|
|
36
|
+
idleTimeoutMs: 0
|
|
37
|
+
maxSessions: 8
|
|
38
|
+
# Response-size lever (off by default): return "+/-/~" ref diffs between
|
|
39
|
+
# snapshots instead of the whole tree.
|
|
40
|
+
snapshot:
|
|
41
|
+
diff: false
|
|
42
|
+
# Model-facing tools: prefix, capability gates and wait caps.
|
|
43
|
+
tool:
|
|
44
|
+
# This file ships INSIDE the package, so it must not turn anything on for
|
|
45
|
+
# the people who install it. It used to: an earlier revision carried the
|
|
46
|
+
# author's own allowEvaluate/allowCdp true (2026-09-04) into every
|
|
47
|
+
# download — a backdoor each installer never asked for, and what a
|
|
48
|
+
# third-party scanner reads as "dynamic code execution, critical".
|
|
49
|
+
#
|
|
50
|
+
# Both are OFF here (matching the code defaults in src/tool.ts). Opt in
|
|
51
|
+
# per deployment, in YOUR profile or home patch — never in the package:
|
|
52
|
+
#
|
|
53
|
+
# - id: browser
|
|
54
|
+
# name: 'dsh-browser-playwright-codex'
|
|
55
|
+
# config:
|
|
56
|
+
# service: { provider: playwright }
|
|
57
|
+
# playwright: { launch: { headless: false, persistent: true }, idleTimeoutMs: 0, maxSessions: 8, snapshot: { diff: false } }
|
|
58
|
+
# tool:
|
|
59
|
+
# toolPrefix: 'browser_'
|
|
60
|
+
# allowEvaluate: true # registers browser_evaluate
|
|
61
|
+
# diagnostics: true # registers browser_console_messages / browser_network_requests
|
|
62
|
+
# allowCdp: true # registers browser_cdp
|
|
63
|
+
# registerDisabledTools: false
|
|
64
|
+
# interactiveOnlyDefault: false # true = snapshot returns actionable elements only
|
|
65
|
+
# maxWaitMs: 60000
|
|
66
|
+
#
|
|
67
|
+
# The loader replaces an entry's config object WHOLESALE, so a partial
|
|
68
|
+
# block resets every key you leave out. List all three sections.
|
|
69
|
+
#
|
|
70
|
+
# browser_evaluate runs arbitrary page JavaScript: it can read anything
|
|
71
|
+
# in the page and act on the user's logged-in sites. browser_cdp is CDP
|
|
72
|
+
# with an allow-list — only inspection and input simulation pass
|
|
73
|
+
# (lib/cdp-policy.js); network/storage/cookie/security commands never do.
|
|
74
|
+
# Neither tool is registered at all while its gate is false.
|
|
75
|
+
allowEvaluate: false
|
|
76
|
+
allowCdp: false
|
|
77
|
+
# Diagnostics tools (console messages, network requests) are OFF here:
|
|
78
|
+
# they debug a failed run; their schemas would otherwise sit in the
|
|
79
|
+
# system prompt every turn. Turn them on per deployment.
|
|
80
|
+
diagnostics: false
|
|
@@ -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)
|
|
57
|
-
// Presence only: X-IInfo is an opaque per-response trace that Imperva adds
|
|
58
|
-
//
|
|
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
|
|
66
|
+
hint: 'DataDome blocked this request',
|
|
65
67
|
headerPrefixes: ['x-datadome'],
|
|
68
|
+
statuses: [403, 429, 503],
|
|
66
69
|
},
|
|
67
70
|
{
|
|
68
71
|
id: 'recaptcha',
|
package/lib/challenge.d.ts
CHANGED
|
@@ -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,
|
|
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
|
-
/**
|
|
5
|
-
|
|
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
|
-
|
|
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
|
|
123
|
-
//
|
|
124
|
-
//
|
|
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
|
-
|
|
127
|
-
for (const
|
|
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:
|
|
132
|
-
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
|
-
|
|
139
|
-
|
|
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
|
+
}
|