dsh-builtin-browser 0.1.6 → 0.1.7

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.
@@ -1,13 +1,41 @@
1
1
  /**
2
2
  * Electron-backed browser provider: `WebContentsView` sessions driven over
3
- * `webContents.debugger` (CDP). The provider itself does not import Electron 鈥? * it operates through the {@link ElectronBrowserViewHost} seam, which the
3
+ * `webContents.debugger` (CDP). The provider itself does not import Electron — it operates through the {@link ElectronBrowserViewHost} seam, which the
4
4
  * desktop shell implements with real Electron objects. That keeps this
5
5
  * package testable under plain Node and leaves the Electron dependency to the
6
6
  * shell that owns the `BrowserWindow`.
7
7
  * @module dsh-browser/browser-electron
8
8
  */
9
9
  import { randomUUID } from 'node:crypto';
10
+ import { writeFileSync } from 'node:fs';
10
11
  import { BrowserError } from "../browser/types.js";
12
+ /**
13
+ * Page-context human-verification (CAPTCHA / bot-detection) detection. Runs
14
+ * inside the page; returns `{ blocked, kind?, reason? }`. Marker-based and
15
+ * best-effort: checks for Cloudflare's interstitial, hCaptcha, reCAPTCHA,
16
+ * Turnstile, and generic challenge wording.
17
+ */
18
+ const CHALLENGE_DETECT_EXPRESSION = `(() => {
19
+ const title = (document.title || '').trim()
20
+ const bodyText = (document.body && document.body.innerText || '').slice(0, 4000)
21
+ const lower = (title + '\\n' + bodyText).toLowerCase()
22
+ const frameSrcs = [...document.querySelectorAll('iframe')].map(f => f.src || '').join(' ')
23
+ const framesLower = frameSrcs.toLowerCase()
24
+ const hasCfInterstitial = /just a moment|checking your browser|attention required|cf_chl|challenge-platform/i.test(lower)
25
+ || !!document.querySelector('#challenge-running, #challenge-stage, #cf-chl-container')
26
+ const hasHCaptcha = !!window.hcaptcha || !!document.querySelector('.h-captcha') || /hcaptcha\\.com/i.test(framesLower)
27
+ const hasRecaptcha = !!window.grecaptcha || !!document.querySelector('.g-recaptcha') || /recaptcha\\/api|google\\.com\\/recaptcha/i.test(framesLower)
28
+ const hasTurnstile = !!window.turnstile || /challenges\\.cloudflare\\.com/i.test(framesLower) || /turnstile/i.test(lower)
29
+ const verifyWording = /verify you are human|verify you are not a robot|\\u4eba\\u673a\\u9a8c\\u8bc1|\\u5b89\\u5168\\u9a8c\\u8bc1|enable javascript and cookies|\\u8bf7.*\\u9a8c\\u8bc1/i.test(lower)
30
+ if (hasCfInterstitial) return { blocked: true, kind: 'cloudflare', reason: 'Cloudflare "Just a moment" interstitial' }
31
+ if (hasHCaptcha) return { blocked: true, kind: 'hcaptcha', reason: 'hCaptcha verification' }
32
+ if (hasRecaptcha) return { blocked: true, kind: 'recaptcha', reason: 'Google reCAPTCHA verification' }
33
+ if (hasTurnstile) return { blocked: true, kind: 'turnstile', reason: 'Cloudflare Turnstile verification' }
34
+ if (verifyWording && /challenge|captcha|verification|security check|access denied|blocked|\\u9a8c\\u8bc1/i.test(lower)) {
35
+ return { blocked: true, kind: 'generic', reason: 'Human-verification challenge' }
36
+ }
37
+ return { blocked: false }
38
+ })()`;
11
39
  /** Stable provider id registered with `ctx.browser`. */
12
40
  export const ELECTRON_BROWSER_PROVIDER_ID = 'electron';
13
41
  /** CDP method for a full-page screenshot capture. */
@@ -43,20 +71,16 @@ export class ElectronBrowserProvider {
43
71
  return true;
44
72
  }
45
73
  /**
46
- * Get the shared browser session: the one the host already owns, or a new
47
- * one on first use. Shared-browser semantics (Cherry Studio's
48
- * `getOrCreateWindow` pattern): the host's startup view and every later
49
- * agent call must land on the SAME session and view, so opening again
50
- * returns the existing session instead of creating a second view the human
51
- * cannot see. Closing the session resets this; the next open starts fresh.
74
+ * Open a NEW browser session with its own view. Every call mints a fresh
75
+ * session id and backing view; per-task reuse is owned by the caller (the
76
+ * tool layer caches one session per DSH task). Sessions are isolated from
77
+ * each other: each keeps its own tabs, active tab, and history, and only
78
+ * the active tab of a session is made visible.
52
79
  */
53
80
  open() {
54
- const [existing] = this.sessions.keys();
55
- if (existing !== undefined)
56
- return Promise.resolve(existing);
57
81
  const handle = this.host.createView();
58
82
  const id = `browser:${randomUUID()}`;
59
- this.sessions.set(id, { id, tabs: [{ id: `tab:${randomUUID()}`, handle }], activeIndex: 0 });
83
+ this.sessions.set(id, { id, tabs: [{ id: `tab:${randomUUID()}`, handle }], activeIndex: 0, history: [] });
60
84
  return Promise.resolve(id);
61
85
  }
62
86
  /** Open a URL in the active tab (default) or a new tab. */
@@ -134,32 +158,44 @@ export class ElectronBrowserProvider {
134
158
  }
135
159
  /** Navigate the active tab's view to a URL, honoring HTTP(S)-only admission. */
136
160
  async navigate(session, request, signal) {
137
- const { handle } = this.activeTab(this.session(session));
161
+ const s = this.session(session);
162
+ const { handle } = this.activeTab(s);
138
163
  const url = request.url;
139
- if (this.httpOnly) {
140
- let parsed;
141
- try {
142
- parsed = new URL(url);
143
- }
144
- catch {
145
- throw new BrowserError(`browser: refusing navigation to unparseable URL "${url}"`, 'BROWSER_NAVIGATION_BLOCKED');
164
+ try {
165
+ if (this.httpOnly) {
166
+ let parsed;
167
+ try {
168
+ parsed = new URL(url);
169
+ }
170
+ catch {
171
+ throw new BrowserError(`browser: refusing navigation to unparseable URL "${url}"`, 'BROWSER_NAVIGATION_BLOCKED');
172
+ }
173
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
174
+ throw new BrowserError(`browser: refusing navigation to non-HTTP(S) URL "${url}"`, 'BROWSER_NAVIGATION_BLOCKED');
175
+ }
146
176
  }
147
- if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
148
- throw new BrowserError(`browser: refusing navigation to non-HTTP(S) URL "${url}"`, 'BROWSER_NAVIGATION_BLOCKED');
177
+ signal?.throwIfAborted();
178
+ const result = await handle.sendCommand(CDP_PAGE_NAVIGATE, { url });
179
+ // Page.navigate resolves even when the navigation fails; surface the
180
+ // failure instead of leaving a silent white screen.
181
+ const errorText = result.errorText;
182
+ if (typeof errorText === 'string' && errorText !== '') {
183
+ this.record(s, 'navigate', { url }, false, { error: errorText });
184
+ throw new BrowserError(`browser: navigation to "${url}" failed: ${errorText}`, 'BROWSER_NAVIGATION_FAILED');
149
185
  }
186
+ this.record(s, 'navigate', { url }, true);
150
187
  }
151
- signal?.throwIfAborted();
152
- const result = await handle.sendCommand(CDP_PAGE_NAVIGATE, { url });
153
- // Page.navigate resolves even when the navigation fails; surface the
154
- // failure instead of leaving a silent white screen.
155
- const errorText = result.errorText;
156
- if (typeof errorText === 'string' && errorText !== '') {
157
- throw new BrowserError(`browser: navigation to "${url}" failed: ${errorText}`, 'BROWSER_NAVIGATION_FAILED');
188
+ catch (error) {
189
+ if (!(error instanceof BrowserError && error.code === 'BROWSER_NAVIGATION_BLOCKED')) {
190
+ this.record(s, 'navigate', { url }, false, { error: String(error) });
191
+ }
192
+ throw error;
158
193
  }
159
194
  }
160
195
  /** Execute JS in the active tab's page context. */
161
196
  async execute(session, request, signal) {
162
- const { handle } = this.activeTab(this.session(session));
197
+ const s = this.session(session);
198
+ const { handle } = this.activeTab(s);
163
199
  signal?.throwIfAborted();
164
200
  try {
165
201
  // Wrap the script in a Function so `return` statements are legal and
@@ -189,9 +225,13 @@ export class ElectronBrowserProvider {
189
225
  }), timeoutMs, signal, `browser: execute timed out after ${timeoutMs}ms`);
190
226
  if (result.exceptionDetails !== undefined) {
191
227
  const detail = result.exceptionDetails;
192
- return { ok: false, exception: detail.exception?.description ?? detail.text ?? 'unknown exception' };
228
+ const exception = detail.exception?.description ?? detail.text ?? 'unknown exception';
229
+ this.record(s, 'execute', { script: request.script }, false, { error: exception });
230
+ return { ok: false, exception };
193
231
  }
194
- return { ok: true, value: result.result?.value ?? null };
232
+ const value = result.result?.value ?? null;
233
+ this.record(s, 'execute', { script: request.script }, true, { result: typeof value === 'string' ? value.slice(0, 500) : JSON.stringify(value).slice(0, 500) });
234
+ return { ok: true, value };
195
235
  }
196
236
  catch (error) {
197
237
  throw new BrowserError(`browser: execute failed: ${String(error)}`, 'BROWSER_EXECUTE_FAILED', { cause: error });
@@ -229,7 +269,8 @@ export class ElectronBrowserProvider {
229
269
  y: Math.round(r.y + r.height / 2),
230
270
  })
231
271
  }
232
- return { url, title, elements: out, truncated: out.length >= cap }
272
+ const challenge = ${CHALLENGE_DETECT_EXPRESSION}
273
+ return { url, title, elements: out, truncated: out.length >= cap, challenge }
233
274
  })()`;
234
275
  // Same hang guard as execute: a renderer that has not committed after
235
276
  // navigate would otherwise block snapshot forever.
@@ -240,6 +281,19 @@ export class ElectronBrowserProvider {
240
281
  const value = result.value;
241
282
  return value;
242
283
  }
284
+ /** Check whether a human-verification challenge is blocking the active tab. */
285
+ async detectChallenge(session, signal) {
286
+ const s = this.session(session);
287
+ const tab = this.activeTab(s);
288
+ signal?.throwIfAborted();
289
+ const timeoutMs = 15_000;
290
+ const result = await withTimeout(handleSendEvaluate(tab.handle, CHALLENGE_DETECT_EXPRESSION), timeoutMs, signal, `browser: challenge detection timed out after ${timeoutMs}ms`);
291
+ if (!result.ok) {
292
+ throw new BrowserError(`browser: challenge detection failed: ${result.exception}`, 'BROWSER_CHALLENGE_DETECT_FAILED');
293
+ }
294
+ const value = result.value;
295
+ return { blocked: value.blocked === true, kind: value.kind, reason: value.reason };
296
+ }
243
297
  /** Fetch page content in a requested format. */
244
298
  async content(session, request, signal) {
245
299
  const tab = this.activeTab(this.session(session));
@@ -295,16 +349,199 @@ export class ElectronBrowserProvider {
295
349
  }
296
350
  /** Click at viewport coordinates (CDP mousePressed + mouseReleased). */
297
351
  async click(session, request, signal) {
298
- const { handle } = this.activeTab(this.session(session));
352
+ const s = this.session(session);
353
+ const { handle } = this.activeTab(s);
299
354
  signal?.throwIfAborted();
300
355
  await handle.sendCommand('Input.dispatchMouseEvent', { type: 'mousePressed', x: request.x, y: request.y, button: 'left', clickCount: 1 });
301
356
  await handle.sendCommand('Input.dispatchMouseEvent', { type: 'mouseReleased', x: request.x, y: request.y, button: 'left', clickCount: 1 });
357
+ this.record(s, 'click', { x: request.x, y: request.y }, true);
302
358
  }
303
359
  /** Type into the focused element. */
304
360
  async type(session, request, signal) {
305
- const { handle } = this.activeTab(this.session(session));
361
+ const s = this.session(session);
362
+ const { handle } = this.activeTab(s);
306
363
  signal?.throwIfAborted();
307
364
  await handle.sendCommand('Input.insertText', { text: request.text });
365
+ this.record(s, 'type', { text: request.text.slice(0, 200) }, true);
366
+ }
367
+ /**
368
+ * Fill a form's fields in one batch. Runs one page-context script that
369
+ * resolves each field (selector, or name/label/placeholder among visible
370
+ * controls), sets its value with the native prototype setter (React/Vue
371
+ * controlled inputs included) plus input/change events, handles
372
+ * select/checkbox/radio/contenteditable, and optionally submits the form.
373
+ */
374
+ async fillForm(session, request, signal) {
375
+ const s = this.session(session);
376
+ const tab = this.activeTab(s);
377
+ signal?.throwIfAborted();
378
+ const specs = JSON.stringify(request.fields.map(f => ({
379
+ selector: f.selector ?? null,
380
+ name: f.name ?? null,
381
+ label: f.label ?? null,
382
+ placeholder: f.placeholder ?? null,
383
+ kind: f.kind ?? 'text',
384
+ value: f.value,
385
+ })));
386
+ const submitFlag = request.submit === true;
387
+ const script = `(() => {
388
+ const specs = ${specs}
389
+ const out = []
390
+ const setNative = (el, proto, value) => {
391
+ const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set
392
+ if (setter) setter.call(el, value)
393
+ else el.value = value
394
+ }
395
+ const visible = (el) => {
396
+ const r = el.getBoundingClientRect()
397
+ const cs = getComputedStyle(el)
398
+ return r.width >= 4 && r.height >= 4 && cs.visibility !== 'hidden' && cs.display !== 'none'
399
+ }
400
+ const describe = (spec) => spec.selector || spec.name || spec.label || spec.placeholder || '(unspecified)'
401
+ const matches = (el, spec) => {
402
+ if (spec.selector) { try { return el.matches(spec.selector) } catch { return false } }
403
+ if (spec.name && el.name === spec.name) return true
404
+ if (spec.placeholder && el.placeholder === spec.placeholder) return true
405
+ if (spec.label) {
406
+ if (el.getAttribute('aria-label') === spec.label) return true
407
+ if (el.id) {
408
+ const lbl = document.querySelector('label[for=' + JSON.stringify(el.id) + ']')
409
+ if (lbl && (lbl.textContent || '').trim() === spec.label) return true
410
+ }
411
+ const wrap = el.closest('label')
412
+ if (wrap && (wrap.textContent || '').trim() === spec.label) return true
413
+ }
414
+ return false
415
+ }
416
+ const candidates = (spec) => {
417
+ const all = spec.selector
418
+ ? [...document.querySelectorAll(spec.selector)]
419
+ : [...document.querySelectorAll('input, textarea, select, [contenteditable="true"]')].filter(el => matches(el, spec))
420
+ const vis = all.filter(visible)
421
+ return vis.length > 0 ? vis : all
422
+ }
423
+ for (const spec of specs) {
424
+ const els = candidates(spec)
425
+ if (els.length === 0) { out.push({ ok: false, error: 'field not found', target: describe(spec) }); continue }
426
+ const el = els[0]
427
+ const tag = el.tagName
428
+ const type = (el.type || '').toLowerCase()
429
+ try {
430
+ if (tag === 'SELECT') {
431
+ const wanted = String(spec.value)
432
+ if (el.multiple) {
433
+ const wantedList = wanted.split(',').map(x => x.trim())
434
+ let hit = false
435
+ for (const o of [...el.options]) {
436
+ o.selected = wantedList.includes(o.value) || wantedList.includes((o.textContent || '').trim())
437
+ if (o.selected) hit = true
438
+ }
439
+ if (!hit) { out.push({ ok: false, error: 'option not found: ' + wanted, target: describe(spec) }); continue }
440
+ } else {
441
+ let opt = [...el.options].find(o => o.value === wanted)
442
+ if (!opt) opt = [...el.options].find(o => (o.textContent || '').trim() === wanted)
443
+ if (!opt) { out.push({ ok: false, error: 'option not found: ' + wanted, target: describe(spec) }); continue }
444
+ setNative(el, HTMLSelectElement.prototype, opt.value)
445
+ }
446
+ el.dispatchEvent(new Event('input', { bubbles: true }))
447
+ el.dispatchEvent(new Event('change', { bubbles: true }))
448
+ out.push({ ok: true, method: 'select', target: describe(spec) })
449
+ } else if (type === 'file') {
450
+ out.push({ ok: false, error: 'file inputs cannot be set from script; use browser_download or ask the human', target: describe(spec) })
451
+ } else if (type === 'checkbox') {
452
+ const want = spec.value === true || spec.value === 'true' || spec.value === 'on'
453
+ if (el.checked !== want) el.click()
454
+ out.push({ ok: true, method: 'checkbox', target: describe(spec) })
455
+ } else if (type === 'radio') {
456
+ const wanted = String(spec.value)
457
+ const radio = [...document.querySelectorAll('input[type="radio"][name=' + JSON.stringify(el.name || '') + ']')]
458
+ .find(r => r.value === wanted || (r === el && (spec.value === true || spec.value === 'true')))
459
+ if (!radio) { out.push({ ok: false, error: 'radio option not found: ' + wanted, target: describe(spec) }); continue }
460
+ if (!radio.checked) radio.click()
461
+ out.push({ ok: true, method: 'radio', target: describe(spec) })
462
+ } else if (el.isContentEditable) {
463
+ el.textContent = String(spec.value)
464
+ el.dispatchEvent(new Event('input', { bubbles: true }))
465
+ out.push({ ok: true, method: 'contenteditable', target: describe(spec) })
466
+ } else if (tag === 'TEXTAREA') {
467
+ setNative(el, HTMLTextAreaElement.prototype, String(spec.value))
468
+ el.dispatchEvent(new Event('input', { bubbles: true }))
469
+ el.dispatchEvent(new Event('change', { bubbles: true }))
470
+ out.push({ ok: true, method: 'textarea', target: describe(spec) })
471
+ } else {
472
+ setNative(el, HTMLInputElement.prototype, String(spec.value))
473
+ el.dispatchEvent(new Event('input', { bubbles: true }))
474
+ el.dispatchEvent(new Event('change', { bubbles: true }))
475
+ out.push({ ok: true, method: 'input', target: describe(spec) })
476
+ }
477
+ } catch (e) {
478
+ out.push({ ok: false, error: String(e), target: describe(spec) })
479
+ }
480
+ }
481
+ let submitted = false
482
+ if (${submitFlag}) {
483
+ let last = null
484
+ for (const spec of specs) { const els = candidates(spec); if (els.length > 0) { last = els[0]; break } }
485
+ const form = last && (last.form || last.closest('form'))
486
+ if (form) { form.requestSubmit(); submitted = true }
487
+ }
488
+ return { fields: out, submitted }
489
+ })()`;
490
+ const timeoutMs = request.timeoutMs ?? 30_000;
491
+ const result = await withTimeout(handleSendEvaluate(tab.handle, script), timeoutMs, signal, `browser: fillForm timed out after ${timeoutMs}ms`);
492
+ if (!result.ok) {
493
+ throw new BrowserError(`browser: fillForm evaluation failed: ${result.exception}`, 'BROWSER_FILL_FAILED');
494
+ }
495
+ const value = result.value;
496
+ const okCount = value.fields.filter(f => f.ok).length;
497
+ this.record(s, 'fill', { fields: request.fields.length, submit: submitFlag }, okCount === value.fields.length, {
498
+ result: `${okCount}/${value.fields.length} fields filled${value.submitted ? ', form submitted' : ''}`,
499
+ });
500
+ return { fields: value.fields, submitted: value.submitted === true };
501
+ }
502
+ /**
503
+ * Download a URL to a local file, keeping the session's cookies/login.
504
+ * Requires the self-hosted host (which implements view-level download); the
505
+ * desktop shell's embedded views delegate downloads to the real browser UI.
506
+ */
507
+ async download(session, request, signal) {
508
+ const s = this.session(session);
509
+ const { handle } = this.activeTab(s);
510
+ signal?.throwIfAborted();
511
+ const downloadable = handle;
512
+ if (typeof downloadable.download !== 'function') {
513
+ throw new BrowserError('browser: download is only available on the self-hosted browser', 'BROWSER_DOWNLOAD_UNSUPPORTED');
514
+ }
515
+ await downloadable.download(request.url, request.savePath);
516
+ this.record(s, 'download', { url: request.url, savePath: request.savePath }, true, { result: request.savePath });
517
+ return { path: request.savePath };
518
+ }
519
+ /**
520
+ * Export the session's cookies (login state) as serializable objects.
521
+ * Self-hosted only; the desktop shell's embedded views use the real profile.
522
+ */
523
+ async flushAuth(session) {
524
+ const s = this.session(session);
525
+ const { handle } = this.activeTab(s);
526
+ const host = handle;
527
+ if (typeof host.flushAuth !== 'function') {
528
+ throw new BrowserError('browser: auth export is only available on the self-hosted browser', 'BROWSER_AUTH_UNSUPPORTED');
529
+ }
530
+ const cookies = await host.flushAuth();
531
+ this.record(s, 'flushAuth', {}, true, { result: `${cookies.length} cookies` });
532
+ return cookies;
533
+ }
534
+ /** Import cookies into the session (restore login state). Self-hosted only. */
535
+ async restoreAuth(session, cookies) {
536
+ const s = this.session(session);
537
+ const { handle } = this.activeTab(s);
538
+ const host = handle;
539
+ if (typeof host.restoreAuth !== 'function') {
540
+ throw new BrowserError('browser: auth restore is only available on the self-hosted browser', 'BROWSER_AUTH_UNSUPPORTED');
541
+ }
542
+ const restored = await host.restoreAuth(cookies);
543
+ this.record(s, 'restoreAuth', { count: cookies.length }, true, { result: `${restored} cookies` });
544
+ return restored;
308
545
  }
309
546
  /** Capture the current page, optionally full-page. PNG only (CDP JPEG hangs on Electron 43). */
310
547
  async screenshot(session, request, signal) {
@@ -321,8 +558,92 @@ export class ElectronBrowserProvider {
321
558
  if (typeof data !== 'string') {
322
559
  throw new BrowserError('browser: screenshot returned no image data', 'BROWSER_SCREENSHOT_FAILED');
323
560
  }
561
+ // Optional disk write so a vision tool (e.g. modlens read_image) can read
562
+ // the screenshot from a file path. Fire-and-forget: a write failure must
563
+ // not fail the capture.
564
+ if (request?.savePath !== undefined) {
565
+ try {
566
+ writeFileSync(request.savePath, Buffer.from(data, 'base64'));
567
+ return { dataUrl: `data:image/png;base64,${data}`, path: request.savePath };
568
+ }
569
+ catch (error) {
570
+ // Report the write problem but keep the capture usable.
571
+ throw new BrowserError(`browser: screenshot save to "${request.savePath}" failed: ${String(error)}`, 'BROWSER_SCREENSHOT_SAVE_FAILED', { cause: error });
572
+ }
573
+ }
324
574
  return { dataUrl: `data:image/png;base64,${data}` };
325
575
  }
576
+ /** Append one operation to the session's history. */
577
+ record(s, action, params, ok, detail) {
578
+ const entry = {
579
+ seq: s.history.length + 1,
580
+ action,
581
+ params,
582
+ ok,
583
+ ...detail?.result !== undefined ? { result: detail.result } : {},
584
+ ...detail?.error !== undefined ? { error: detail.error } : {},
585
+ at: Date.now(),
586
+ };
587
+ s.history.push(entry);
588
+ // Bound memory: keep the last 500 operations.
589
+ if (s.history.length > 500)
590
+ s.history.splice(0, s.history.length - 500);
591
+ }
592
+ /** Return the session's chronological operation log (newest last). */
593
+ async history(session) {
594
+ return this.session(session).history;
595
+ }
596
+ /**
597
+ * Replay one recorded operation by sequence number. Navigate/click/type are
598
+ * re-issued against the current page; execute re-runs its script. The
599
+ * replayed step is appended to history as a new entry.
600
+ * @param session - the session id.
601
+ * @param seq - the recorded entry's sequence number to replay.
602
+ */
603
+ async replay(session, seq) {
604
+ const s = this.session(session);
605
+ const entry = s.history.find(e => e.seq === seq);
606
+ if (entry === undefined) {
607
+ throw new BrowserError(`browser: no history entry with seq ${seq}`, 'BROWSER_HISTORY_UNKNOWN');
608
+ }
609
+ switch (entry.action) {
610
+ case 'navigate': {
611
+ const url = entry.params.url;
612
+ if (typeof url !== 'string')
613
+ throw new BrowserError(`browser: history seq ${seq} navigate has no url`, 'BROWSER_HISTORY_INVALID');
614
+ await this.navigate(session, { url });
615
+ this.record(s, 'replay', { seq, of: entry.action, url }, true);
616
+ return;
617
+ }
618
+ case 'click': {
619
+ const x = entry.params.x;
620
+ const y = entry.params.y;
621
+ if (typeof x !== 'number' || typeof y !== 'number')
622
+ throw new BrowserError(`browser: history seq ${seq} click has no coordinates`, 'BROWSER_HISTORY_INVALID');
623
+ await this.click(session, { x, y });
624
+ this.record(s, 'replay', { seq, of: entry.action, x, y }, true);
625
+ return;
626
+ }
627
+ case 'type': {
628
+ const text = entry.params.text;
629
+ if (typeof text !== 'string')
630
+ throw new BrowserError(`browser: history seq ${seq} type has no text`, 'BROWSER_HISTORY_INVALID');
631
+ await this.type(session, { text });
632
+ this.record(s, 'replay', { seq, of: entry.action, text }, true);
633
+ return;
634
+ }
635
+ case 'execute': {
636
+ const script = entry.params.script;
637
+ if (typeof script !== 'string')
638
+ throw new BrowserError(`browser: history seq ${seq} execute has no script`, 'BROWSER_HISTORY_INVALID');
639
+ const result = await this.execute(session, { script });
640
+ this.record(s, 'replay', { seq, of: entry.action, script }, result.ok, result.ok ? { result: String(result.value) } : { error: result.exception });
641
+ return;
642
+ }
643
+ default:
644
+ throw new BrowserError(`browser: history seq ${seq} action "${entry.action}" is not replayable`, 'BROWSER_HISTORY_NOT_REPLAYABLE');
645
+ }
646
+ }
326
647
  /** Close the session and destroy all its views. Idempotent. */
327
648
  close(session) {
328
649
  const existing = this.sessions.get(session);
@@ -17,7 +17,7 @@
17
17
  */
18
18
  import { spawn } from 'node:child_process';
19
19
  import { createRequire } from 'node:module';
20
- import { existsSync, readdirSync } from 'node:fs';
20
+ import { existsSync, readdirSync, writeFileSync } from 'node:fs';
21
21
  import { join } from 'node:path';
22
22
  import { createServer } from 'node:net';
23
23
  import { fileURLToPath } from 'node:url';
@@ -268,6 +268,19 @@ class RemoteView {
268
268
  params: params ?? {},
269
269
  });
270
270
  }
271
+ /** Ask the child to download a URL to a local file (keeps cookies/login). */
272
+ async download(url, savePath) {
273
+ const result = await this.client.call('download', { viewId: this.id, url, savePath });
274
+ writeFileSync(savePath, Buffer.from(result.base64, 'base64'));
275
+ }
276
+ /** Export the session's cookies (login state). */
277
+ flushAuth() {
278
+ return this.client.call('flushAuth', { viewId: this.id }).then(r => r.cookies);
279
+ }
280
+ /** Import cookies into the session (restore login state). */
281
+ restoreAuth(cookies) {
282
+ return this.client.call('restoreAuth', { viewId: this.id, cookies }).then(r => r.restored);
283
+ }
271
284
  }
272
285
  /**
273
286
  * Self-hosted view host: spawns the plugin's Electron child on first use and
@@ -371,6 +384,21 @@ class DeferredRemoteView {
371
384
  const view = await this.materialized;
372
385
  return view.sendCommand(method, params);
373
386
  }
387
+ async download(url, savePath) {
388
+ this.materialized ??= this.materialize();
389
+ const view = await this.materialized;
390
+ return view.download(url, savePath);
391
+ }
392
+ async flushAuth() {
393
+ this.materialized ??= this.materialize();
394
+ const view = await this.materialized;
395
+ return view.flushAuth();
396
+ }
397
+ async restoreAuth(cookies) {
398
+ this.materialized ??= this.materialize();
399
+ const view = await this.materialized;
400
+ return view.restoreAuth(cookies);
401
+ }
374
402
  }
375
403
  /** Reject a promise if it does not settle within the budget. */
376
404
  function withTimeout(promise, ms, message) {
@@ -5,10 +5,12 @@
5
5
  * `browser_switch_tab`, `browser_close_tab`, `browser_reset`).
6
6
  *
7
7
  * The tool layer owns only the model-facing schema, argument validation, and
8
- * result formatting 鈥?never provider selection or page driving, which belong
9
- * to the seam. Session lifecycle is owned here at the plugin level: the
10
- * first `browser_open` (or any tool when no session exists) opens a session;
11
- * later tools reuse it. Per-agent isolation is a follow-up.
8
+ * result formatting — never provider selection or page driving, which belong
9
+ * to the seam. Session lifecycle is owned here at the plugin level: each
10
+ * calling task (a DSH session) gets its own browser session — the first
11
+ * `browser_open` (or any tool when no session exists) opens it, and later
12
+ * tools in the same task reuse it. Concurrent tasks therefore never fight
13
+ * over tabs, history, or navigation state.
12
14
  * @module dsh-browser/tool-browser
13
15
  */
14
16
  import type { Context } from '@deepseek-ai/cordis';
@@ -23,11 +25,15 @@ export interface Config {
23
25
  readonly timeoutMs?: number;
24
26
  /** Whether to offer tab-management tools. Default true. */
25
27
  readonly tabTools?: boolean;
28
+ /** Optional initial allow-list of browser tool names; other tools are refused. */
29
+ readonly allowedActions?: readonly string[];
26
30
  }
27
31
  /** Register all browser tools with `ctx.tools`. */
28
32
  export declare function apply(ctx: Context, config?: Config): void;
29
- /** Test hook: clear the plugin-level session (used by tests and on reset). */
33
+ /** Test hook: inspect and reset the plugin-level session map (used by tests). */
30
34
  export declare const internals: {
31
- readonly session: BrowserSessionId | undefined;
32
- clearSession(): void;
35
+ /** The per-task session map (task key -> provider session id). */
36
+ readonly sessions: ReadonlyMap<string, BrowserSessionId>;
37
+ /** Drop one task's mapping without closing the provider session. */
38
+ clearSession(key?: string): void;
33
39
  };