dsh-builtin-browser 0.4.0 → 0.4.2

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.
Files changed (44) hide show
  1. package/README.en.md +68 -30
  2. package/README.md +66 -30
  3. package/client.js +60 -6
  4. package/cordis.patch.yml +21 -0
  5. package/desktop-bridge/plugin-browser-bridge.js +12 -4
  6. package/docs/architecture.md +12 -2
  7. package/docs/tool-reference.md +17 -17
  8. package/docs/user-guide.md +13 -4
  9. package/docs/why-browser.md +2 -2
  10. package/lib/browser/types.d.ts +25 -1
  11. package/lib/browser-electron/bridge-connection.d.ts +31 -5
  12. package/lib/browser-electron/bridge-connection.js +143 -21
  13. package/lib/browser-electron/entry.d.ts +1 -1
  14. package/lib/browser-electron/entry.js +61 -35
  15. package/lib/browser-electron/ephemeral-profile.d.ts +37 -0
  16. package/lib/browser-electron/ephemeral-profile.js +146 -0
  17. package/lib/browser-electron/history-store.d.ts +25 -0
  18. package/lib/browser-electron/history-store.js +51 -4
  19. package/lib/browser-electron/host-main.js +75 -27
  20. package/lib/browser-electron/provider.d.ts +44 -1
  21. package/lib/browser-electron/provider.js +379 -48
  22. package/lib/browser-electron/remote-host.d.ts +52 -3
  23. package/lib/browser-electron/remote-host.js +244 -23
  24. package/lib/browser-electron/settings-route.d.ts +28 -0
  25. package/lib/browser-electron/settings-route.js +28 -0
  26. package/lib/browser-electron/settings-store.d.ts +40 -0
  27. package/lib/browser-electron/settings-store.js +107 -11
  28. package/lib/browser-electron/system-browser.d.ts +71 -3
  29. package/lib/browser-electron/system-browser.js +355 -38
  30. package/lib/tool-browser/index.js +143 -34
  31. package/package.json +5 -3
  32. package/scripts/verify-host-compat.mjs +195 -0
  33. package/src/browser/types.ts +25 -1
  34. package/src/browser-electron/bridge-connection.ts +142 -21
  35. package/src/browser-electron/entry.ts +58 -33
  36. package/src/browser-electron/ephemeral-profile.ts +152 -0
  37. package/src/browser-electron/history-store.ts +51 -4
  38. package/src/browser-electron/host-main.ts +105 -35
  39. package/src/browser-electron/provider.ts +401 -51
  40. package/src/browser-electron/remote-host.ts +261 -36
  41. package/src/browser-electron/settings-route.ts +46 -0
  42. package/src/browser-electron/settings-store.ts +121 -11
  43. package/src/browser-electron/system-browser.ts +342 -21
  44. package/src/tool-browser/index.ts +144 -36
@@ -66,7 +66,10 @@ export const READ_ONLY_TOOLS = new Set([
66
66
  'browser_list_tabs', 'browser_session', 'browser_history', 'browser_visited',
67
67
  // browser_auth reads cookies out of the browser: an observation, and one the
68
68
  // credentials switch gates on its own (see credentials.allowRead in the settings).
69
- 'browser_auth',
69
+ // browser_auth is deliberately NOT here. It reads cookies on "flush" but WRITES them on
70
+ // "restore", to arbitrary domains, which is an action like any other — exempting the tool
71
+ // would let a task that restricted its own actions rewrite the shared browser's logins.
72
+ //
70
73
  // Getting out of a bad state, or lifting the restriction itself. Without these a task
71
74
  // that restricted everything could neither recover nor be released.
72
75
  'browser_restrict', 'browser_reset_session', 'browser_reset',
@@ -94,6 +97,26 @@ function assertAllowed(state, toolName, task) {
94
97
  function agentOf(exec) {
95
98
  return exec?.agent;
96
99
  }
100
+ /**
101
+ * Read a target's `index`, refusing the values that cannot mean anything.
102
+ *
103
+ * The index is looked up in the page (`els[index] ?? null`), so a negative one — `-1`, the
104
+ * common "give me the last one" shorthand — matches nothing. The page script then treated
105
+ * that as "not on the page yet" and polled for its whole 10s budget before reporting
106
+ * "element not found", which reads like a slow page rather than a bad argument. Refusing it
107
+ * here names the real problem immediately.
108
+ * @param target - the caller's target, or undefined when there is none.
109
+ * @returns the index, or undefined to let the page default to 0.
110
+ */
111
+ function targetIndex(target) {
112
+ const index = target?.index;
113
+ if (index === undefined)
114
+ return undefined;
115
+ if (typeof index !== 'number' || !Number.isSafeInteger(index) || index < 0) {
116
+ throw new Error(`target.index must be a non-negative integer (got ${JSON.stringify(index)}); it is a 0-based position among the matches, and negative values such as -1 match nothing — pass the position you want, or narrow the selector`);
117
+ }
118
+ return index;
119
+ }
97
120
  /**
98
121
  * The task key for a tool call: the calling DSH session id, or the shared
99
122
  * default key when the call carries no agent context (CLI probes, tests).
@@ -191,7 +214,7 @@ function parseFillValue(v) {
191
214
  */
192
215
  function formatSnapshot(snapshot, options = {}) {
193
216
  const showCoords = options.coords === true;
194
- const lines = snapshot.elements.map(el => `[${el.ref}] ${el.kind}: ${el.label}${el.frame === true ? ' (iframe)' : ''}${showCoords ? ` (${el.x},${el.y})` : ''}`);
217
+ const lines = snapshot.elements.map(el => `[${el.ref}] ${el.kind}: ${el.label}${el.selector !== undefined && el.selector !== '' ? ` {${el.selector}}` : ''}${el.frame === true ? ' (iframe)' : ''}${showCoords ? ` (${el.x},${el.y})` : ''}`);
195
218
  const header = `URL: ${snapshot.url}${snapshot.title !== undefined ? `\nTitle: ${snapshot.title}` : ''}`;
196
219
  const body = lines.length > 0 ? lines.join('\n') : '(no interactive elements found)';
197
220
  const tail = snapshot.truncated === true ? '\n(snapshot truncated)' : '';
@@ -203,6 +226,25 @@ function formatSnapshot(snapshot, options = {}) {
203
226
  /** Register all browser tools with `ctx.tools`. */
204
227
  export function apply(ctx, config = {}) {
205
228
  const timeoutMs = config.timeoutMs ?? 60_000;
229
+ /**
230
+ * Longest wait `browser_wait` may poll for, leaving room for the tool call itself to
231
+ * finish and report.
232
+ *
233
+ * `browser_wait` promises a VERDICT — `{ready:false, reason}` — and the enclosing tool
234
+ * call aborts at `timeoutMs`. A caller-supplied budget longer than that aborted the call
235
+ * instead, so the documented verdict never arrived. Clamping to just inside the budget is
236
+ * what keeps the promise: the polling stops, the reason is returned.
237
+ */
238
+ const maxWaitMs = Math.max(250, timeoutMs - 5_000);
239
+ /**
240
+ * Most fields `browser_fill` accepts in one batch.
241
+ *
242
+ * The batch runs as ONE page evaluation, so hundreds of fields do not produce hundreds of
243
+ * per-field verdicts — they produce a single evaluate that exceeds the tool budget and
244
+ * throws, which is the opposite of the documented "per-field failures are reported instead
245
+ * of throwing". Real forms are far smaller than this; anything larger should be split.
246
+ */
247
+ const maxFillFields = 200;
206
248
  // Per-context state: sessions, in-flight opens, and the restriction are
207
249
  // scoped to THIS plugin apply, so parallel contexts never share sessions
208
250
  // or leak restrictions into each other.
@@ -246,6 +288,12 @@ export function apply(ctx, config = {}) {
246
288
  x: { type: 'number', required: true },
247
289
  y: { type: 'number', required: true },
248
290
  frame: { type: 'boolean' },
291
+ // The page-side script computes this for every element and the tool layer
292
+ // passes it through. Without it in the schema, a declared
293
+ // additionalProperties: false rejects the whole result at run time — which is
294
+ // how this was found, on the very first real call, after a static review had
295
+ // called the change verified.
296
+ selector: { type: 'string' },
249
297
  },
250
298
  },
251
299
  },
@@ -281,7 +329,7 @@ export function apply(ctx, config = {}) {
281
329
  return {
282
330
  url: snapshot.url,
283
331
  ...snapshot.title !== undefined ? { title: snapshot.title } : {},
284
- elements: snapshot.elements.map(el => ({ ref: el.ref, kind: el.kind, label: el.label, x: el.x, y: el.y, ...el.frame === true ? { frame: true } : {} })),
332
+ elements: snapshot.elements.map(el => ({ ref: el.ref, kind: el.kind, label: el.label, selector: el.selector, x: el.x, y: el.y, ...el.frame === true ? { frame: true } : {} })),
285
333
  truncated: snapshot.truncated,
286
334
  ...snapshot.challenge !== undefined ? { challenge: snapshot.challenge } : {},
287
335
  };
@@ -314,6 +362,12 @@ export function apply(ctx, config = {}) {
314
362
  x: { type: 'number', required: true },
315
363
  y: { type: 'number', required: true },
316
364
  frame: { type: 'boolean' },
365
+ // The page-side script computes this for every element and the tool layer
366
+ // passes it through. Without it in the schema, a declared
367
+ // additionalProperties: false rejects the whole result at run time — which is
368
+ // how this was found, on the very first real call, after a static review had
369
+ // called the change verified.
370
+ selector: { type: 'string' },
317
371
  },
318
372
  },
319
373
  },
@@ -341,7 +395,7 @@ export function apply(ctx, config = {}) {
341
395
  return {
342
396
  url: snapshot.url,
343
397
  ...snapshot.title !== undefined ? { title: snapshot.title } : {},
344
- elements: snapshot.elements.map(el => ({ ref: el.ref, kind: el.kind, label: el.label, x: el.x, y: el.y, ...el.frame === true ? { frame: true } : {} })),
398
+ elements: snapshot.elements.map(el => ({ ref: el.ref, kind: el.kind, label: el.label, selector: el.selector, x: el.x, y: el.y, ...el.frame === true ? { frame: true } : {} })),
345
399
  truncated: snapshot.truncated,
346
400
  ...snapshot.challenge !== undefined ? { challenge: snapshot.challenge } : {},
347
401
  };
@@ -349,10 +403,10 @@ export function apply(ctx, config = {}) {
349
403
  }));
350
404
  ctx.tools.register(defineTool({
351
405
  name: 'browser_a11y',
352
- description: 'Read the page\'s accessibility tree: every interactive node with its semantic role (button/link/textbox/checkbox/…), accessible name, current value, and states (enabled/disabled/checked/expanded/…), plus coordinates. Prefer this over browser_snapshot to understand a page\'s structure and find the right element: roles and names tell you WHAT each node is, and the coordinates let you drive it with browser_click/browser_type. Penetrates same-origin iframes and shadow roots.',
406
+ description: 'Read the page\'s accessibility tree: every interactive node with its semantic role (button/link/textbox/checkbox/…), accessible name, current value, and states (enabled/disabled/checked/expanded/…), (coordinates only when coords: true). Prefer this over browser_snapshot to understand a page\'s structure and find the right element: roles and names tell you WHAT each node is, and the name is what browser_click matches when you target by text. Penetrates same-origin iframes and shadow roots.',
353
407
  parameters: {
354
408
  includeHidden: { type: 'boolean', description: 'Include hidden elements (default false).' },
355
- maxNodes: { type: 'number', description: 'Maximum nodes (default 500, range 10-5000).' },
409
+ maxNodes: { type: 'number', description: 'Maximum nodes (default 150, range 10-5000).' },
356
410
  coords: { type: 'boolean', description: 'Include each element\'s viewport coordinates. Off by default: a semantic target needs no coordinates, and they are pure noise unless a vision pass is going to click a pixel position.' },
357
411
  },
358
412
  output: {
@@ -381,6 +435,12 @@ export function apply(ctx, config = {}) {
381
435
  x: { type: 'number', required: true },
382
436
  y: { type: 'number', required: true },
383
437
  frame: { type: 'boolean' },
438
+ // The page-side script computes this for every element and the tool layer
439
+ // passes it through. Without it in the schema, a declared
440
+ // additionalProperties: false rejects the whole result at run time — which is
441
+ // how this was found, on the very first real call, after a static review had
442
+ // called the change verified.
443
+ selector: { type: 'string' },
384
444
  },
385
445
  },
386
446
  },
@@ -396,13 +456,22 @@ export function apply(ctx, config = {}) {
396
456
  const showCoords = args.coords === true;
397
457
  const lines = nodes.map(n => {
398
458
  const valuePart = n.value !== undefined && n.value !== null ? ` value="${String(n.value).slice(0, 60)}"` : '';
399
- const indent = ' '.repeat(Math.max(0, Math.min(8, Number(n.depth) || 0)));
459
+ // One space per level, not two: indentation was 19.7% of a measured 38,953-character
460
+ // result, and DOM depth is not a11y depth — deep wrapper divs push it to the cap.
461
+ const indent = ' '.repeat(Math.max(0, Math.min(8, Number(n.depth) || 0)));
400
462
  const coordsPart = showCoords ? ` (${n.x},${n.y})` : '';
401
- const statePart = Array.isArray(n.states) && n.states.length > 0 ? ` states=[${n.states.join(',')}]` : '';
402
- return `${indent}[${n.ref}] ${n.role} "${n.name}"${valuePart}${coordsPart}${statePart}${n.frame === true ? ' (iframe)' : ''}`;
463
+ // Only states that are not the default. `states=[enabled]` was printed on 405 of 500
464
+ // nodes whose state WAS the default — 22% of the output for no information. Absence
465
+ // now means "enabled", which the description states.
466
+ const notableStates = Array.isArray(n.states) ? n.states.filter((state) => state !== 'enabled') : [];
467
+ const statePart = notableStates.length > 0 ? ` states=[${notableStates.join(',')}]` : '';
468
+ // The provider computes this for a node with an id or a name; printing it is what
469
+ // makes the node something the caller can point at instead of guessing at its text.
470
+ const selectorPart = typeof n.selector === 'string' && n.selector !== '' ? ` {${n.selector}}` : '';
471
+ return `${indent}[${n.ref}] ${n.role} "${n.name}"${valuePart}${coordsPart}${statePart}${selectorPart}${n.frame === true ? ' (iframe)' : ''}`;
403
472
  });
404
473
  const header = `URL: ${value.url}${value.title !== undefined ? `\nTitle: ${value.title}` : ''}`;
405
- const hint = showCoords ? '' : '\n(coordinates omitted — pass coords: true to include them, or click by semantic target)';
474
+ const hint = showCoords ? '' : '\n(no coords; pass coords: true)';
406
475
  return [{ type: 'text', text: `${header}\n\n${lines.length > 0 ? lines.join('\n') : '(no accessible interactive nodes)'}${value.truncated === true ? '\n(truncated)' : ''}${hint}` }];
407
476
  },
408
477
  },
@@ -430,6 +499,11 @@ export function apply(ctx, config = {}) {
430
499
  states: [...n.states],
431
500
  depth: n.depth,
432
501
  tag: n.tag,
502
+ // The provider computes this and the renderer prints it, but this mapping is an
503
+ // explicit allow-list rather than a pass-through — so leaving it out here silently
504
+ // dropped the field between the two, and the tool promised a reference it never
505
+ // sent. The snapshot mapping lists its selector; this one did not.
506
+ selector: n.selector,
433
507
  x: n.x,
434
508
  y: n.y,
435
509
  ...n.frame === true ? { frame: true } : {},
@@ -504,7 +578,9 @@ export function apply(ctx, config = {}) {
504
578
  throw new Error('tool-browser: browser service unavailable');
505
579
  const session = await ensureSession(browser, state, taskKey(exec), agentOf(exec));
506
580
  const result = await browser.waitFor(session, {
507
- ...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {},
581
+ // Capped so an over-budget argument returns the documented verdict (see maxWaitMs)
582
+ // rather than aborting the whole tool call.
583
+ ...args.timeoutMs !== undefined ? { timeoutMs: Math.min(args.timeoutMs, maxWaitMs) } : {},
508
584
  ...args.url !== undefined ? { url: args.url } : {},
509
585
  ...args.selector !== undefined ? { selector: args.selector } : {},
510
586
  }, exec.signal);
@@ -528,7 +604,19 @@ export function apply(ctx, config = {}) {
528
604
  exception: { type: 'string' },
529
605
  },
530
606
  },
531
- render: (_args, value) => [{ type: 'text', text: value.ok ? `Result: ${String(value.value)}` : `Exception: ${value.exception}` }],
607
+ // A return value is whatever the page produced, and `document.body.outerHTML` measured
608
+ // 157,350 characters — ~39-52k tokens from one call. Bounded, with the cut stated so the
609
+ // caller knows to narrow the expression rather than the value being silently short.
610
+ render: (_args, value) => {
611
+ if (!value.ok)
612
+ return [{ type: 'text', text: `Exception: ${value.exception}` }];
613
+ const rendered = String(value.value ?? '');
614
+ const cap = 50_000;
615
+ const text = rendered.length > cap
616
+ ? `Result: ${rendered.slice(0, cap)}\n(result truncated at ${cap} of ${rendered.length} characters — return a smaller value, or write it with browser_execute + a variable)`
617
+ : `Result: ${rendered}`;
618
+ return [{ type: 'text', text }];
619
+ },
532
620
  },
533
621
  timeoutMs,
534
622
  isConcurrencySafe: () => false, // page JS can be stateful
@@ -568,7 +656,7 @@ export function apply(ctx, config = {}) {
568
656
  truncated: { type: 'boolean', required: true },
569
657
  },
570
658
  },
571
- render: (_args, value) => [{ type: 'text', text: value.content + (value.truncated ? '\n(truncated)' : '') }],
659
+ render: (_args, value) => [{ type: 'text', text: value.content + (value.truncated ? `\n(content truncated — narrow it with selector= or raise maxChars)` : '') }],
572
660
  },
573
661
  timeoutMs,
574
662
  isConcurrencySafe: () => true,
@@ -614,7 +702,10 @@ export function apply(ctx, config = {}) {
614
702
  },
615
703
  render: (_args, value) => [{
616
704
  type: 'text',
617
- text: `${value.count} item(s):\n${JSON.stringify(value.items, null, 2)}`,
705
+ // Compact, unlike the pretty-printed form this used to emit: two-space indentation
706
+ // carries no information a model can use and cost 13.7% of a measured 39,559-character
707
+ // result. The count leads so the caller can tell a short list from a capped one.
708
+ text: `${value.count} item(s):\n${JSON.stringify(value.items)}`,
618
709
  }],
619
710
  },
620
711
  timeoutMs,
@@ -670,12 +761,13 @@ export function apply(ctx, config = {}) {
670
761
  if (target !== undefined && typeof target.value === 'string' && target.value === '') {
671
762
  throw new Error('target.value is empty; pass a css selector, visible text or XPath, or omit target entirely to use x/y');
672
763
  }
764
+ const index = targetIndex(target);
673
765
  if (target !== undefined && typeof target.value === 'string' && target.value !== '') {
674
766
  await browser.click(session, {
675
767
  target: {
676
768
  by: (target.by === 'text' || target.by === 'xpath' ? target.by : 'css'),
677
769
  value: target.value,
678
- ...target.index !== undefined ? { index: target.index } : {},
770
+ ...index !== undefined ? { index } : {},
679
771
  },
680
772
  }, exec.signal);
681
773
  }
@@ -723,13 +815,14 @@ export function apply(ctx, config = {}) {
723
815
  if (target !== undefined && typeof target.value === 'string' && target.value === '') {
724
816
  throw new Error('target.value is empty; pass a css selector, visible text or XPath, or omit target entirely to use x/y');
725
817
  }
818
+ const index = targetIndex(target);
726
819
  if (target !== undefined && typeof target.value === 'string' && target.value !== '') {
727
820
  await browser.type(session, {
728
821
  text: args.text,
729
822
  target: {
730
823
  by: (target.by === 'text' || target.by === 'xpath' ? target.by : 'css'),
731
824
  value: target.value,
732
- ...target.index !== undefined ? { index: target.index } : {},
825
+ ...index !== undefined ? { index } : {},
733
826
  },
734
827
  }, exec.signal);
735
828
  }
@@ -918,7 +1011,11 @@ export function apply(ctx, config = {}) {
918
1011
  if (browser === undefined)
919
1012
  throw new Error('tool-browser: browser service unavailable');
920
1013
  const session = await ensureSession(browser, state, taskKey(exec), agentOf(exec));
921
- const fields = (args.fields ?? []).map((f) => {
1014
+ const requested = args.fields ?? [];
1015
+ if (requested.length > maxFillFields) {
1016
+ throw new Error(`browser_fill: ${requested.length} fields in one call exceeds the ${maxFillFields}-field limit; split it into several calls so each one can report per-field results instead of timing out as a batch`);
1017
+ }
1018
+ const fields = requested.map((f) => {
922
1019
  // A missing value is a caller mistake, not a request to clear the field. Turning
923
1020
  // it into '' silently unchecks a checkbox or empties a text input while the tool
924
1021
  // still reports success, so it fails loudly. The schema marks value required, but
@@ -1149,7 +1246,7 @@ export function apply(ctx, config = {}) {
1149
1246
  }));
1150
1247
  ctx.tools.register(defineTool({
1151
1248
  name: 'browser_screenshot',
1152
- description: 'Capture the current shared-browser page as a screenshot (PNG default, JPEG optional). This is for models that can read images: layout checks, charts, designs, CAPTCHAs, or locating an element by eye before clicking its coordinates. A model without image input gains nothing from it — browser_snapshot, browser_a11y and browser_content carry the same page as text, and browser_scrape extracts structured data. Supports full-page capture, save-to-file, JPEG encoding, and downscaling (maxWidth/maxHeight) to cut vision-tool token cost. JPEG needs a browser whose CDP encoder works — the self-hosted browser and an installed Chrome/Edge; the desktop sidebar falls back to PNG because the Electron CDP JPEG encoder hangs. Downscaling works on every carrier.',
1249
+ description: 'Capture the current shared-browser page as a screenshot (PNG default, JPEG optional). This is for models that can read images: layout checks, charts, designs, CAPTCHAs, or locating an element by eye before clicking its coordinates. A model without image input gains nothing from it — browser_snapshot, browser_a11y and browser_content carry the same page as text, and browser_scrape extracts structured data. Supports full-page capture, save-to-file, JPEG encoding, and downscaling (maxWidth/maxHeight) to cut vision-tool token cost. JPEG needs a browser whose CDP encoder works — the self-hosted browser and an installed Chrome/Edge; some carriers (the desktop sidebar) return PNG instead. Downscaling works on every carrier.',
1153
1250
  parameters: {
1154
1251
  fullPage: { type: 'boolean', description: 'Capture the full scrollable page instead of the viewport (default false).' },
1155
1252
  savePath: { type: 'string', description: 'Absolute file path to also save the image to (e.g. for read_image vision location). Must resolve inside the configured downloadDir (default: the system Downloads folder, localized names such as ~/下载 included); an existing file is never overwritten.' },
@@ -1163,7 +1260,9 @@ export function apply(ctx, config = {}) {
1163
1260
  type: 'object',
1164
1261
  additionalProperties: false,
1165
1262
  properties: {
1166
- dataUrl: { type: 'string', required: true, description: 'Base64 PNG data URL of the screenshot.' },
1263
+ dataUrl: { type: 'string', required: true, description: 'Base64 data URL of the screenshot (PNG, or JPEG when the carrier uses CDP JPEG).' },
1264
+ width: { type: 'number', description: 'Pixel width of the returned image, when the capture was measured (present with maxWidth/maxHeight).' },
1265
+ height: { type: 'number', description: 'Pixel height of the returned image, when the capture was measured.' },
1167
1266
  path: { type: 'string', description: 'The file path the screenshot was saved to, when savePath was given.' },
1168
1267
  },
1169
1268
  },
@@ -1300,7 +1399,9 @@ export function apply(ctx, config = {}) {
1300
1399
  ctx.tools.register(defineTool({
1301
1400
  name: 'browser_history',
1302
1401
  description: 'List the shared browser session\'s recorded operation history (navigate/execute/click/type), newest last, with per-step success/error. Use to understand what the agent did and to pick a step to replay.',
1303
- parameters: {},
1402
+ parameters: {
1403
+ verbose: { type: 'boolean', description: "Include each operation's parameters (default false). They are what you just sent, so they are omitted unless asked for." },
1404
+ },
1304
1405
  output: {
1305
1406
  schema: {
1306
1407
  type: 'object',
@@ -1324,18 +1425,21 @@ export function apply(ctx, config = {}) {
1324
1425
  },
1325
1426
  },
1326
1427
  },
1327
- render: (_args, value) => {
1428
+ render: (args, value) => {
1328
1429
  const entries = value.entries;
1329
1430
  if (entries.length === 0)
1330
1431
  return [{ type: 'text', text: '(no recorded operations yet)' }];
1331
- return [{
1332
- type: 'text',
1333
- text: entries.map(e => {
1334
- const rawParams = JSON.stringify(e.params);
1335
- const shownParams = rawParams.length > 300 ? rawParams.slice(0, 300) + '…' : rawParams;
1336
- return `#${e.seq} ${e.action} ${e.ok ? 'ok' : 'FAIL'} ${shownParams}${e.result !== undefined ? ` -> ${e.result}` : ''}${e.error !== undefined ? ` !! ${e.error}` : ''}`;
1337
- }).join('\n'),
1338
- }];
1432
+ // The parameters are what the caller itself just sent, so echoing them back is cost
1433
+ // without information — measured at 18,162 characters for 200 operations, of which the
1434
+ // per-entry params dominated. Only the tail is shown by default: the recent steps are
1435
+ // what a caller reasons about, and the count leads so the tail is not mistaken for all.
1436
+ const recent = entries.slice(-20);
1437
+ const omitted = entries.length - recent.length;
1438
+ const lines = recent.map(e => `#${e.seq} ${e.action} ${e.ok ? 'ok' : 'FAIL'}${e.result !== undefined ? ` -> ${e.result}` : ''}${e.error !== undefined ? ` !! ${e.error}` : ''}${args.verbose === true ? ` ${JSON.stringify(e.params)}` : ''}`);
1439
+ const header = omitted > 0
1440
+ ? `${entries.length} operations recorded; showing the last ${recent.length} (${args.verbose === true ? '' : ' (pass verbose: true for parameters)'}):`
1441
+ : `${entries.length} operation(s):`;
1442
+ return [{ type: 'text', text: `${header}\n${lines.join('\n')}` }];
1339
1443
  },
1340
1444
  },
1341
1445
  timeoutMs,
@@ -1425,15 +1529,20 @@ export function apply(ctx, config = {}) {
1425
1529
  },
1426
1530
  render: (_args, value) => {
1427
1531
  const entries = value.entries;
1532
+ let lastSession;
1428
1533
  if (entries.length === 0) {
1429
1534
  return [{ type: 'text', text: '(no recorded visits — nothing has been browsed yet, or history recording is turned off in settings)' }];
1430
1535
  }
1431
1536
  return [{
1432
1537
  type: 'text',
1433
1538
  text: entries.map(entry => {
1434
- const when = new Date(entry.at).toISOString().replace('T', ' ').slice(0, 19);
1539
+ const when = new Date(entry.at).toISOString().replace('T', ' ').slice(0, 16);
1435
1540
  const title = entry.title !== undefined ? `${entry.title} — ` : '';
1436
- return `${when} ${title}${entry.url}${entry.session !== undefined ? ` [${entry.session}]` : ''}`;
1541
+ const tag = entry.session !== undefined && entry.session !== lastSession
1542
+ ? ` [session ${entry.session.slice(0, 8)}]`
1543
+ : '';
1544
+ lastSession = entry.session;
1545
+ return `${when} ${title}${entry.url}${tag}`;
1437
1546
  }).join('\n'),
1438
1547
  }];
1439
1548
  },
@@ -1584,7 +1693,7 @@ export function apply(ctx, config = {}) {
1584
1693
  }));
1585
1694
  ctx.tools.register(defineTool({
1586
1695
  name: 'browser_restrict',
1587
- description: 'Restrict which browser actions are allowed, to prevent stray clicks/navigation. Pass a list of browser tool names (e.g. ["browser_snapshot","browser_content","browser_click"]) — any other browser_* call is refused. Pass an empty list or omit to lift the restriction. Read-only tools (snapshot/content/screenshot/session) are never blocked. IMPORTANT: this is a SOFT guardrail against accidental actions, NOT a security boundary — you (the model) can lift it yourself with an empty list.',
1696
+ description: 'Restrict which browser actions are allowed, to prevent stray clicks/navigation. Pass a list of browser tool names (e.g. ["browser_snapshot","browser_content","browser_click"]) — any other browser_* call is refused. Pass an empty list or omit to lift the restriction. Never blocked, whatever the list says: the observing tools (snapshot/a11y/content/scrape/screenshot/get_value/wait/challenge/list_tabs/session/history/visited) and the three that lift a restriction or recover from a bad state (restrict/reset/reset_session) — so a restriction can always be read around and undone. IMPORTANT: this is a SOFT guardrail against accidental actions, NOT a security boundary — you (the model) can lift it yourself with an empty list.',
1588
1697
  parameters: {
1589
1698
  allowed: {
1590
1699
  type: 'array',
@@ -1619,7 +1728,7 @@ export function apply(ctx, config = {}) {
1619
1728
  }));
1620
1729
  ctx.tools.register(defineTool({
1621
1730
  name: 'browser_auth',
1622
- description: 'Export or restore the browser session\'s cookies (login state). Use "flush" to get a JSON cookie list (save it to a private file to persist logins), or "restore" with that list to put logins back (e.g. after the browser host restarted). Available on the self-hosted browser. Exported cookies are LIVE CREDENTIALS: treat them as secrets — do not echo them into the conversation, keep them out of logs, and store the list in a private file.',
1731
+ description: 'Export or restore the browser session\'s cookies (login state). Use "flush" to get a JSON cookie list (save it to a private file to persist logins), or "restore" with that list to put logins back (e.g. after the browser host restarted). Available on all three carriers: the self-hosted browser reads its own session, while the desktop sidebar and an installed Chrome/Edge go through CDP. Refused entirely when the credential switch is off. Exported cookies are LIVE CREDENTIALS: treat them as secrets — do not echo them into the conversation, keep them out of logs, and store the list in a private file.',
1623
1732
  parameters: {
1624
1733
  action: { type: 'string', required: true, enum: ['flush', 'restore'], description: 'flush = export cookies; restore = import cookies.' },
1625
1734
  cookies: { type: 'array', items: { type: 'object', additionalProperties: true }, description: 'Cookie list to restore (required when action=restore).' },
@@ -1651,7 +1760,7 @@ export function apply(ctx, config = {}) {
1651
1760
  // empty call returned {restored: 0} and rendered 'Restored 0 cookies.', which
1652
1761
  // reads as success and would leave the caller believing the login was restored.
1653
1762
  if (!Array.isArray(args.cookies) || args.cookies.length === 0) {
1654
- throw new Error('browser_auth: action "restore" needs a non-empty "cookies" array (get one from action "export" on the same profile)');
1763
+ throw new Error('browser_auth: action "restore" needs a non-empty "cookies" array (get one from action "flush" on the same profile)');
1655
1764
  }
1656
1765
  const list = args.cookies;
1657
1766
  const restored = await browser.restoreAuth(session, list);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-builtin-browser",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Shared real browser plugin for DeepSeek Harness: install-and-use — a visible native browser the human can take over, driven by the agent over CDP. DOM-level interaction (React/Vue safe), per-task session isolation, cookie persistence (browser_auth), CAPTCHA detection, batch form fill (browser_fill), authenticated download, history/replay, action restriction",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -40,7 +40,8 @@
40
40
  "cordis.patch.yml",
41
41
  "README.md",
42
42
  "README.en.md",
43
- "LICENSE"
43
+ "LICENSE",
44
+ "scripts"
44
45
  ],
45
46
  "scripts": {
46
47
  "build": "tsc -p tsconfig.json",
@@ -102,7 +103,8 @@
102
103
  "0.1.1-rc.1": "compatible",
103
104
  "0.1.1-rc.2": "compatible",
104
105
  "0.2.0-rc.1": "compatible",
105
- "0.2.0-rc.2": "compatible"
106
+ "0.2.0-rc.2": "compatible",
107
+ "0.2.1-alpha.1": "compatible"
106
108
  }
107
109
  }
108
110
  }
@@ -0,0 +1,195 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Host-compatibility probe: does THIS build of the plugin run on the DSH in front of it?
4
+ *
5
+ * A version range in package.json is a claim, not evidence. This assembles a host out of the
6
+ * real DSH packages installed in a profile, loads the plugin's compiled output, registers its
7
+ * tools through the host's own `defineTool`, and calls two of them over the plugin's provider
8
+ * seam. It is the check that answers "did DSH 0.2.x break us?" without booting a browser.
9
+ *
10
+ * Usage:
11
+ * node scripts/verify-host-compat.mjs # probe the profile named by DSH_PROFILE
12
+ * DSH_PROFILE=desktop node scripts/verify-host-compat.mjs
13
+ * DSH_HOME=/path/to/.dsh node scripts/verify-host-compat.mjs
14
+ *
15
+ * Resolution: the profile's own package.json is the module root, so `@deepseek-ai/*` resolves
16
+ * exactly as it does at runtime (links included). With no usable profile the probe reports
17
+ * that it could not run and exits 0 — an unprobeable host is not a compatibility failure, and
18
+ * making CI red for it would be reporting the wrong thing.
19
+ *
20
+ * What it does NOT cover: the browser actually launching (no Electron here), and the client
21
+ * panel rendering in a real session.
22
+ */
23
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
24
+ import { createRequire } from 'node:module'
25
+ import { join } from 'node:path'
26
+ import { pathToFileURL } from 'node:url'
27
+
28
+ const REPO = new URL('..', import.meta.url)
29
+ const failures = []
30
+
31
+ const ok = (label, condition, detail = '') => {
32
+ console.log(` ${condition ? 'OK ' : 'FAIL'} ${label}${detail === '' ? '' : ` — ${detail}`}`)
33
+ if (!condition) failures.push(label)
34
+ }
35
+
36
+ /** The profile whose node_modules resolve the host packages, or undefined. */
37
+ function findProfileRoot() {
38
+ const home = process.env.DSH_HOME ?? join(process.env.USERPROFILE ?? process.env.HOME ?? '', '.dsh')
39
+ const profiles = join(home, 'profiles')
40
+ if (!existsSync(profiles)) return undefined
41
+ const named = process.env.DSH_PROFILE
42
+ const candidates = named !== undefined && named !== ''
43
+ ? [named]
44
+ : readdirSync(profiles).filter(entry => existsSync(join(profiles, entry, 'package.json')))
45
+ for (const candidate of candidates) {
46
+ const manifest = join(profiles, candidate, 'package.json')
47
+ if (existsSync(manifest)) return { name: candidate, manifest }
48
+ }
49
+ return undefined
50
+ }
51
+
52
+ const profile = findProfileRoot()
53
+ if (profile === undefined) {
54
+ console.log('No DSH profile found (looked under $DSH_HOME/profiles).')
55
+ console.log('Set DSH_HOME or DSH_PROFILE to point at one; nothing to verify.')
56
+ process.exit(0)
57
+ }
58
+
59
+ const require = createRequire(profile.manifest)
60
+ const load = async (spec) => import(pathToFileURL(require.resolve(spec)).href)
61
+
62
+ console.log(`Host: profile "${profile.name}" at ${profile.manifest}`)
63
+ let hostVersion = 'unknown'
64
+ try {
65
+ hostVersion = JSON.parse(readFileSync(require.resolve('@deepseek-ai/dsh-system-prompt/package.json'), 'utf8')).version
66
+ } catch { /* reported as unknown; the probe below is what matters */ }
67
+ console.log(` @deepseek-ai/dsh-system-prompt = ${hostVersion}`)
68
+ console.log('')
69
+
70
+ console.log('=== 1. the host API surface this plugin imports ===')
71
+ let cordis
72
+ let dshTools
73
+ let dshLlm
74
+ let schemastery
75
+ try {
76
+ cordis = await load('@deepseek-ai/cordis')
77
+ dshTools = await load('@deepseek-ai/dsh-tools')
78
+ dshLlm = await load('@deepseek-ai/dsh-llm')
79
+ schemastery = await load('@deepseek-ai/schemastery')
80
+ } catch (error) {
81
+ console.log(` FAIL a host package could not be resolved: ${String(error && error.message)}`)
82
+ process.exit(1)
83
+ }
84
+ const z = schemastery.default ?? schemastery
85
+
86
+ ok('@deepseek-ai/cordis exports Context', typeof cordis.Context === 'function')
87
+ ok('@deepseek-ai/cordis exports Service', typeof cordis.Service === 'function')
88
+ ok('@deepseek-ai/dsh-tools exports defineTool', typeof dshTools.defineTool === 'function')
89
+ ok('@deepseek-ai/dsh-llm exports HarnessError', typeof dshLlm.HarnessError === 'function')
90
+ ok('@deepseek-ai/schemastery default is the schema builder', typeof z?.string === 'function')
91
+
92
+ console.log('')
93
+ console.log('=== 2. the plugin loads against a real cordis context ===')
94
+ const root = new cordis.Context()
95
+
96
+ // Stub services. DSH's own tools/systemPrompt implementations are not what is under test and
97
+ // standing them up would mean booting a profile; only their SHAPE matters here, and it is
98
+ // taken from the real ones: `ctx.tools.register(definition)`, `ctx.systemPrompt.section(spec)`.
99
+ const registered = new Map()
100
+ class StubTools extends cordis.Service {
101
+ constructor(ctx) { super(ctx, 'tools') }
102
+ register(definition) { registered.set(definition.name, definition); return () => {} }
103
+ }
104
+ class StubSystemPrompt extends cordis.Service {
105
+ constructor(ctx) { super(ctx, 'systemPrompt'); this.sections = [] }
106
+ section(spec) { this.sections.push(spec); return () => {} }
107
+ }
108
+ new StubTools(root)
109
+ const prompts = new StubSystemPrompt(root)
110
+
111
+ try {
112
+ const runtimeModule = await import(new URL('lib/browser/runtime.js', REPO).href)
113
+ ok('the plugin ships a browser seam service', typeof runtimeModule.default === 'function')
114
+ new runtimeModule.default(root)
115
+ ok('the seam registered itself as ctx.browser', root.get('browser') !== undefined)
116
+
117
+ const layer = await import(new URL('lib/tool-browser/index.js', REPO).href)
118
+ ok('the tool layer exports an apply()', typeof layer.apply === 'function')
119
+ layer.apply(root, {})
120
+ ok('tools registered through the host\'s own defineTool', registered.size > 30, `${registered.size} tools`)
121
+ ok('the system prompt section was declared', prompts.sections.length === 1)
122
+ } catch (error) {
123
+ ok('the plugin loads and registers', false, String(error && error.stack).split('\n').slice(0, 2).join(' | '))
124
+ }
125
+
126
+ console.log('')
127
+ console.log('=== 3. the definitions the host will consume ===')
128
+ // `defineTool` already accepted every spec the plugin handed it — it throws on a shape it
129
+ // cannot compile, and the tools came back — so what is left is the stored definition: a JSON
130
+ // Schema the host can show a model, plus the output contract.
131
+ const SCALAR_TYPES = new Set(['string', 'number', 'integer', 'boolean', 'array', 'object', 'null'])
132
+ const schemaFailures = []
133
+ for (const [name, definition] of registered) {
134
+ if (typeof definition.execute !== 'function') schemaFailures.push(`${name}: no execute`)
135
+ if (definition.output?.schema === undefined) schemaFailures.push(`${name}: no output.schema`)
136
+ if (typeof definition.output?.render !== 'function') schemaFailures.push(`${name}: no output.render`)
137
+ if (typeof definition.description !== 'string' || definition.description === '') schemaFailures.push(`${name}: no description`)
138
+ const parameters = definition.parameters
139
+ if (typeof parameters !== 'object' || parameters === null) {
140
+ schemaFailures.push(`${name}: parameters is not a schema object`)
141
+ continue
142
+ }
143
+ if (parameters.type !== 'object') schemaFailures.push(`${name}: parameters.type is ${String(parameters.type)}`)
144
+ for (const [field, spec] of Object.entries(parameters.properties ?? {})) {
145
+ if (typeof spec?.type !== 'string' || !SCALAR_TYPES.has(spec.type)) schemaFailures.push(`${name}.${field}: type ${String(spec?.type)}`)
146
+ }
147
+ }
148
+ ok('every tool definition carries a usable schema', schemaFailures.length === 0, schemaFailures.slice(0, 4).join(' | '))
149
+
150
+ console.log('')
151
+ console.log('=== 4. a tool runs, over the plugin\'s own provider seam ===')
152
+ const fakeSession = 'browser:00000000-0000-4000-8000-000000000001'
153
+ try {
154
+ root.get('browser').registerBrowserProvider({
155
+ id: 'verify-host-compat',
156
+ available: () => true,
157
+ open: async () => fakeSession,
158
+ listTabs: async () => [{ id: 'tab:1', title: 'verify', url: 'about:blank', active: true }],
159
+ a11y: async () => ({
160
+ url: 'about:blank',
161
+ count: 1,
162
+ truncated: false,
163
+ nodes: [{ ref: 1, role: 'button', name: 'Probe', states: ['enabled'], depth: 0, tag: 'button', selector: '#probe', x: 1, y: 2 }],
164
+ }),
165
+ })
166
+ } catch (error) {
167
+ ok('a provider can register on the seam', false, String(error && error.message))
168
+ }
169
+
170
+ const exec = { agent: { id: 'verify-host-compat' } }
171
+ try {
172
+ const value = await registered.get('browser_session').execute({}, exec)
173
+ ok('browser_session ran', value !== undefined, JSON.stringify(value).slice(0, 100))
174
+ } catch (error) {
175
+ ok('browser_session ran', false, String(error && error.message))
176
+ }
177
+
178
+ try {
179
+ const tool = registered.get('browser_a11y')
180
+ const value = await tool.execute({}, exec)
181
+ const text = JSON.stringify(tool.output.render({}, value))
182
+ ok('browser_a11y ran and rendered', value?.nodes?.length === 1, '')
183
+ ok('its renderer emits the element selector', text.includes('#probe'), text.slice(0, 120))
184
+ } catch (error) {
185
+ ok('browser_a11y ran', false, String(error && error.message))
186
+ }
187
+
188
+ console.log('')
189
+ if (failures.length === 0) {
190
+ console.log(`VERDICT: this build runs on the host above (${hostVersion}).`)
191
+ } else {
192
+ console.log(`VERDICT: ${failures.length} failure(s) — this build does NOT match the host above:`)
193
+ for (const failure of failures) console.log(` - ${failure}`)
194
+ process.exitCode = 1
195
+ }