dsh-browser-playwright-codex 0.6.6 → 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 +21 -0
- package/README.md +69 -57
- package/SECURITY.md +6 -3
- package/cordis.patch.yml +76 -68
- 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 +16 -0
- package/lib/page-actions.js +43 -1
- 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.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 +27 -15
|
@@ -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.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. */
|
package/lib/tool.js
CHANGED
|
@@ -21,6 +21,7 @@ export const Config = z.object({
|
|
|
21
21
|
allowEvaluate: z.boolean().default(false),
|
|
22
22
|
allowCdp: z.boolean().default(false),
|
|
23
23
|
registerDisabledTools: z.boolean().default(false),
|
|
24
|
+
diagnostics: z.boolean().default(false),
|
|
24
25
|
maxWaitMs: z.number().default(60000),
|
|
25
26
|
interactiveOnlyDefault: z.boolean().default(false),
|
|
26
27
|
extract: z.object({
|
|
@@ -50,6 +51,7 @@ export function apply(ctx, config) {
|
|
|
50
51
|
allowEvaluate: config.allowEvaluate,
|
|
51
52
|
allowCdp: config.allowCdp,
|
|
52
53
|
extractConfigured: config.extract?.provider !== undefined && config.extract?.model !== undefined,
|
|
54
|
+
diagnostics: config.diagnostics,
|
|
53
55
|
registerDisabledTools: config.registerDisabledTools,
|
|
54
56
|
};
|
|
55
57
|
const registered = new Set(registeredSuffixes(surface));
|
|
@@ -262,6 +264,8 @@ export function apply(ctx, config) {
|
|
|
262
264
|
description: 'Replace the value of the input or select referenced by the latest snapshot. ' +
|
|
263
265
|
'`value` is accepted as an alias of `text`. ' +
|
|
264
266
|
'For <select> elements the text selects the matching option label. ' +
|
|
267
|
+
'Credential fields (password, one-time code, card number) are refused with CREDENTIAL_FIELD: ' +
|
|
268
|
+
'a human types secrets in the visible window, so ask them and re-snapshot instead. ' +
|
|
265
269
|
'IRON RULE: after filling, verify in the returned snapshot that the field actually holds the value. ' +
|
|
266
270
|
'Frameworks can silently reject or transform input (controlled components, contenteditable editors, ' +
|
|
267
271
|
'input-mask/currency fields). If the value is wrong or missing, do NOT blindly re-fill or append — ' +
|
|
@@ -584,58 +588,62 @@ export function apply(ctx, config) {
|
|
|
584
588
|
return (await session.cdp(args.method, args.params ?? {}, exec.signal));
|
|
585
589
|
},
|
|
586
590
|
}));
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
'
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
591
|
+
if (config.diagnostics || config.registerDisabledTools) {
|
|
592
|
+
ctx.tools.register(defineTool({
|
|
593
|
+
name: p('console_messages'),
|
|
594
|
+
description: 'Return recent console messages and uncaught page errors from the tab this session is driving, newest last. ' +
|
|
595
|
+
'Use it right after an action that reported success but produced no visible change: a thrown page error or a failed ' +
|
|
596
|
+
'subresource is the usual reason a click or fill did not take effect. Read-only observation, it never acts on the page. ' +
|
|
597
|
+
'Capture starts when this session first drives a tab, so an empty list on a fresh tab is normal. ' +
|
|
598
|
+
'In persistent mode all sessions share one window, so entries describe that shared page.',
|
|
599
|
+
parameters: {
|
|
600
|
+
level: {
|
|
601
|
+
type: 'string',
|
|
602
|
+
description: "Filter by level: 'all' (default), 'error' (errors + uncaught page errors), 'warning', 'info', 'log', 'debug'.",
|
|
603
|
+
},
|
|
604
|
+
limit: { type: 'number', description: 'Max entries to return, newest last. Default 50, cap 200.' },
|
|
598
605
|
},
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
{ type: 'text', text: renderDiagnostics('console', value) },
|
|
605
|
-
],
|
|
606
|
-
},
|
|
607
|
-
async execute(args, exec) {
|
|
608
|
-
const session = await acquire(exec);
|
|
609
|
-
// The canonical value is a wire value; the framework types tool outputs as JSON.
|
|
610
|
-
return session.consoleMessages(args ?? {});
|
|
611
|
-
},
|
|
612
|
-
}));
|
|
613
|
-
ctx.tools.register(defineTool({
|
|
614
|
-
name: p('network_requests'),
|
|
615
|
-
description: 'Return recent network requests from the tab this session is driving, newest last: method, URL, status, ' +
|
|
616
|
-
'resource type, duration, and the failure text for requests that never completed. Use it to tell "the page did not ' +
|
|
617
|
-
'react" apart from "the request behind it failed", and to find the API call an action triggered. ' +
|
|
618
|
-
'Read-only; response bodies are not captured. In persistent mode all sessions share one window, so entries describe that shared page.',
|
|
619
|
-
parameters: {
|
|
620
|
-
urlContains: {
|
|
621
|
-
type: 'string',
|
|
622
|
-
description: 'Only return requests whose URL contains this substring (case-insensitive).',
|
|
606
|
+
output: {
|
|
607
|
+
schema: { type: 'json' },
|
|
608
|
+
render: (_args, value) => [
|
|
609
|
+
{ type: 'text', text: renderDiagnostics('console', value) },
|
|
610
|
+
],
|
|
623
611
|
},
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
612
|
+
async execute(args, exec) {
|
|
613
|
+
const session = await acquire(exec);
|
|
614
|
+
// The canonical value is a wire value; the framework types tool outputs as JSON.
|
|
615
|
+
return session.consoleMessages(args ?? {});
|
|
616
|
+
},
|
|
617
|
+
}));
|
|
618
|
+
}
|
|
619
|
+
if (config.diagnostics || config.registerDisabledTools) {
|
|
620
|
+
ctx.tools.register(defineTool({
|
|
621
|
+
name: p('network_requests'),
|
|
622
|
+
description: 'Return recent network requests from the tab this session is driving, newest last: method, URL, status, ' +
|
|
623
|
+
'resource type, duration, and the failure text for requests that never completed. Use it to tell "the page did not ' +
|
|
624
|
+
'react" apart from "the request behind it failed", and to find the API call an action triggered. ' +
|
|
625
|
+
'Read-only; response bodies are not captured. In persistent mode all sessions share one window, so entries describe that shared page.',
|
|
626
|
+
parameters: {
|
|
627
|
+
urlContains: {
|
|
628
|
+
type: 'string',
|
|
629
|
+
description: 'Only return requests whose URL contains this substring (case-insensitive).',
|
|
630
|
+
},
|
|
631
|
+
failedOnly: { type: 'boolean', description: 'Only return requests that failed or returned status >= 400.' },
|
|
632
|
+
limit: { type: 'number', description: 'Max entries to return, newest last. Default 50, cap 200.' },
|
|
633
|
+
},
|
|
634
|
+
output: {
|
|
635
|
+
schema: { type: 'json' },
|
|
636
|
+
render: (_args, value) => [
|
|
637
|
+
{ type: 'text', text: renderDiagnostics('network', value) },
|
|
638
|
+
],
|
|
639
|
+
},
|
|
640
|
+
async execute(args, exec) {
|
|
641
|
+
const session = await acquire(exec);
|
|
642
|
+
// The canonical value is a wire value; the framework types tool outputs as JSON.
|
|
643
|
+
return session.networkRequests(args ?? {});
|
|
644
|
+
},
|
|
645
|
+
}));
|
|
646
|
+
}
|
|
639
647
|
ctx.tools.register(defineTool({
|
|
640
648
|
name: p('close'),
|
|
641
649
|
description: "Release this session's browser window. Persistent mode shares one window, so it closes only when the last session releases it; the profile keeps login state either way, so the next browser call reopens it still signed in (reset the profile to clear cookies).",
|
package/locale/en.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meta": {
|
|
3
3
|
"title": "Browser Automation",
|
|
4
|
-
"description": "Playwright-powered browser capability for DeepSeek Harness: accessibility-snapshot interaction with stable element refs, session-scoped browser sessions, screenshots as attachments."
|
|
4
|
+
"description": "Playwright-powered browser capability for DeepSeek Harness: accessibility-snapshot interaction with stable element refs, session-scoped browser sessions, screenshots as attachments — with the Settings card (on/off, page-reading mode, capability gates, signed-in sites) built in."
|
|
5
5
|
}
|
|
6
6
|
}
|
package/locale/zh.json
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-browser-playwright-codex",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Browser automation for DeepSeek Harness: fill in forms, scrape lists and work across tabs in real pages, using the logins you already have. Runs in a visible window that stays quiet when minimised, and
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"description": "Browser automation for DeepSeek Harness, one plugin: fill in forms, scrape lists and work across tabs in real pages, using the logins you already have. Runs in a visible window that stays quiet when minimised; native dialogs are parked instead of confirmed, CAPTCHA pages are flagged with a note that says stop and hand the page back, password / one-time-code / card fields are refused by browser_fill, and the Settings card (enable/disable, page-reading mode, capability gates) now lives inside this package.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "lib/index.js",
|
|
@@ -28,7 +28,14 @@
|
|
|
28
28
|
},
|
|
29
29
|
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
30
30
|
"./package.json": "./package.json",
|
|
31
|
-
"./locale/*.json": "./locale/*.json"
|
|
31
|
+
"./locale/*.json": "./locale/*.json",
|
|
32
|
+
"./client": {
|
|
33
|
+
"default": "./lib/client.js"
|
|
34
|
+
},
|
|
35
|
+
"./plugin": {
|
|
36
|
+
"types": "./lib/plugin.d.ts",
|
|
37
|
+
"default": "./lib/plugin.js"
|
|
38
|
+
}
|
|
32
39
|
},
|
|
33
40
|
"files": [
|
|
34
41
|
"lib",
|
|
@@ -42,7 +49,7 @@
|
|
|
42
49
|
"scripts/dsh-browser-verify.mjs"
|
|
43
50
|
],
|
|
44
51
|
"scripts": {
|
|
45
|
-
"build": "node node_modules/typescript/bin/tsc -p tsconfig.json",
|
|
52
|
+
"build": "node node_modules/typescript/bin/tsc -p tsconfig.json && node scripts/build-client.mjs",
|
|
46
53
|
"typecheck": "node node_modules/typescript/bin/tsc -p tsconfig.tests.json",
|
|
47
54
|
"test": "node --test \"tests/*.test.ts\"",
|
|
48
55
|
"test:scenarios": "node --test tests/scenarios.test.ts tests/tool-scenarios.test.ts",
|
|
@@ -82,8 +89,21 @@
|
|
|
82
89
|
"dsh": {
|
|
83
90
|
"bundle": {
|
|
84
91
|
"patch": "./cordis.patch.yml"
|
|
92
|
+
},
|
|
93
|
+
"client": {
|
|
94
|
+
"platform": "web",
|
|
95
|
+
"inject": []
|
|
85
96
|
}
|
|
86
97
|
},
|
|
98
|
+
"engines": {
|
|
99
|
+
"node": "^22.19.0 || >=24.0.0"
|
|
100
|
+
},
|
|
101
|
+
"packageManager": "pnpm@10.31.0",
|
|
102
|
+
"repository": {
|
|
103
|
+
"type": "git",
|
|
104
|
+
"url": "git+https://github.com/miqian-nomad/dsh-browser-playwright-codex.git"
|
|
105
|
+
},
|
|
106
|
+
"homepage": "https://github.com/miqian-nomad/dsh-browser-playwright-codex#readme",
|
|
87
107
|
"keywords": [
|
|
88
108
|
"deepseek-harness",
|
|
89
109
|
"dsh",
|
|
@@ -98,15 +118,7 @@
|
|
|
98
118
|
"form-filling",
|
|
99
119
|
"deepseek",
|
|
100
120
|
"ai-agent",
|
|
101
|
-
"rpa"
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
"node": "^22.19.0 || >=24.0.0"
|
|
105
|
-
},
|
|
106
|
-
"packageManager": "pnpm@10.31.0",
|
|
107
|
-
"repository": {
|
|
108
|
-
"type": "git",
|
|
109
|
-
"url": "git+https://github.com/miqian-nomad/dsh-browser-playwright-codex.git"
|
|
110
|
-
},
|
|
111
|
-
"homepage": "https://github.com/miqian-nomad/dsh-browser-playwright-codex#readme"
|
|
121
|
+
"rpa",
|
|
122
|
+
"settings"
|
|
123
|
+
]
|
|
112
124
|
}
|