dsh-browser-playwright-codex 0.6.5 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +49 -0
- package/NOTICE.md +5 -6
- package/README.md +165 -94
- package/SECURITY.md +24 -14
- package/cordis.patch.yml +76 -68
- package/lib/challenge-signatures.d.ts +9 -0
- package/lib/challenge-signatures.js +7 -4
- package/lib/challenge.d.ts +20 -0
- package/lib/challenge.js +106 -30
- package/lib/client.js +582 -0
- package/lib/contract.d.ts +5 -2
- package/lib/contract.js +12 -3
- package/lib/gates-patch.d.ts +46 -0
- package/lib/gates-patch.js +158 -0
- package/lib/index.d.ts +5 -0
- package/lib/index.js +6 -0
- package/lib/page-actions.d.ts +36 -0
- package/lib/page-actions.js +92 -18
- package/lib/page-diagnostics.d.ts +5 -0
- package/lib/page-diagnostics.js +37 -1
- package/lib/page-probes.d.ts +12 -0
- package/lib/page-probes.js +33 -0
- package/lib/page-snapshot.js +2 -2
- package/lib/plugin.d.ts +56 -0
- package/lib/plugin.js +31 -0
- package/lib/settings-card.d.ts +9 -0
- package/lib/settings-card.js +268 -0
- package/lib/snapshot-render.d.ts +12 -0
- package/lib/snapshot-render.js +99 -1
- package/lib/tool-render.js +37 -2
- package/lib/tool-types.d.ts +6 -0
- package/lib/tool.d.ts +6 -0
- package/lib/tool.js +58 -50
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +31 -19
- package/scripts/dsh-browser-verify.mjs +24 -12
package/lib/plugin.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import z from '@deepseek-ai/schemastery';
|
|
2
|
+
import BrowserRuntime, { Config as ServiceConfig } from "./service.js";
|
|
3
|
+
import * as playwright from "./playwright.js";
|
|
4
|
+
import * as tool from "./tool.js";
|
|
5
|
+
import * as settingsCard from "./settings-card.js";
|
|
6
|
+
import { Config as PlaywrightConfigSchema } from "./config.js";
|
|
7
|
+
import { Config as ToolConfigSchema } from "./tool.js";
|
|
8
|
+
/** Schemastery validation for {@link BrowserPluginConfig}. */
|
|
9
|
+
export const Config = z.object({
|
|
10
|
+
service: ServiceConfig,
|
|
11
|
+
playwright: PlaywrightConfigSchema,
|
|
12
|
+
tool: ToolConfigSchema,
|
|
13
|
+
});
|
|
14
|
+
/** Cordis plugin name used by loader diagnostics (this is the row id in patches). */
|
|
15
|
+
export const name = 'browser';
|
|
16
|
+
/**
|
|
17
|
+
* Mount the whole capability: the seam, the Playwright provider, the tool family,
|
|
18
|
+
* and — when the deployment has a web server — the Settings card.
|
|
19
|
+
*
|
|
20
|
+
* @param ctx - plugin context
|
|
21
|
+
* @param config - nested config for each concern; the loader fills defaults
|
|
22
|
+
*/
|
|
23
|
+
export function apply(ctx, config) {
|
|
24
|
+
ctx.plugin(BrowserRuntime, config.service ?? {});
|
|
25
|
+
ctx.plugin(playwright, config.playwright);
|
|
26
|
+
ctx.plugin(tool, config.tool);
|
|
27
|
+
// The card's host half injects `webServer`, so in a CLI deployment this child
|
|
28
|
+
// simply stays pending instead of failing the whole plugin.
|
|
29
|
+
ctx.plugin(settingsCard);
|
|
30
|
+
}
|
|
31
|
+
export default { name, Config, apply };
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings card host half, absorbed from dsh-browser-toggle 0.3.0 (2026-10-19).
|
|
3
|
+
* Same four /api/browser-playwright routes and CSRF rules; the only change is that the
|
|
4
|
+
* runtime state is now a sibling module instead of a package specifier.
|
|
5
|
+
*
|
|
6
|
+
* @module dsh-browser-playwright-codex/settings-card
|
|
7
|
+
*/
|
|
8
|
+
// @ts-nocheck — absorbed verbatim from the standalone package (plain JS), typed later.
|
|
9
|
+
/**
|
|
10
|
+
* dsh-browser-toggle - Host half.
|
|
11
|
+
*
|
|
12
|
+
* HTTP API for the Settings → Plugins UI card that flips the runtime enable
|
|
13
|
+
* flag of dsh-browser-playwright-codex without restarting DSH.
|
|
14
|
+
*
|
|
15
|
+
* GET /api/browser-playwright/state -> { enabled, engine, engineUserChosen, allowEvaluate, allowCdp }
|
|
16
|
+
* POST /api/browser-playwright/toggle {enabled} -> { enabled, changed }
|
|
17
|
+
* POST /api/browser-playwright/engine {engine} -> { engine, changed }
|
|
18
|
+
* POST /api/browser-playwright/gates {allowEvaluate, allowCdp} -> { allowEvaluate, allowCdp, changed }
|
|
19
|
+
* GET /api/browser-playwright/logins -> { logins: { source, capturedAt, cookies, total, sites } | null }
|
|
20
|
+
*
|
|
21
|
+
* `allowEvaluate` / `allowCdp` are the two power gates of the browser tool
|
|
22
|
+
* family: arbitrary page JavaScript and raw CDP. They are registration-time
|
|
23
|
+
* settings (an off gate means the tool is not registered at all, which is how
|
|
24
|
+
* the plugin keeps them out of the resident prompt), so this route cannot flip
|
|
25
|
+
* them in memory — it writes them into the home-level cordis.patch.yml, the
|
|
26
|
+
* layer with the highest priority among patch files, and lets the profile's
|
|
27
|
+
* `patchReload: live` re-apply the tree. The Settings page therefore reads the
|
|
28
|
+
* *effective* values published by the tool family (runtime-state.getGates),
|
|
29
|
+
* never a guess at the package default.
|
|
30
|
+
*
|
|
31
|
+
* The package itself ships both gates off: cordis.patch.yml travels inside the
|
|
32
|
+
* package, so anything it enabled would be enabled for every installer. This
|
|
33
|
+
* route is how a user opts in, in their own layer.
|
|
34
|
+
*
|
|
35
|
+
* `engine` is the page-reading mode: 'legacy' (the plugin's own DOM walk) or
|
|
36
|
+
* 'aria' (the browser's official accessibility tree). It is a user setting too,
|
|
37
|
+
* so it lives next to the enable flag and applies from the next operation on —
|
|
38
|
+
* no restart, no config edit. `engineUserChosen` says whether the current mode
|
|
39
|
+
* is the user's own pick or still the configured default.
|
|
40
|
+
*
|
|
41
|
+
* The enable flag lives in dsh-browser-playwright-codex's shared module
|
|
42
|
+
* (lib/runtime-state.js, persisted to ~/.dsh/dsh-browser-playwright.state.json —
|
|
43
|
+
* the file name is intentionally stable across package renames so the user's
|
|
44
|
+
* saved switch survives). Importing it through the package export
|
|
45
|
+
* gives this plugin the SAME module instance as the playwright provider, so
|
|
46
|
+
* flipping the flag here triggers the provider's disable-subscription (tears
|
|
47
|
+
* down any open window) in-process, and every future browser acquire is
|
|
48
|
+
* rejected while disabled.
|
|
49
|
+
*
|
|
50
|
+
* The specifier below must match the provider package's package.json#name.
|
|
51
|
+
* 2026-09-30: the provider was renamed to *-codex; the old specifier stopped
|
|
52
|
+
* resolving (MODULE_NOT_FOUND) and the toggle failed to load, so the import and
|
|
53
|
+
* the node_modules link have to move together with the rename.
|
|
54
|
+
*
|
|
55
|
+
* CSRF: POST requires the custom `x-dsh-plugin: browser-toggle` header, the
|
|
56
|
+
* same pattern as dsh-local-switch.
|
|
57
|
+
*/
|
|
58
|
+
// Namespace import on purpose: this host half and the provider package are
|
|
59
|
+
// separate plugins, and a profile that is still running an older provider build
|
|
60
|
+
// would otherwise fail at link time with "does not provide an export named".
|
|
61
|
+
// Missing helpers degrade to "unknown" instead of breaking the Settings page.
|
|
62
|
+
import * as runtimeState from "./runtime-state.js";
|
|
63
|
+
// The patch writer lives in its own dependency-free module so it can be tested
|
|
64
|
+
// without a DSH host (a half-written patch layer would break every plugin load).
|
|
65
|
+
import { homePatchPath, parseGatesBlock, writeGates } from "./gates-patch.js";
|
|
66
|
+
import { readFileSync } from 'node:fs';
|
|
67
|
+
const { getEnabled, setEnabled, getEffectiveEngine, setSnapshotEngine, isEngineUserChosen } = runtimeState;
|
|
68
|
+
export const name = 'browser-toggle';
|
|
69
|
+
export const inject = ['webServer'];
|
|
70
|
+
const PREFIX = '/api/browser-playwright';
|
|
71
|
+
const HEADER = 'x-dsh-plugin';
|
|
72
|
+
/** Read the gates the tool family published, or undefined while unknown. */
|
|
73
|
+
function readGates() {
|
|
74
|
+
if (typeof runtimeState.getGates !== 'function')
|
|
75
|
+
return undefined;
|
|
76
|
+
try {
|
|
77
|
+
const gates = runtimeState.getGates();
|
|
78
|
+
if (gates && typeof gates.allowEvaluate === 'boolean' && typeof gates.allowCdp === 'boolean')
|
|
79
|
+
return gates;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
/* fall through to unknown */
|
|
83
|
+
}
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Report the current managed block (what the user's own layer says). The switch
|
|
88
|
+
* shows the *effective* gates from readGates(); this is the file-level view used
|
|
89
|
+
* for diagnostics when the two disagree (i.e. the reload has not landed yet).
|
|
90
|
+
*/
|
|
91
|
+
function managedBlockGates() {
|
|
92
|
+
try {
|
|
93
|
+
const text = readFileSync(homePatchPath(), 'utf8');
|
|
94
|
+
return parseGatesBlock(text);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function sendJson(res, status, payload) {
|
|
101
|
+
res.statusCode = status;
|
|
102
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
103
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
104
|
+
res.end(JSON.stringify(payload));
|
|
105
|
+
}
|
|
106
|
+
async function readBody(req) {
|
|
107
|
+
const chunks = [];
|
|
108
|
+
try {
|
|
109
|
+
for await (const chunk of req)
|
|
110
|
+
chunks.push(chunk);
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
return { ok: false, error: String(error?.message ?? error) };
|
|
114
|
+
}
|
|
115
|
+
return { ok: true, text: Buffer.concat(chunks).toString('utf8') };
|
|
116
|
+
}
|
|
117
|
+
export function apply(ctx) {
|
|
118
|
+
ctx.effect(() => ctx.webServer.register({
|
|
119
|
+
kind: 'exact',
|
|
120
|
+
path: `${PREFIX}/state`,
|
|
121
|
+
handler: async (req, res) => {
|
|
122
|
+
if (req.method !== 'GET')
|
|
123
|
+
return sendJson(res, 405, { error: { code: 'METHOD', message: 'GET only' } });
|
|
124
|
+
// Required even for a read: this exposes the deployment's switch state. A custom
|
|
125
|
+
// header forces a CORS preflight, which is what stops any other page in the
|
|
126
|
+
// browser from reading it off 127.0.0.1 (2026-10-16: only the POST routes had it).
|
|
127
|
+
if (req.headers[HEADER] !== 'browser-toggle')
|
|
128
|
+
return sendJson(res, 403, { error: { code: 'CSRF', message: 'missing plugin header' } });
|
|
129
|
+
const gates = readGates();
|
|
130
|
+
sendJson(res, 200, {
|
|
131
|
+
enabled: getEnabled(),
|
|
132
|
+
engine: getEffectiveEngine(),
|
|
133
|
+
engineUserChosen: isEngineUserChosen(),
|
|
134
|
+
// Effective values, published by the tool family itself. null = the
|
|
135
|
+
// provider build in this profile is too old to report them.
|
|
136
|
+
allowEvaluate: gates ? gates.allowEvaluate : null,
|
|
137
|
+
allowCdp: gates ? gates.allowCdp : null,
|
|
138
|
+
// What the user's own layer says right now. Differs from the above
|
|
139
|
+
// between a write and the live reload that applies it.
|
|
140
|
+
patchGates: managedBlockGates() ?? null,
|
|
141
|
+
});
|
|
142
|
+
},
|
|
143
|
+
}));
|
|
144
|
+
ctx.effect(() => ctx.webServer.register({
|
|
145
|
+
kind: 'exact',
|
|
146
|
+
path: `${PREFIX}/toggle`,
|
|
147
|
+
handler: async (req, res) => {
|
|
148
|
+
if (req.method !== 'POST')
|
|
149
|
+
return sendJson(res, 405, { error: { code: 'METHOD', message: 'POST only' } });
|
|
150
|
+
if (req.headers[HEADER] !== 'browser-toggle')
|
|
151
|
+
return sendJson(res, 403, { error: { code: 'CSRF', message: 'missing plugin header' } });
|
|
152
|
+
const body = await readBody(req);
|
|
153
|
+
if (!body.ok)
|
|
154
|
+
return sendJson(res, 400, { error: { code: 'BODY_READ', message: body.error } });
|
|
155
|
+
let payload;
|
|
156
|
+
try {
|
|
157
|
+
payload = JSON.parse(body.text || '{}');
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return sendJson(res, 400, { error: { code: 'INVALID_JSON', message: 'body must be JSON' } });
|
|
161
|
+
}
|
|
162
|
+
if (typeof payload.enabled !== 'boolean')
|
|
163
|
+
return sendJson(res, 400, { error: { code: 'INVALID_BODY', message: '`enabled` must be a boolean' } });
|
|
164
|
+
const changed = setEnabled(payload.enabled);
|
|
165
|
+
sendJson(res, 200, { enabled: getEnabled(), changed });
|
|
166
|
+
},
|
|
167
|
+
}));
|
|
168
|
+
ctx.effect(() => ctx.webServer.register({
|
|
169
|
+
kind: 'exact',
|
|
170
|
+
path: `${PREFIX}/engine`,
|
|
171
|
+
handler: async (req, res) => {
|
|
172
|
+
if (req.method !== 'POST')
|
|
173
|
+
return sendJson(res, 405, { error: { code: 'METHOD', message: 'POST only' } });
|
|
174
|
+
if (req.headers[HEADER] !== 'browser-toggle')
|
|
175
|
+
return sendJson(res, 403, { error: { code: 'CSRF', message: 'missing plugin header' } });
|
|
176
|
+
const body = await readBody(req);
|
|
177
|
+
if (!body.ok)
|
|
178
|
+
return sendJson(res, 400, { error: { code: 'BODY_READ', message: body.error } });
|
|
179
|
+
let payload;
|
|
180
|
+
try {
|
|
181
|
+
payload = JSON.parse(body.text || '{}');
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
return sendJson(res, 400, { error: { code: 'INVALID_JSON', message: 'body must be JSON' } });
|
|
185
|
+
}
|
|
186
|
+
if (payload.engine !== 'legacy' && payload.engine !== 'aria')
|
|
187
|
+
return sendJson(res, 400, {
|
|
188
|
+
error: { code: 'INVALID_BODY', message: '`engine` must be "legacy" or "aria"' },
|
|
189
|
+
});
|
|
190
|
+
const changed = setSnapshotEngine(payload.engine);
|
|
191
|
+
sendJson(res, 200, { engine: getEffectiveEngine(), changed });
|
|
192
|
+
},
|
|
193
|
+
}));
|
|
194
|
+
ctx.effect(() => ctx.webServer.register({
|
|
195
|
+
kind: 'exact',
|
|
196
|
+
path: `${PREFIX}/gates`,
|
|
197
|
+
handler: async (req, res) => {
|
|
198
|
+
if (req.method !== 'POST')
|
|
199
|
+
return sendJson(res, 405, { error: { code: 'METHOD', message: 'POST only' } });
|
|
200
|
+
if (req.headers[HEADER] !== 'browser-toggle')
|
|
201
|
+
return sendJson(res, 403, { error: { code: 'CSRF', message: 'missing plugin header' } });
|
|
202
|
+
const body = await readBody(req);
|
|
203
|
+
if (!body.ok)
|
|
204
|
+
return sendJson(res, 400, { error: { code: 'BODY_READ', message: body.error } });
|
|
205
|
+
let payload;
|
|
206
|
+
try {
|
|
207
|
+
payload = JSON.parse(body.text || '{}');
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return sendJson(res, 400, { error: { code: 'INVALID_JSON', message: 'body must be JSON' } });
|
|
211
|
+
}
|
|
212
|
+
if (typeof payload.allowEvaluate !== 'boolean' || typeof payload.allowCdp !== 'boolean')
|
|
213
|
+
return sendJson(res, 400, {
|
|
214
|
+
error: {
|
|
215
|
+
code: 'INVALID_BODY',
|
|
216
|
+
message: '`allowEvaluate` and `allowCdp` must both be booleans',
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
const before = readGates();
|
|
220
|
+
if (before && before.allowEvaluate === payload.allowEvaluate && before.allowCdp === payload.allowCdp)
|
|
221
|
+
return sendJson(res, 200, { ...before, changed: false, patch: null });
|
|
222
|
+
try {
|
|
223
|
+
const written = await writeGates(payload.allowEvaluate, payload.allowCdp);
|
|
224
|
+
// The effective values only move once the loader re-applies the tree
|
|
225
|
+
// (profile patchReload: live); report what was written, and let the
|
|
226
|
+
// panel's next /state poll show whether it took effect.
|
|
227
|
+
sendJson(res, 200, {
|
|
228
|
+
allowEvaluate: written.allowEvaluate,
|
|
229
|
+
allowCdp: written.allowCdp,
|
|
230
|
+
changed: true,
|
|
231
|
+
patch: written.patch,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
sendJson(res, 500, {
|
|
236
|
+
error: { code: 'PATCH_WRITE', message: String(error?.message ?? error) },
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
}));
|
|
241
|
+
ctx.effect(() => ctx.webServer.register({
|
|
242
|
+
kind: 'exact',
|
|
243
|
+
path: `${PREFIX}/logins`,
|
|
244
|
+
handler: async (req, res) => {
|
|
245
|
+
if (req.method !== 'GET')
|
|
246
|
+
return sendJson(res, 405, { error: { code: 'METHOD', message: 'GET only' } });
|
|
247
|
+
// The CSRF header is mandatory here, and this route is the reason the rule exists:
|
|
248
|
+
// the payload is a list of the sites this browser is signed in to. A custom header
|
|
249
|
+
// forces a preflight, so no other page in the browser can read it off 127.0.0.1.
|
|
250
|
+
if (req.headers[HEADER] !== 'browser-toggle')
|
|
251
|
+
return sendJson(res, 403, { error: { code: 'CSRF', message: 'missing plugin header' } });
|
|
252
|
+
// Read-only, display-only: the provider publishes this summary (counts and domain
|
|
253
|
+
// names, never cookie values or names). null = nothing published yet, or the
|
|
254
|
+
// provider build in this profile is too old to publish it.
|
|
255
|
+
let logins = null;
|
|
256
|
+
if (typeof runtimeState.getLoginSites === 'function') {
|
|
257
|
+
try {
|
|
258
|
+
logins = runtimeState.getLoginSites() ?? null;
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
logins = null;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
sendJson(res, 200, { logins });
|
|
265
|
+
},
|
|
266
|
+
}));
|
|
267
|
+
}
|
|
268
|
+
export default { name, inject, apply };
|
package/lib/snapshot-render.d.ts
CHANGED
|
@@ -4,6 +4,18 @@
|
|
|
4
4
|
* @module dsh-browser-playwright-codex/snapshot-render
|
|
5
5
|
*/
|
|
6
6
|
import type { BrowserNode, BrowserSnapshot, SnapshotDiff } from './types.ts';
|
|
7
|
+
/** Default cap for a rendered href, in characters. */
|
|
8
|
+
export declare const MAX_HREF_LENGTH = 160;
|
|
9
|
+
/**
|
|
10
|
+
* Trim a href for display: drop analytics parameters and clip the whole thing.
|
|
11
|
+
* The snapshot's refs remain the interaction handle, so this only affects what
|
|
12
|
+
* the model reads, never what a click does.
|
|
13
|
+
*
|
|
14
|
+
* @param href - the raw href from the accessibility tree
|
|
15
|
+
* @param maxLength - display budget in characters
|
|
16
|
+
* @returns the shortened href
|
|
17
|
+
*/
|
|
18
|
+
export declare function trimUrlForDisplay(href: string, maxLength?: number): string;
|
|
7
19
|
/**
|
|
8
20
|
* Render a node tree as indented lines, one element per line.
|
|
9
21
|
* @param nodes - the snapshot's root nodes.
|
package/lib/snapshot-render.js
CHANGED
|
@@ -3,8 +3,106 @@
|
|
|
3
3
|
* functions run on live calls and on session replay alike.
|
|
4
4
|
* @module dsh-browser-playwright-codex/snapshot-render
|
|
5
5
|
*/
|
|
6
|
+
/**
|
|
7
|
+
* Query parameters that only feed analytics or the site's own ranking pipeline.
|
|
8
|
+
* They make a rendered href several times longer than the link it names and the
|
|
9
|
+
* model never needs them: the ref (not the URL) is what clicks.
|
|
10
|
+
*/
|
|
11
|
+
const TRACKING_PARAMS = new Set([
|
|
12
|
+
'gclid',
|
|
13
|
+
'dclid',
|
|
14
|
+
'fbclid',
|
|
15
|
+
'msclkid',
|
|
16
|
+
'yclid',
|
|
17
|
+
'spm',
|
|
18
|
+
'scm',
|
|
19
|
+
'share_token',
|
|
20
|
+
'rsv_spt',
|
|
21
|
+
'rsv_iqid',
|
|
22
|
+
'rsv_bp',
|
|
23
|
+
'rsv_idx',
|
|
24
|
+
'rsv_enter',
|
|
25
|
+
'rsv_pq',
|
|
26
|
+
'rsv_t',
|
|
27
|
+
'rsv_dl',
|
|
28
|
+
'rsv_sug1',
|
|
29
|
+
'rsv_sug2',
|
|
30
|
+
'rsv_sug3',
|
|
31
|
+
'rsv_sug4',
|
|
32
|
+
'rsv_sug5',
|
|
33
|
+
'rsv_sug6',
|
|
34
|
+
'rsv_sug7',
|
|
35
|
+
'rsv_sug8',
|
|
36
|
+
'rsv_sug9',
|
|
37
|
+
'rsv_sug10',
|
|
38
|
+
'sa',
|
|
39
|
+
'usm',
|
|
40
|
+
'rqid',
|
|
41
|
+
'rsf',
|
|
42
|
+
]);
|
|
43
|
+
/** Default cap for a rendered href, in characters. */
|
|
44
|
+
export const MAX_HREF_LENGTH = 160;
|
|
45
|
+
/** Clip a string to a display budget, marking the cut. */
|
|
46
|
+
function clip(value, maxLength) {
|
|
47
|
+
return value.length <= maxLength ? value : value.slice(0, maxLength - 1) + '…';
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Trim a href for display: drop analytics parameters and clip the whole thing.
|
|
51
|
+
* The snapshot's refs remain the interaction handle, so this only affects what
|
|
52
|
+
* the model reads, never what a click does.
|
|
53
|
+
*
|
|
54
|
+
* @param href - the raw href from the accessibility tree
|
|
55
|
+
* @param maxLength - display budget in characters
|
|
56
|
+
* @returns the shortened href
|
|
57
|
+
*/
|
|
58
|
+
export function trimUrlForDisplay(href, maxLength = MAX_HREF_LENGTH) {
|
|
59
|
+
const hashAt = href.indexOf('#');
|
|
60
|
+
const hash = hashAt >= 0 ? href.slice(hashAt) : '';
|
|
61
|
+
const head = hashAt >= 0 ? href.slice(0, hashAt) : href;
|
|
62
|
+
const queryAt = head.indexOf('?');
|
|
63
|
+
if (queryAt < 0)
|
|
64
|
+
return clip(head + hash, maxLength);
|
|
65
|
+
const base = head.slice(0, queryAt);
|
|
66
|
+
const kept = head
|
|
67
|
+
.slice(queryAt + 1)
|
|
68
|
+
.split('&')
|
|
69
|
+
.filter((pair) => pair !== '')
|
|
70
|
+
.filter((pair) => {
|
|
71
|
+
let key = pair.split('=')[0] ?? '';
|
|
72
|
+
try {
|
|
73
|
+
key = decodeURIComponent(key);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
/* keep the raw key: an undecodable one is not a known tracker */
|
|
77
|
+
}
|
|
78
|
+
key = key.toLowerCase();
|
|
79
|
+
return !key.startsWith('utm_') && !TRACKING_PARAMS.has(key);
|
|
80
|
+
});
|
|
81
|
+
return clip(base + (kept.length > 0 ? '?' + kept.join('&') : '') + hash, maxLength);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* A wrapper element with no name, ref, state or link of its own. Keeping it
|
|
85
|
+
* would push every real element one level deeper and repeat the word "generic"
|
|
86
|
+
* for every <div> on the page; dropping it keeps the structure the model needs
|
|
87
|
+
* (nesting of things that have names) without the noise.
|
|
88
|
+
*/
|
|
89
|
+
function isTransparentWrapper(node) {
|
|
90
|
+
return (node.role === 'generic' &&
|
|
91
|
+
node.name === '' &&
|
|
92
|
+
node.ref === undefined &&
|
|
93
|
+
node.href === undefined &&
|
|
94
|
+
node.level === undefined &&
|
|
95
|
+
node.checked === undefined &&
|
|
96
|
+
node.selected === undefined &&
|
|
97
|
+
node.disabled === undefined);
|
|
98
|
+
}
|
|
6
99
|
/** Render one node line into the accumulating line list. */
|
|
7
100
|
function renderNode(node, depth, lines) {
|
|
101
|
+
if (isTransparentWrapper(node)) {
|
|
102
|
+
for (const child of node.children)
|
|
103
|
+
renderNode(child, depth, lines);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
8
106
|
const indent = ' '.repeat(depth);
|
|
9
107
|
let line = indent + '- ' + node.role;
|
|
10
108
|
if (node.name !== '')
|
|
@@ -23,7 +121,7 @@ function renderNode(node, depth, lines) {
|
|
|
23
121
|
if (flags.length > 0)
|
|
24
122
|
line += ' [' + flags.join(', ') + ']';
|
|
25
123
|
if (node.href !== undefined)
|
|
26
|
-
line += ' -> ' + node.href;
|
|
124
|
+
line += ' -> ' + trimUrlForDisplay(node.href);
|
|
27
125
|
lines.push(line);
|
|
28
126
|
for (const child of node.children)
|
|
29
127
|
renderNode(child, depth + 1, lines);
|
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);
|
|
@@ -80,6 +103,18 @@ export function snapshotValue(snapshot) {
|
|
|
80
103
|
const stall = snapshot.stallNote;
|
|
81
104
|
if (challenge === null && stall !== undefined && stall !== '')
|
|
82
105
|
notes.push(stall);
|
|
106
|
+
// The backstop above needs an action to compare before and after; a page with
|
|
107
|
+
// nothing to click never gets one, so the emptiest wall-like pages used to say
|
|
108
|
+
// nothing at all. Only on a successful status: a 4xx/5xx already has its own line.
|
|
109
|
+
const healthyStatus = status === undefined || (status >= 200 && status < 300);
|
|
110
|
+
// A parked dialog means the page is fine, it is the dialog that blocks us: the
|
|
111
|
+
// dialog note already says what to do, and adding "nothing here" contradicts it.
|
|
112
|
+
const dialogPending = snapshot.dialogNote !== undefined && snapshot.dialogNote !== '';
|
|
113
|
+
if (challenge === null && healthyStatus && !dialogPending) {
|
|
114
|
+
const silent = detectSilentPage({ refs: snapshot.totalRefs, nodes: snapshot.nodes, title: snapshot.title });
|
|
115
|
+
if (silent !== null)
|
|
116
|
+
notes.push(silent);
|
|
117
|
+
}
|
|
83
118
|
const head = notes.length > 0 ? notes.join('\n\n') + '\n\n' : '';
|
|
84
119
|
// Incremental diff rides along when the provider computed one (snapshot.diff).
|
|
85
120
|
const diffBlock = snapshot.diff !== undefined ? '\n\n' + renderSnapshotDiff(snapshot.diff) : '';
|
package/lib/tool-types.d.ts
CHANGED
|
@@ -53,6 +53,12 @@ export interface ChallengeSnapshot {
|
|
|
53
53
|
readonly title?: string;
|
|
54
54
|
readonly url?: string;
|
|
55
55
|
readonly nodes?: readonly ChallengeNode[];
|
|
56
|
+
/**
|
|
57
|
+
* Actionable-element count. The wording tier uses it to tell a challenge page
|
|
58
|
+
* (nothing to click) from an ordinary page whose *title* merely contains the
|
|
59
|
+
* phrase — searching Baidu for "安全验证" produces exactly that false positive.
|
|
60
|
+
*/
|
|
61
|
+
readonly refs?: number;
|
|
56
62
|
/** Main document response metadata; absent when no navigation response was seen. */
|
|
57
63
|
readonly response?: DocumentResponse;
|
|
58
64
|
}
|
package/lib/tool.d.ts
CHANGED
|
@@ -31,6 +31,12 @@ export interface ToolConfig {
|
|
|
31
31
|
* its schema would sit in the system prompt on every turn for nothing.
|
|
32
32
|
*/
|
|
33
33
|
registerDisabledTools: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Register the two diagnostics tools (console messages, network requests).
|
|
36
|
+
* Defaults to false: they exist to debug a failed run, and their schemas
|
|
37
|
+
* would otherwise sit in the system prompt on every turn.
|
|
38
|
+
*/
|
|
39
|
+
diagnostics: boolean;
|
|
34
40
|
/** Upper bound for browser_wait in milliseconds. */
|
|
35
41
|
maxWaitMs: number;
|
|
36
42
|
/** Default value of the snapshot tool's interactiveOnly flag. */
|