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.
- package/CHANGELOG.md +57 -0
- package/NOTICE.md +5 -6
- package/README.md +130 -38
- package/SECURITY.md +18 -11
- package/lib/challenge-signatures.d.ts +114 -0
- package/lib/challenge-signatures.js +157 -0
- package/lib/challenge.d.ts +68 -8
- package/lib/challenge.js +287 -26
- package/lib/injected.js +13 -0
- package/lib/page-actions.d.ts +27 -56
- package/lib/page-actions.js +50 -18
- package/lib/page-diagnostics.d.ts +5 -0
- package/lib/page-diagnostics.js +60 -6
- package/lib/page-probes.d.ts +12 -0
- package/lib/page-probes.js +33 -0
- package/lib/page-snapshot.d.ts +14 -8
- package/lib/page-snapshot.js +50 -3
- package/lib/session.d.ts +21 -82
- package/lib/session.js +15 -3
- package/lib/snapshot-aria.js +16 -0
- package/lib/tool-render.js +58 -3
- package/lib/tool-types.d.ts +16 -0
- package/lib/types.d.ts +23 -0
- package/package.json +8 -7
- package/scripts/dsh-browser-verify.mjs +24 -12
- package/scripts/analysis/hl-compare.mjs +0 -149
- package/scripts/analysis/live-handoff-probe.mjs +0 -97
- package/scripts/analysis/needs-human-probe.mjs +0 -83
- package/scripts/analysis/repro-minimize.mjs +0 -112
- package/scripts/autocommit.ps1 +0 -23
- package/scripts/check-doc-links.py +0 -112
- package/scripts/measure-prompt-cost.mjs +0 -57
- package/scripts/preflight.mjs +0 -192
- package/scripts/prompt-cost.mjs +0 -73
- package/scripts/shipped-gates.mjs +0 -43
package/lib/page-diagnostics.js
CHANGED
|
@@ -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
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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 {
|
package/lib/page-probes.d.ts
CHANGED
|
@@ -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
|
package/lib/page-probes.js
CHANGED
|
@@ -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;
|
package/lib/page-snapshot.d.ts
CHANGED
|
@@ -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;
|
package/lib/page-snapshot.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
245
|
-
|
|
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
|
-
|
|
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').
|
package/lib/snapshot-aria.js
CHANGED
|
@@ -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);
|
package/lib/tool-render.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
}
|
package/lib/tool-types.d.ts
CHANGED
|
@@ -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 {
|