dsh-browser-playwright-codex 0.6.1 → 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.
@@ -65,11 +65,29 @@ export function trackPage(session, page) {
65
65
  });
66
66
  page.on('response', (response) => {
67
67
  const slot = session.requestEntries.get(response.request());
68
- if (slot === undefined)
69
- return;
70
- slot.entry.status = response.status();
71
- slot.entry.ms = Date.now() - slot.startedAt;
72
- session.requestEntries.delete(response.request());
68
+ if (slot !== undefined) {
69
+ slot.entry.status = response.status();
70
+ slot.entry.ms = Date.now() - slot.startedAt;
71
+ session.requestEntries.delete(response.request());
72
+ }
73
+ // The main document response carries whatever the interceptor put on the
74
+ // wire. Challenge detection reads this before it reads a single word of the
75
+ // page: `cf-mitigated: challenge` is Cloudflare's published detection
76
+ // contract, and unlike page wording it cannot be reworded by accident.
77
+ try {
78
+ if (!response.request().isNavigationRequest())
79
+ return;
80
+ if (response.frame() !== page.mainFrame())
81
+ return;
82
+ session.docResponses.set(page, {
83
+ status: response.status(),
84
+ url: response.url(),
85
+ headers: { ...response.headers() },
86
+ });
87
+ }
88
+ catch {
89
+ // A response that detached mid-flight must never break capture.
90
+ }
73
91
  });
74
92
  page.on('requestfailed', (request) => {
75
93
  const slot = session.requestEntries.get(request);
@@ -155,6 +173,11 @@ export function networkRequests(session, args) {
155
173
  })),
156
174
  };
157
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
+ */
158
181
  export async function pageData(session) {
159
182
  return session.run(async () => {
160
183
  session.assertLive();
@@ -164,7 +187,38 @@ export async function pageData(session) {
164
187
  const fn = window.__dshPageData;
165
188
  if (typeof fn !== 'function')
166
189
  return { url: location.href, title: document.title, text: '', truncated: false, links: [], inputs: [] };
167
- 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;
168
222
  };
169
223
  let data;
170
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;
@@ -80,17 +80,23 @@ export declare function captureLegacy(session: PlaywrightSession, page: Page, op
80
80
  * data-dsh-ref, so legacy and aria engines share the same contract.
81
81
  */
82
82
  export declare function attachDiff(session: PlaywrightSession, snap: BrowserSnapshot): BrowserSnapshot;
83
+ /**
84
+ * Attach the main document response metadata observed for this page, when there
85
+ * is one. Challenge detection reads it before it reads a word of the page: a
86
+ * response header is the interceptor's own published API, unlike its prose.
87
+ */
88
+ export declare function attachDocResponse(session: PlaywrightSession, page: Page, snap: BrowserSnapshot): BrowserSnapshot;
89
+ /**
90
+ * The no-progress backstop — the tier that needs no vendor signature at all. It
91
+ * compares this capture's page-state fingerprint with the previous one and, for a
92
+ * post-action capture, appends an advisory note when nothing moved. The note says
93
+ * it is a guess on purpose: covering walls we have no signature for is the point.
94
+ */
95
+ export declare function attachNoProgress(session: PlaywrightSession, snap: BrowserSnapshot, afterAction: boolean): BrowserSnapshot;
83
96
  /** Build ref -> feature signature map from a captured tree, with parent refs. */
84
97
  export declare function buildRefSignatures(_session: PlaywrightSession, nodes: readonly BrowserNode[]): Map<string, RefSignature>;
85
98
  export declare function snapshot(session: PlaywrightSession, opts?: {
86
99
  interactiveOnly?: boolean;
87
- }): Promise<BrowserSnapshot | {
88
- url: string;
89
- title: string;
90
- nodes: never[];
91
- totalRefs: number;
92
- truncated: boolean;
93
- dialogNote: string;
94
- }>;
100
+ }): Promise<BrowserSnapshot>;
95
101
  export declare function refLocator(session: PlaywrightSession, ref: string): Promise<import("playwright-core").Locator>;
96
102
  export declare function assertLive(session: PlaywrightSession): void;
@@ -1,4 +1,5 @@
1
1
  import { captureAriaSnapshot } from "./snapshot-aria.js";
2
+ import { detectNoProgress, snapshotFingerprint } from "./challenge.js";
2
3
  import { isContextDestroyed } from "./page-diagnostics.js";
3
4
  import { REF_PATTERN } from "./config.js";
4
5
  import { BrowserError } from "./errors.js";
@@ -72,7 +73,7 @@ export async function settleAndSnapshot(session, page, navChanged) {
72
73
  await page.waitForLoadState('load', { timeout: session.timeoutMs() }).catch(() => { });
73
74
  }
74
75
  await session.waitForPageStable(page, navChanged ? 1500 : 800);
75
- return await session.capture(page, undefined);
76
+ return await session.capture(page, undefined, true);
76
77
  });
77
78
  if (raced.blocked)
78
79
  return session.blockedSnapshot(page, raced.pending);
@@ -109,9 +110,9 @@ export async function captureAria(session, page, opts) {
109
110
  const legacy = await session.captureLegacy(page, opts);
110
111
  return {
111
112
  ...legacy,
112
- 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. ' +
113
114
  'The refs below belong to the compatible engine. Nothing is wrong with the page; ' +
114
- '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.',
115
116
  };
116
117
  }
117
118
  return {
@@ -206,6 +207,52 @@ export function attachDiff(session, snap) {
206
207
  }
207
208
  return { ...snap, diff: { added, removed, changed, same, navigationReset: false } };
208
209
  }
210
+ /**
211
+ * Attach the main document response metadata observed for this page, when there
212
+ * is one. Challenge detection reads it before it reads a word of the page: a
213
+ * response header is the interceptor's own published API, unlike its prose.
214
+ */
215
+ export function attachDocResponse(session, page, snap) {
216
+ const response = session.docResponses.get(page);
217
+ if (response === undefined)
218
+ return snap;
219
+ // Only the response that produced the document we are actually looking at
220
+ // counts. A single-page app changes the URL with no document load, and reusing
221
+ // the previous document's headers there would report a wall on a page that is
222
+ // fine. Falling back to the lower tiers is the fail-safe direction: they still
223
+ // run, and so does the no-progress backstop.
224
+ if (withoutHash(response.url) !== withoutHash(snap.url))
225
+ return snap;
226
+ return { ...snap, response };
227
+ }
228
+ /** URL without its fragment, for comparing a page against the response it came from. */
229
+ function withoutHash(url) {
230
+ const hash = url.indexOf('#');
231
+ return hash === -1 ? url : url.slice(0, hash);
232
+ }
233
+ /**
234
+ * The no-progress backstop — the tier that needs no vendor signature at all. It
235
+ * compares this capture's page-state fingerprint with the previous one and, for a
236
+ * post-action capture, appends an advisory note when nothing moved. The note says
237
+ * it is a guess on purpose: covering walls we have no signature for is the point.
238
+ */
239
+ export function attachNoProgress(session, snap, afterAction) {
240
+ const fingerprint = snapshotFingerprint({
241
+ url: snap.url,
242
+ title: snap.title,
243
+ refs: snap.totalRefs,
244
+ nodes: snap.nodes,
245
+ });
246
+ const identical = session.lastFingerprint !== undefined && session.lastFingerprint === fingerprint;
247
+ session.unchangedStreak = identical ? session.unchangedStreak + 1 : 0;
248
+ session.lastFingerprint = fingerprint;
249
+ const note = detectNoProgress({
250
+ afterAction,
251
+ unchangedStreak: session.unchangedStreak,
252
+ refs: snap.totalRefs,
253
+ });
254
+ return note === null ? snap : { ...snap, stallNote: note };
255
+ }
209
256
  /** Build ref -> feature signature map from a captured tree, with parent refs. */
210
257
  export function buildRefSignatures(_session, nodes) {
211
258
  const out = new Map();
package/lib/session.d.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import type { BrowserContext, CDPSession, Locator, Page, Request } from 'playwright-core';
7
7
  import type { PlaywrightProvider } from './provider.ts';
8
- import type { BrowserNode, BrowserSnapshot, DiagnosticsEntry, LoadState } from './types.ts';
8
+ import type { BrowserNode, BrowserSnapshot, DiagnosticsEntry, DocumentResponse, LoadState } from './types.ts';
9
9
  import type { RefSignature } from './page-snapshot.ts';
10
10
  import { type CookieRow } from './login-sites.ts';
11
11
  import type { ActionResult, PendingDialogRecord } from './provider.ts';
@@ -44,6 +44,15 @@ export declare class PlaywrightSession {
44
44
  entry: DiagnosticsEntry;
45
45
  startedAt: number;
46
46
  }>;
47
+ /**
48
+ * Main document response per page. Challenge detection reads this before it
49
+ * reads a word of the page: a response header is the interceptor's own API.
50
+ */
51
+ docResponses: WeakMap<Page, DocumentResponse>;
52
+ /** Fingerprint of the previous capture's page state (no-progress backstop). */
53
+ lastFingerprint: string | undefined;
54
+ /** Consecutive captures that produced an identical page-state fingerprint. */
55
+ unchangedStreak: number;
47
56
  /** Ring capacity per stream. */
48
57
  diagLimit: number;
49
58
  /** ref -> feature signature of the previous capture (diff baseline). */
@@ -87,14 +96,7 @@ export declare class PlaywrightSession {
87
96
  * cached data (page.url() needs no round trip) — page.evaluate() and
88
97
  * page.title() would both stall behind the dialog.
89
98
  */
90
- blockedSnapshot(page: Page, pending: PendingDialogRecord | undefined): {
91
- url: string;
92
- title: string;
93
- nodes: never[];
94
- totalRefs: number;
95
- truncated: boolean;
96
- dialogNote: string;
97
- };
99
+ blockedSnapshot(page: Page, pending: PendingDialogRecord | undefined): BrowserSnapshot;
98
100
  /**
99
101
  * Run one page action that may raise a native dialog. Such an action never
100
102
  * resolves while the dialog is up, so race it against the dialog appearing
@@ -109,7 +111,7 @@ export declare class PlaywrightSession {
109
111
  /** Read the snapshot tree from one page (must run inside a run()). */
110
112
  capture(page: Page, opts: {
111
113
  interactiveOnly?: boolean;
112
- } | undefined): Promise<BrowserSnapshot>;
114
+ } | undefined, afterAction?: boolean): Promise<BrowserSnapshot>;
113
115
  /**
114
116
  * Semantic-tree capture via Playwright's official ariaSnapshot(mode:'ai').
115
117
  * An unavailable or unreadable aria tree (`missing`) degrades to the legacy
@@ -153,24 +155,10 @@ export declare class PlaywrightSession {
153
155
  buildRefSignatures(nodes: readonly BrowserNode[]): Map<string, RefSignature>;
154
156
  /** The document nonce shared by this snapshot's refs, or undefined. */
155
157
  refNonce(nodes: readonly BrowserNode[]): string | undefined;
156
- navigate(url: string, waitUntil: LoadState, signal?: AbortSignal): Promise<BrowserSnapshot | {
157
- url: string;
158
- title: string;
159
- nodes: never[];
160
- totalRefs: number;
161
- truncated: boolean;
162
- dialogNote: string;
163
- }>;
158
+ navigate(url: string, waitUntil: LoadState, signal?: AbortSignal): Promise<BrowserSnapshot>;
164
159
  snapshot(opts?: {
165
160
  interactiveOnly?: boolean;
166
- }): Promise<BrowserSnapshot | {
167
- url: string;
168
- title: string;
169
- nodes: never[];
170
- totalRefs: number;
171
- truncated: boolean;
172
- dialogNote: string;
173
- }>;
161
+ }): Promise<BrowserSnapshot>;
174
162
  /**
175
163
  * Codex-style click-point resolution. Tries a scroll-alignment ladder
176
164
  * (centre, then end, then start). For each alignment: scroll the element
@@ -186,14 +174,7 @@ export declare class PlaywrightSession {
186
174
  hitDesc: string;
187
175
  hitSelf: boolean;
188
176
  }>;
189
- click(ref: string, signal?: AbortSignal): Promise<BrowserSnapshot | {
190
- url: string;
191
- title: string;
192
- nodes: never[];
193
- totalRefs: number;
194
- truncated: boolean;
195
- dialogNote: string;
196
- }>;
177
+ click(ref: string, signal?: AbortSignal): Promise<BrowserSnapshot>;
197
178
  /**
198
179
  * Click an element the snapshot gave no ref, addressed by its visible label
199
180
  * (e.g. a menu item revealed by browser_click with mode "hover" that is plain text). The
@@ -201,14 +182,7 @@ export declare class PlaywrightSession {
201
182
  * and the landing note names what was actually clicked plus how many
202
183
  * elements shared the label.
203
184
  */
204
- clickText(text: string, signal?: AbortSignal): Promise<BrowserSnapshot | {
205
- url: string;
206
- title: string;
207
- nodes: never[];
208
- totalRefs: number;
209
- truncated: boolean;
210
- dialogNote: string;
211
- }>;
185
+ clickText(text: string, signal?: AbortSignal): Promise<BrowserSnapshot>;
212
186
  /**
213
187
  * Enforce the navigation policy for the link a click is about to follow.
214
188
  * Click navigation does not run through navigate(), so this is the only place
@@ -240,41 +214,13 @@ export declare class PlaywrightSession {
240
214
  * then report what actually received the click. The label names the target
241
215
  * in the landing note; shared is how many elements matched it (0 for refs).
242
216
  */
243
- clickLocator(locator: Locator, signal: AbortSignal | undefined, label: string, shared: number): Promise<BrowserSnapshot | {
244
- url: string;
245
- title: string;
246
- nodes: never[];
247
- totalRefs: number;
248
- truncated: boolean;
249
- dialogNote: string;
250
- }>;
251
- clickAt(x: number, y: number, signal?: AbortSignal): Promise<BrowserSnapshot | {
252
- url: string;
253
- title: string;
254
- nodes: never[];
255
- totalRefs: number;
256
- truncated: boolean;
257
- dialogNote: string;
258
- }>;
259
- clickAtRef(ref: string, signal?: AbortSignal): Promise<BrowserSnapshot | {
260
- url: string;
261
- title: string;
262
- nodes: never[];
263
- totalRefs: number;
264
- truncated: boolean;
265
- dialogNote: string;
266
- }>;
217
+ clickLocator(locator: Locator, signal: AbortSignal | undefined, label: string, shared: number): Promise<BrowserSnapshot>;
218
+ clickAt(x: number, y: number, signal?: AbortSignal): Promise<BrowserSnapshot>;
219
+ clickAtRef(ref: string, signal?: AbortSignal): Promise<BrowserSnapshot>;
267
220
  fill(ref: string, text: string, signal?: AbortSignal): Promise<BrowserSnapshot>;
268
221
  /** Read the field back and decide whether the fill landed. */
269
222
  verifyFill(locator: Locator, tag: string, text: string): Promise<boolean>;
270
- press(ref: string, key: string, signal?: AbortSignal): Promise<BrowserSnapshot | {
271
- url: string;
272
- title: string;
273
- nodes: never[];
274
- totalRefs: number;
275
- truncated: boolean;
276
- dialogNote: string;
277
- }>;
223
+ press(ref: string, key: string, signal?: AbortSignal): Promise<BrowserSnapshot>;
278
224
  /**
279
225
  * Hover the referenced element: rest the pointer on it so hover-revealed
280
226
  * content appears (CSS :hover rules, JS mouseenter handlers, submenus,
@@ -308,14 +254,7 @@ export declare class PlaywrightSession {
308
254
  title: string;
309
255
  active: boolean;
310
256
  }[]>;
311
- switchTab(index: number, signal?: AbortSignal): Promise<BrowserSnapshot | {
312
- url: string;
313
- title: string;
314
- nodes: never[];
315
- totalRefs: number;
316
- truncated: boolean;
317
- dialogNote: string;
318
- }>;
257
+ switchTab(index: number, signal?: AbortSignal): Promise<BrowserSnapshot>;
319
258
  openTab(url: string, waitUntil: LoadState, signal?: AbortSignal): Promise<BrowserSnapshot>;
320
259
  closeTab(index: number, signal?: AbortSignal): Promise<void>;
321
260
  /**
package/lib/session.js CHANGED
@@ -4,7 +4,7 @@ import { asTimeoutError, isAbortError, isContextDestroyed, isCrashError } from "
4
4
  import { consoleMessages, networkRequests, pageData, trackPage } from "./page-diagnostics.js";
5
5
  import { elementStabilityProbe, pageStabilityProbe } from "./page-probes.js";
6
6
  import { assertActionable, back, clickAt, clickAtRef, clickLocator, clickText, closeTab, fill, forward, hoverLocator, hoverText, navigate, openTab, press, resolveClickPoint, runAction, scroll, switchTab, verifyFill, withAbort, } from "./page-actions.js";
7
- import { assertLive, attachDiff, buildRefSignatures, captureAria, captureLegacy, refLocator, resolveSnapshotEngine, settleAndSnapshot, snapshot, } from "./page-snapshot.js";
7
+ import { assertLive, attachDiff, attachDocResponse, attachNoProgress, buildRefSignatures, captureAria, captureLegacy, refLocator, resolveSnapshotEngine, settleAndSnapshot, snapshot, } from "./page-snapshot.js";
8
8
  import { assertAllowedUrl } from "./url-policy.js";
9
9
  /** Live session over one browser context owned by one caller. */
10
10
  export class PlaywrightSession {
@@ -35,6 +35,15 @@ export class PlaywrightSession {
35
35
  diagDropped = { console: 0, network: 0 };
36
36
  /** In-flight requests keyed by request object, for status and duration attribution. */
37
37
  requestEntries = new WeakMap();
38
+ /**
39
+ * Main document response per page. Challenge detection reads this before it
40
+ * reads a word of the page: a response header is the interceptor's own API.
41
+ */
42
+ docResponses = new WeakMap();
43
+ /** Fingerprint of the previous capture's page state (no-progress backstop). */
44
+ lastFingerprint = undefined;
45
+ /** Consecutive captures that produced an identical page-state fingerprint. */
46
+ unchangedStreak = 0;
38
47
  /** Ring capacity per stream. */
39
48
  diagLimit = 200;
40
49
  /** ref -> feature signature of the previous capture (diff baseline). */
@@ -217,12 +226,15 @@ export class PlaywrightSession {
217
226
  });
218
227
  }
219
228
  /** Read the snapshot tree from one page (must run inside a run()). */
220
- async capture(page, opts) {
229
+ async capture(page, opts, afterAction = false) {
221
230
  this.provider.armDialogGuard(page);
222
231
  const raw = resolveSnapshotEngine(this.provider.config.snapshot.engine) === 'aria'
223
232
  ? await this.captureAria(page, opts)
224
233
  : await this.captureLegacy(page, opts);
225
- return this.attachDiff(raw);
234
+ // Order matters: the response metadata is attached before the diff, so the
235
+ // snapshot the model reads carries the same evidence the detector used, and
236
+ // the no-progress backstop sees the final tree.
237
+ return this.attachDiff(attachNoProgress(this, attachDocResponse(this, page, raw), afterAction));
226
238
  }
227
239
  /**
228
240
  * Semantic-tree capture via Playwright's official ariaSnapshot(mode:'ai').
@@ -325,6 +325,22 @@ function normalizeRole(role) {
325
325
  function convertAriaNode(node, opts, refs, roles, state) {
326
326
  const actionable = isActionableRole(node.role);
327
327
  if (opts.interactiveOnly === true && !actionable) {
328
+ // Frames are the one non-actionable node kept: their accessible name is the
329
+ // embedded widget's own title, and that title appears nowhere else in the
330
+ // tree (see the same rule in the legacy walker).
331
+ if (node.role === 'iframe') {
332
+ state.count += 1;
333
+ if (state.count > opts.maxNodes) {
334
+ state.truncated = true;
335
+ return null;
336
+ }
337
+ const frameName = node.name !== '' ? node.name : (node.placeholder ?? '');
338
+ return {
339
+ role: node.role,
340
+ name: frameName.length > opts.maxNameLength ? frameName.slice(0, opts.maxNameLength) + '…' : frameName,
341
+ children: [],
342
+ };
343
+ }
328
344
  const kids = [];
329
345
  for (const child of node.children) {
330
346
  const sub = convertAriaNode(child, opts, refs, roles, state);
@@ -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);
@@ -72,6 +95,26 @@ export function snapshotValue(snapshot) {
72
95
  const engine = snapshot.engineNote;
73
96
  if (engine !== undefined && engine !== '')
74
97
  notes.push('[page reader] ' + engine);
98
+ // The no-progress backstop rides with the other notes: it is advice about the
99
+ // action the model just took, so it has to be in that action's result. It is
100
+ // suppressed when a challenge was already identified, because "this is a wall,
101
+ // here is why" is the actionable message and stacking a second, vaguer warning
102
+ // on top of it only spends prompt budget on a case that is already decided.
103
+ const stall = snapshot.stallNote;
104
+ if (challenge === null && stall !== undefined && stall !== '')
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
+ }
75
118
  const head = notes.length > 0 ? notes.join('\n\n') + '\n\n' : '';
76
119
  // Incremental diff rides along when the provider computed one (snapshot.diff).
77
120
  const diffBlock = snapshot.diff !== undefined ? '\n\n' + renderSnapshotDiff(snapshot.diff) : '';
@@ -81,7 +124,19 @@ export function snapshotValue(snapshot) {
81
124
  refs: snapshot.totalRefs,
82
125
  truncated: snapshot.truncated,
83
126
  tree: head +
84
- (challenge === null ? tree : challenge.engine + ': ' + challenge.hint + '.\n' + CHALLENGE_NOTE + '\n\n' + tree) +
127
+ (challenge === null
128
+ ? tree
129
+ : challenge.engine +
130
+ ' [' +
131
+ challenge.tier +
132
+ ': ' +
133
+ challenge.evidence +
134
+ ']: ' +
135
+ challenge.hint +
136
+ '.\n' +
137
+ CHALLENGE_NOTE +
138
+ '\n\n' +
139
+ tree) +
85
140
  diffBlock,
86
141
  };
87
142
  }
@@ -3,6 +3,8 @@
3
3
  * @module dsh-browser-playwright-codex/tool-types
4
4
  */
5
5
  import type { StreamChunk } from '@deepseek-ai/dsh-llm';
6
+ import type { ChallengeTier } from './challenge-signatures.ts';
7
+ import type { DocumentResponse } from './types.ts';
6
8
  /**
7
9
  * JSON value: the shape every tool value must be serializable to, so the
8
10
  * framework can put it on the wire. Declared locally because the installed
@@ -42,6 +44,8 @@ export interface ScreenshotValue {
42
44
  /** Minimal node shape the challenge detector walks. */
43
45
  export interface ChallengeNode {
44
46
  readonly name?: string;
47
+ /** Accessible role. Both built-in engines provide one; it gates the wording tier. */
48
+ readonly role?: string;
45
49
  readonly children?: readonly ChallengeNode[];
46
50
  }
47
51
  /** The slice of a snapshot the challenge detector reads. */
@@ -49,11 +53,23 @@ export interface ChallengeSnapshot {
49
53
  readonly title?: string;
50
54
  readonly url?: string;
51
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;
62
+ /** Main document response metadata; absent when no navigation response was seen. */
63
+ readonly response?: DocumentResponse;
52
64
  }
53
65
  /** What the challenge detector reports when a page is an anti-bot wall. */
54
66
  export interface ChallengeMatch {
55
67
  engine: string;
56
68
  hint: string;
69
+ /** How it was proven, strongest first. */
70
+ tier: ChallengeTier;
71
+ /** The concrete signal that fired, so a reader can judge the call instead of trusting it. */
72
+ evidence: string;
57
73
  }
58
74
  /** The attachments-service face consumed at execution time. */
59
75
  export interface AttachmentServiceLike {
package/lib/types.d.ts CHANGED
@@ -25,6 +25,17 @@ export interface BrowserNode {
25
25
  readonly href?: string;
26
26
  readonly children: readonly BrowserNode[];
27
27
  }
28
+ /**
29
+ * Metadata of the main document response that produced a snapshot, read from the
30
+ * network layer. Challenge detection consults this first: a response header is the
31
+ * interceptor's own published API (`cf-mitigated: challenge`), unlike its wording.
32
+ */
33
+ export interface DocumentResponse {
34
+ readonly status: number;
35
+ readonly url: string;
36
+ /** Response headers as Playwright reports them (names lowercased). */
37
+ readonly headers: Readonly<Record<string, string>>;
38
+ }
28
39
  /** Bounded accessibility snapshot of the current page. */
29
40
  export interface BrowserSnapshot {
30
41
  readonly url: string;
@@ -58,6 +69,18 @@ export interface BrowserSnapshot {
58
69
  * and a user who picked smart mode kept believing it was running.
59
70
  */
60
71
  engineNote?: string;
72
+ /**
73
+ * Metadata of the main document response, when the capture observed one. It is
74
+ * the first thing challenge detection reads, because it is the interceptor's
75
+ * own API rather than its prose.
76
+ */
77
+ response?: DocumentResponse;
78
+ /**
79
+ * Advisory note appended when an action left the page identical. This is the
80
+ * no-progress backstop: it covers anti-bot walls whose vendor we have no
81
+ * signature for, by asking "did anything happen" instead of "which wall is this".
82
+ */
83
+ stallNote?: string;
61
84
  }
62
85
  /** One element that entered the tree since the previous snapshot. */
63
86
  export interface SnapshotDiffEntry {