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/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
  ---
@@ -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
- not have your passwords, and it never types them: when a login, a 2FA prompt or a captcha appears,
19
- the agent is supposed to stop and hand the page back to you.
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 [README](README.md)).
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**. Here is
75
- each one, with the gate that decides whether it is reachable at all.
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 that file is a 55-line
96
- entry point). File names survive refactors; line numbers do not.
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 # 100+ unit, scenario and guard tests: contracts, tool schemas, scenarios,
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: no DSH, no network, local fixture server
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: the browser seam, the Playwright
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
- # The `name:` values are package specifiers resolved from the profile, so they
6
- # must track package.json#name — a rename that misses this file fails at load
7
- # with MODULE_NOT_FOUND (2026-09-30: the 0.4.0 rename to *-codex did exactly
8
- # that; verified `require.resolve('dsh-browser-playwright/service')` fails in
9
- # both profiles while the *-codex specifier resolves).
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/service'
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
- # This file ships INSIDE the package, so it must not turn anything on for
44
- # the people who install it. It used to: an earlier revision carried the
45
- # author's own `allowEvaluate: true` / `allowCdp: true` (2026-09-04) into
46
- # every download, which is how a package ends up handing each installer a
47
- # backdoor they never asked for — and it is what a third-party scanner
48
- # reads as "dynamic code execution, critical".
49
- #
50
- # Both are OFF here (matching the code defaults in src/tool.ts, and the
51
- # README's own configuration example). Opt in per deployment, in YOUR
52
- # profile patch — never in the package:
53
- #
54
- # # ~/.dsh/profiles/<name>/cordis.patch.yml
55
- # # NOTE: dsh replaces an entry's config object wholesale, so list every
56
- # # key you rely on, not just the one you are changing.
57
- # - id: browser-tool
58
- # config:
59
- # toolPrefix: 'browser_'
60
- # allowEvaluate: true # registers browser_evaluate
61
- # allowCdp: true # registers browser_cdp (needs a DSH restart)
62
- # registerDisabledTools: false
63
- # interactiveOnlyDefault: false # true = snapshot returns actionable elements only
64
- # maxWaitMs: 60000
65
- #
66
- # browser_evaluate runs arbitrary page JavaScript: it can read anything in
67
- # the page and act on the user's logged-in sites. browser_cdp is CDP with
68
- # an allow-list — only inspection and input simulation pass
69
- # (lib/cdp-policy.js); network/storage/cookie/security commands never do.
70
- # Neither tool is registered at all while its gate is false.
71
- allowEvaluate: false
72
- allowCdp: false
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) 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
+ }