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.
@@ -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 };
@@ -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.
@@ -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
- ctx.tools.register(defineTool({
588
- name: p('console_messages'),
589
- description: 'Return recent console messages and uncaught page errors from the tab this session is driving, newest last. ' +
590
- 'Use it right after an action that reported success but produced no visible change: a thrown page error or a failed ' +
591
- 'subresource is the usual reason a click or fill did not take effect. Read-only observation, it never acts on the page. ' +
592
- 'Capture starts when this session first drives a tab, so an empty list on a fresh tab is normal. ' +
593
- 'In persistent mode all sessions share one window, so entries describe that shared page.',
594
- parameters: {
595
- level: {
596
- type: 'string',
597
- description: "Filter by level: 'all' (default), 'error' (errors + uncaught page errors), 'warning', 'info', 'log', 'debug'.",
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
- limit: { type: 'number', description: 'Max entries to return, newest last. Default 50, cap 200.' },
600
- },
601
- output: {
602
- schema: { type: 'json' },
603
- render: (_args, value) => [
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
- failedOnly: { type: 'boolean', description: 'Only return requests that failed or returned status >= 400.' },
625
- limit: { type: 'number', description: 'Max entries to return, newest last. Default 50, cap 200.' },
626
- },
627
- output: {
628
- schema: { type: 'json' },
629
- render: (_args, value) => [
630
- { type: 'text', text: renderDiagnostics('network', value) },
631
- ],
632
- },
633
- async execute(args, exec) {
634
- const session = await acquire(exec);
635
- // The canonical value is a wire value; the framework types tool outputs as JSON.
636
- return session.networkRequests(args ?? {});
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "浏览器自动化",
4
- "description": "浏览器自动化(Playwright):无障碍快照交互 + 稳定元素引用、会话级浏览器实例、截图作为附件。"
4
+ "description": "浏览器自动化(Playwright):无障碍快照交互 + 稳定元素引用 + 会话级浏览器实例 + 截图附件;自带设置卡(总开关 / 页面识别方式 / 强力工具闸门 / 已登录站点名单)。"
5
5
  }
6
6
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-browser-playwright-codex",
3
- "version": "0.6.6",
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 stops for sign-ins, CAPTCHAs and native dialogs instead of confirming for you.",
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
- "engines": {
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
  }