dsh-builtin-browser 0.1.7 → 0.1.9

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.
@@ -20,6 +20,8 @@ export const name = 'tool-browser';
20
20
  export const inject = ['tools', 'browser', 'systemPrompt'];
21
21
  /** Per-task browser sessions, keyed by the calling DSH session id. */
22
22
  const sessionsByTask = new Map();
23
+ /** In-flight first-open per task key, so concurrent first calls share one session. */
24
+ const pendingOpens = new Map();
23
25
  /**
24
26
  * Action restriction state: an allow-list of browser tool names, or undefined
25
27
  * for unrestricted. When set, any browser_* tool not in the list is refused
@@ -48,25 +50,29 @@ function taskKey(exec) {
48
50
  }
49
51
  /**
50
52
  * Resolve the calling task's browser session, opening one on first use.
53
+ * Concurrent first calls for the same key share a single open.
51
54
  * @param browser - the seam service.
52
55
  * @param key - the task key (see {@link taskKey}).
53
56
  * @returns the task's session id.
54
57
  */
55
58
  async function ensureSession(browser, key) {
56
- let session = sessionsByTask.get(key);
57
- if (session === undefined) {
58
- session = await browser.open();
59
- sessionsByTask.set(key, session);
60
- }
61
- return session;
59
+ const existing = sessionsByTask.get(key);
60
+ if (existing !== undefined)
61
+ return existing;
62
+ const pending = pendingOpens.get(key);
63
+ if (pending !== undefined)
64
+ return pending;
65
+ const opening = browser.open().then(session => { sessionsByTask.set(key, session); pendingOpens.delete(key); return session; }, error => { pendingOpens.delete(key); throw error; });
66
+ pendingOpens.set(key, opening);
67
+ return opening;
62
68
  }
63
- /** Coerce a tool-provided string value back to boolean/number when it looks like one. */
69
+ /** Coerce a tool-provided string value back to boolean/number only when lossless. */
64
70
  function parseFillValue(v) {
65
71
  if (v === 'true')
66
72
  return true;
67
73
  if (v === 'false')
68
74
  return false;
69
- if (v !== undefined && /^-?\d+(\.\d+)?$/.test(v))
75
+ if (v !== undefined && /^-?\d+(\.\d+)?$/.test(v) && String(Number(v)) === v)
70
76
  return Number(v);
71
77
  return v ?? '';
72
78
  }
@@ -84,8 +90,8 @@ function formatSnapshot(snapshot) {
84
90
  /** Register all browser tools with `ctx.tools`. */
85
91
  export function apply(ctx, config = {}) {
86
92
  const timeoutMs = config.timeoutMs ?? 60_000;
87
- if (config.allowedActions !== undefined)
88
- restrictedTo = [...config.allowedActions];
93
+ // Re-apply resets the restriction: an omitted allowedActions lifts it.
94
+ restrictedTo = config.allowedActions !== undefined ? [...config.allowedActions] : undefined;
89
95
  ctx.systemPrompt.section({
90
96
  name: 'tool:browser',
91
97
  // Tool guidance band is 100-199; 150 keeps clear of the common 110/120
@@ -107,6 +113,7 @@ export function apply(ctx, config = {}) {
107
113
  properties: {
108
114
  url: { type: 'string', required: true },
109
115
  title: { type: 'string' },
116
+ truncated: { type: 'boolean' },
110
117
  elements: {
111
118
  type: 'array',
112
119
  required: true,
@@ -168,6 +175,7 @@ export function apply(ctx, config = {}) {
168
175
  properties: {
169
176
  url: { type: 'string', required: true },
170
177
  title: { type: 'string' },
178
+ truncated: { type: 'boolean' },
171
179
  elements: {
172
180
  type: 'array',
173
181
  required: true,
@@ -546,6 +554,7 @@ export function apply(ctx, config = {}) {
546
554
  timeoutMs,
547
555
  isConcurrencySafe: () => true,
548
556
  async execute(args, exec) {
557
+ assertAllowed('browser_switch_tab');
549
558
  const browser = ctx.get('browser');
550
559
  if (browser === undefined)
551
560
  throw new Error('tool-browser: browser service unavailable');
@@ -629,7 +638,11 @@ export function apply(ctx, config = {}) {
629
638
  return [{ type: 'text', text: '(no recorded operations yet)' }];
630
639
  return [{
631
640
  type: 'text',
632
- text: entries.map(e => `#${e.seq} ${e.action} ${e.ok ? 'ok' : 'FAIL'} ${JSON.stringify(e.params)}${e.result !== undefined ? ` -> ${e.result}` : ''}${e.error !== undefined ? ` !! ${e.error}` : ''}`).join('\n'),
641
+ text: entries.map(e => {
642
+ const rawParams = JSON.stringify(e.params);
643
+ const shownParams = rawParams.length > 300 ? rawParams.slice(0, 300) + '…' : rawParams;
644
+ return `#${e.seq} ${e.action} ${e.ok ? 'ok' : 'FAIL'} ${shownParams}${e.result !== undefined ? ` -> ${e.result}` : ''}${e.error !== undefined ? ` !! ${e.error}` : ''}`;
645
+ }).join('\n'),
633
646
  }];
634
647
  },
635
648
  },
@@ -738,9 +751,7 @@ export function apply(ctx, config = {}) {
738
751
  const browser = ctx.get('browser');
739
752
  if (browser === undefined)
740
753
  throw new Error('tool-browser: browser service unavailable');
741
- const key = taskKey(exec);
742
- const session = sessionsByTask.get(key) ?? await browser.open();
743
- sessionsByTask.set(key, session);
754
+ const session = await ensureSession(browser, taskKey(exec));
744
755
  const tabs = await browser.listTabs(session);
745
756
  return { session, tabs: tabs.map(t => ({ id: t.id, url: t.url, active: t.active })) };
746
757
  },
@@ -763,8 +774,14 @@ export function apply(ctx, config = {}) {
763
774
  const key = taskKey(exec);
764
775
  const session = sessionsByTask.get(key);
765
776
  if (session !== undefined) {
766
- await browser.close(session);
767
- sessionsByTask.delete(key);
777
+ try {
778
+ await browser.close(session);
779
+ }
780
+ finally {
781
+ // Always forget the mapping so the next call opens a fresh session,
782
+ // even if the provider close threw (the session is half-closed).
783
+ sessionsByTask.delete(key);
784
+ }
768
785
  }
769
786
  return { reset: true };
770
787
  },
@@ -787,8 +804,15 @@ export function apply(ctx, config = {}) {
787
804
  isConcurrencySafe: () => false,
788
805
  async execute(args, exec) {
789
806
  // Always allowed so the guard can be lifted.
790
- restrictedTo = (args.allowed ?? []).filter((t) => t.startsWith('browser_'));
791
- return { restrictedTo: [...restrictedTo] };
807
+ const allowed = args.allowed ?? [];
808
+ const unknown = allowed.filter((t) => !t.startsWith('browser_'));
809
+ if (unknown.length > 0) {
810
+ throw new Error(`browser_restrict: unknown tool name(s) ${unknown.map(t => `"${t}"`).join(', ')} (must start with "browser_")`);
811
+ }
812
+ // Empty list (or omitted) lifts the restriction; a non-empty list is the
813
+ // new allow-list.
814
+ restrictedTo = allowed.length === 0 ? undefined : [...allowed];
815
+ return { restrictedTo: restrictedTo === undefined ? [] : [...restrictedTo] };
792
816
  },
793
817
  }));
794
818
  ctx.tools.register(defineTool({
@@ -829,8 +853,8 @@ export function apply(ctx, config = {}) {
829
853
  }
830
854
  /** Test hook: inspect and reset the plugin-level session map (used by tests). */
831
855
  export const internals = {
832
- /** The per-task session map (task key -> provider session id). */
833
- get sessions() { return sessionsByTask; },
856
+ /** A copy of the per-task session map (task key -> provider session id). */
857
+ get sessions() { return new Map(sessionsByTask); },
834
858
  /** Drop one task's mapping without closing the provider session. */
835
859
  clearSession(key = 'default') { sessionsByTask.delete(key); },
836
860
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-builtin-browser",
3
- "version": "0.1.7",
4
- "description": "Shared real browser capability for DeepSeek Harness: a visible browser the human watches and can take over, driven by the agent over CDP (snapshot/execute/content/tab management); host-side seam, provider, and browser_* tools",
3
+ "version": "0.1.9",
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",
7
7
  "types": "lib/index.d.ts",
@@ -250,9 +250,21 @@ export class BrowserRuntime extends Service {
250
250
  return this.resolveProvider().restoreAuth(session, cookies)
251
251
  }
252
252
 
253
- /** Close the session through the selected provider. Idempotent. */
253
+ /** Close the session through the selected provider. Idempotent; a missing
254
+ * provider is treated as already-closed so teardown paths stay no-ops. */
254
255
  async close(session: BrowserSessionId): Promise<void> {
255
- return this.resolveProvider().close(session)
256
+ try {
257
+ await this.resolveProvider().close(session)
258
+ } catch (error) {
259
+ const code = error instanceof BrowserError ? (error as { code?: string }).code : undefined
260
+ if (code === 'BROWSER_PROVIDER_UNAVAILABLE'
261
+ || code === 'BROWSER_PROVIDER_CONFIGURED_MISSING'
262
+ || code === 'BROWSER_PROVIDER_CONFIGURED_UNAVAILABLE'
263
+ || code === 'BROWSER_PROVIDER_AMBIGUOUS') {
264
+ return // provider gone; nothing to close
265
+ }
266
+ throw error
267
+ }
256
268
  }
257
269
  }
258
270
 
@@ -313,7 +313,8 @@ export interface BrowserProvider {
313
313
  export interface BrowserHistoryEntry {
314
314
  /** Monotonic sequence number within the session. */
315
315
  readonly seq: number
316
- /** The operation name: navigate | execute | click | type | replay. */
316
+ /** The operation name. Replayable: navigate | execute | click | type.
317
+ * Also recorded (non-replayable): fill | download | flushAuth | restoreAuth. */
317
318
  readonly action: string
318
319
  /** The operation's arguments. */
319
320
  readonly params: Record<string, unknown>
@@ -49,10 +49,15 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
49
49
  // External host (desktop shell) wins; otherwise self-host. The self-hosted
50
50
  // child is disposed with the fiber, mirroring the shell's lifetime.
51
51
  const host: ElectronBrowserViewHost = config.viewHost ?? new RemoteElectronViewHost(defaultHostMainPath())
52
+ // Own the disposer on THIS plugin's fiber: registerBrowserProvider's effect
53
+ // is bound to the seam's own fiber (the browser row), so a reload of this
54
+ // row would otherwise collide with the still-registered provider
55
+ // (BROWSER_DUPLICATE_PROVIDER) or leave a stale provider behind.
56
+ const unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(host, { httpOnly: config.httpOnly }))
52
57
  ctx.effect(() => () => {
58
+ unregister()
53
59
  if (config.viewHost === undefined && host instanceof RemoteElectronViewHost) {
54
60
  host.dispose()
55
61
  }
56
62
  })
57
- ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(host, { httpOnly: config.httpOnly }))
58
63
  }
@@ -59,11 +59,13 @@ async function handle(op: string, msg: { id: number; viewId?: string; method?: s
59
59
  window.on('closed', () => { window = undefined })
60
60
  }
61
61
  const view = new WebContentsView()
62
+ // New views start hidden: with several sessions/tabs only the shown
63
+ // one may be visible (they stack in contentView child order).
62
64
  view.setVisible(false)
63
65
  window.contentView.addChildView(view)
64
66
  const [width, height] = window.getContentSize()
65
67
  view.setBounds({ x: 0, y: 0, width: width ?? 0, height: height ?? 0 })
66
- view.setVisible(true)
68
+ if (views.size === 0) view.setVisible(true)
67
69
  view.webContents.debugger.attach(CDP_VERSION)
68
70
  views.set(viewId, { webContentsView: view })
69
71
  reply(msg.id, { ok: true })
@@ -86,7 +88,19 @@ async function handle(op: string, msg: { id: number; viewId?: string; method?: s
86
88
  const viewId = msg.viewId
87
89
  if (viewId === undefined) throw new Error('showView missing viewId')
88
90
  const entry = views.get(viewId)
89
- if (entry !== undefined) entry.webContentsView.setVisible(true)
91
+ if (entry !== undefined) {
92
+ // Hide every other view, then show and RAISE the target so the
93
+ // human actually sees the active tab/session (topmost child wins).
94
+ for (const v of views.values()) {
95
+ if (v === entry) continue
96
+ try { v.webContentsView.setVisible(false) } catch { /* destroyed */ }
97
+ }
98
+ entry.webContentsView.setVisible(true)
99
+ if (window !== undefined) {
100
+ window.contentView.removeChildView(entry.webContentsView)
101
+ window.contentView.addChildView(entry.webContentsView)
102
+ }
103
+ }
90
104
  reply(msg.id, { ok: true })
91
105
  return
92
106
  }
@@ -220,6 +234,12 @@ void app.whenReady().then(() => {
220
234
  socket.on('error', error => {
221
235
  process.stderr.write(`[dsh-browser host] socket error: ${String(error)}\n`)
222
236
  })
237
+ // The parent owns our lifetime: when it closes the socket (dispose) or dies
238
+ // without cleanup, exit so no zombie Electron window is left behind.
239
+ socket.on('close', () => {
240
+ process.stderr.write('[dsh-browser host] parent connection closed, exiting\n')
241
+ app.exit(0)
242
+ })
223
243
  // Keep the process alive until the parent closes the socket or kills us.
224
244
  })
225
245
 
@@ -39,11 +39,11 @@ const CHALLENGE_DETECT_EXPRESSION = `(() => {
39
39
  const lower = (title + '\\n' + bodyText).toLowerCase()
40
40
  const frameSrcs = [...document.querySelectorAll('iframe')].map(f => f.src || '').join(' ')
41
41
  const framesLower = frameSrcs.toLowerCase()
42
- const hasCfInterstitial = /just a moment|checking your browser|attention required|cf_chl|challenge-platform/i.test(lower)
42
+ const hasCfInterstitial = /just a moment|checking your browser|attention required|cf_chl/i.test(lower)
43
43
  || !!document.querySelector('#challenge-running, #challenge-stage, #cf-chl-container')
44
44
  const hasHCaptcha = !!window.hcaptcha || !!document.querySelector('.h-captcha') || /hcaptcha\\.com/i.test(framesLower)
45
45
  const hasRecaptcha = !!window.grecaptcha || !!document.querySelector('.g-recaptcha') || /recaptcha\\/api|google\\.com\\/recaptcha/i.test(framesLower)
46
- const hasTurnstile = !!window.turnstile || /challenges\\.cloudflare\\.com/i.test(framesLower) || /turnstile/i.test(lower)
46
+ const hasTurnstile = !!window.turnstile || /challenges\\.cloudflare\\.com/i.test(framesLower) || /turnstile|challenge-platform/i.test(lower)
47
47
  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)
48
48
  if (hasCfInterstitial) return { blocked: true, kind: 'cloudflare', reason: 'Cloudflare "Just a moment" interstitial' }
49
49
  if (hasHCaptcha) return { blocked: true, kind: 'hcaptcha', reason: 'hCaptcha verification' }
@@ -114,8 +114,10 @@ interface Session {
114
114
  readonly id: BrowserSessionId
115
115
  readonly tabs: Tab[]
116
116
  activeIndex: number
117
- /** Chronological operation log (navigate/execute/click/type/screenshot). */
117
+ /** Chronological operation log (navigate/execute/click/type/fill/download/auth). */
118
118
  readonly history: BrowserHistoryEntry[]
119
+ /** Monotonic sequence counter for history entries (survives truncation). */
120
+ nextSeq: number
119
121
  }
120
122
 
121
123
  /** Provider config: navigation admission defaults and snapshot caps. */
@@ -207,7 +209,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
207
209
  open(): Promise<BrowserSessionId> {
208
210
  const handle = this.host.createView()
209
211
  const id = `browser:${randomUUID()}`
210
- this.sessions.set(id, { id, tabs: [{ id: `tab:${randomUUID()}`, handle }], activeIndex: 0, history: [] })
212
+ this.sessions.set(id, { id, tabs: [{ id: `tab:${randomUUID()}`, handle }], activeIndex: 0, history: [], nextSeq: 1 })
211
213
  return Promise.resolve(id)
212
214
  }
213
215
 
@@ -302,15 +304,24 @@ export class ElectronBrowserProvider implements BrowserProvider {
302
304
  }
303
305
  }
304
306
  signal?.throwIfAborted()
305
- const result = await handle.sendCommand(CDP_PAGE_NAVIGATE, { url } satisfies CdpNavigateParams)
307
+ // Page.navigate can hang on an unreachable/slow host; bound it like the
308
+ // evaluate paths so a wedged navigation surfaces as an error instead of
309
+ // blocking the tool call forever.
310
+ const timeoutMs = 30_000
311
+ const result = await withTimeout(
312
+ handle.sendCommand(CDP_PAGE_NAVIGATE, { url } satisfies CdpNavigateParams),
313
+ timeoutMs,
314
+ signal,
315
+ `browser: navigation timed out after ${timeoutMs}ms`,
316
+ )
306
317
  // Page.navigate resolves even when the navigation fails; surface the
307
318
  // failure instead of leaving a silent white screen.
308
319
  const errorText = (result as { errorText?: string }).errorText
309
320
  if (typeof errorText === 'string' && errorText !== '') {
310
- this.record(s, 'navigate', { url }, false, { error: errorText })
311
321
  throw new BrowserError(`browser: navigation to "${url}" failed: ${errorText}`, 'BROWSER_NAVIGATION_FAILED')
312
322
  }
313
323
  this.record(s, 'navigate', { url }, true)
324
+ this.showActive(s)
314
325
  } catch (error) {
315
326
  if (!(error instanceof BrowserError && (error as { code?: string }).code === 'BROWSER_NAVIGATION_BLOCKED')) {
316
327
  this.record(s, 'navigate', { url }, false, { error: String(error) })
@@ -362,9 +373,15 @@ export class ElectronBrowserProvider implements BrowserProvider {
362
373
  return { ok: false, exception }
363
374
  }
364
375
  const value = (result.result as { value?: unknown } | undefined)?.value ?? null
365
- this.record(s, 'execute', { script: request.script }, true, { result: typeof value === 'string' ? value.slice(0, 500) : JSON.stringify(value).slice(0, 500) })
376
+ this.record(s, 'execute', {
377
+ script: request.script,
378
+ ...request.args !== undefined && request.args.length > 0 ? { args: request.args } : {},
379
+ }, true, { result: typeof value === 'string' ? value.slice(0, 500) : JSON.stringify(value).slice(0, 500) })
366
380
  return { ok: true, value }
367
381
  } catch (error) {
382
+ if (error instanceof Error && error.name === 'TimeoutError') {
383
+ throw new BrowserError(`browser: execute timed out after ${request.timeoutMs ?? 30_000}ms`, 'BROWSER_EXECUTE_TIMEOUT', { cause: error })
384
+ }
368
385
  throw new BrowserError(`browser: execute failed: ${String(error)}`, 'BROWSER_EXECUTE_FAILED', { cause: error })
369
386
  }
370
387
  }
@@ -476,13 +493,15 @@ export class ElectronBrowserProvider implements BrowserProvider {
476
493
  return { ok: true, content: content.slice(0, ${String(maxChars)}), truncated }
477
494
  })()`
478
495
  // Honor a per-call timeout: content evaluation can hang on a heavy page,
479
- // so a caller-supplied budget bounds it. AbortSignal.any merges the
480
- // caller's signal with a timeout.
496
+ // so a caller-supplied budget bounds it. Unlike a bare signal entry check,
497
+ // withTimeout also interrupts a call already in flight.
481
498
  const timeoutMs = request.timeoutMs ?? 30_000
482
- const timeoutSignal = AbortSignal.timeout(timeoutMs)
483
- const result = signal !== undefined
484
- ? await handleSendEvaluate(tab.handle, script, AbortSignal.any([signal, timeoutSignal]))
485
- : await handleSendEvaluate(tab.handle, script, timeoutSignal)
499
+ const result = await withTimeout(
500
+ handleSendEvaluate(tab.handle, script),
501
+ timeoutMs,
502
+ signal,
503
+ `browser: content timed out after ${timeoutMs}ms`,
504
+ )
486
505
  if (!result.ok) throw new BrowserError(`browser: content evaluation failed: ${result.exception}`, 'BROWSER_CONTENT_FAILED')
487
506
  const value = result.value as { ok: boolean; reason?: string; content?: string; truncated?: boolean }
488
507
  if (!value.ok) throw new BrowserError(`browser: content fetch failed: ${value.reason ?? 'unknown'}`, 'BROWSER_CONTENT_FAILED')
@@ -505,7 +524,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
505
524
  const { handle } = this.activeTab(s)
506
525
  signal?.throwIfAborted()
507
526
  await handle.sendCommand('Input.insertText', { text: request.text } satisfies CdpInsertTextParams)
508
- this.record(s, 'type', { text: request.text.slice(0, 200) }, true)
527
+ // Store the full text so replay re-issues the same input; the history
528
+ // tool truncates long values when rendering.
529
+ this.record(s, 'type', { text: request.text }, true)
509
530
  }
510
531
 
511
532
  /**
@@ -565,7 +586,15 @@ export class ElectronBrowserProvider implements BrowserProvider {
565
586
  return vis.length > 0 ? vis : all
566
587
  }
567
588
  for (const spec of specs) {
568
- const els = candidates(spec)
589
+ let els
590
+ try {
591
+ els = candidates(spec)
592
+ } catch (e) {
593
+ // A malformed selector must not abort the whole batch; report the
594
+ // field as failed and continue with the rest.
595
+ out.push({ ok: false, error: String(e), target: describe(spec) })
596
+ continue
597
+ }
569
598
  if (els.length === 0) { out.push({ ok: false, error: 'field not found', target: describe(spec) }); continue }
570
599
  const el = els[0]
571
600
  const tag = el.tagName
@@ -625,7 +654,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
625
654
  let submitted = false
626
655
  if (${submitFlag}) {
627
656
  let last = null
628
- for (const spec of specs) { const els = candidates(spec); if (els.length > 0) { last = els[0]; break } }
657
+ for (const spec of specs) {
658
+ try { const els = candidates(spec); if (els.length > 0) { last = els[0]; break } } catch { /* skip */ }
659
+ }
629
660
  const form = last && (last.form || last.closest('form'))
630
661
  if (form) { form.requestSubmit(); submitted = true }
631
662
  }
@@ -710,7 +741,15 @@ export class ElectronBrowserProvider implements BrowserProvider {
710
741
  // a clip this yields the full-page image (CDP default is the viewport).
711
742
  params.captureBeyondViewport = true
712
743
  }
713
- const result = await handle.sendCommand(CDP_PAGE_CAPTURE_SCREENSHOT, params)
744
+ // Bound the capture: a view that has not painted can wedge
745
+ // Page.captureScreenshot indefinitely (observed after view re-showing).
746
+ const timeoutMs = 30_000
747
+ const result = await withTimeout(
748
+ handle.sendCommand(CDP_PAGE_CAPTURE_SCREENSHOT, params),
749
+ timeoutMs,
750
+ signal,
751
+ `browser: screenshot timed out after ${timeoutMs}ms`,
752
+ )
714
753
  const data = result.data
715
754
  if (typeof data !== 'string') {
716
755
  throw new BrowserError('browser: screenshot returned no image data', 'BROWSER_SCREENSHOT_FAILED')
@@ -739,7 +778,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
739
778
  detail?: { result?: string; error?: string },
740
779
  ): void {
741
780
  const entry: BrowserHistoryEntry = {
742
- seq: s.history.length + 1,
781
+ seq: s.nextSeq++,
743
782
  action,
744
783
  params,
745
784
  ok,
@@ -796,8 +835,10 @@ export class ElectronBrowserProvider implements BrowserProvider {
796
835
  case 'execute': {
797
836
  const script = entry.params.script
798
837
  if (typeof script !== 'string') throw new BrowserError(`browser: history seq ${seq} execute has no script`, 'BROWSER_HISTORY_INVALID')
799
- const result = await this.execute(session, { script })
800
- this.record(s, 'replay', { seq, of: entry.action, script }, result.ok, result.ok ? { result: String(result.value) } : { error: result.exception })
838
+ const recordedArgs = entry.params.args
839
+ const args = Array.isArray(recordedArgs) ? recordedArgs.filter((a): a is string => typeof a === 'string') : undefined
840
+ const result = await this.execute(session, { script, ...args !== undefined && args.length > 0 ? { args } : {} })
841
+ this.record(s, 'replay', { seq, of: entry.action, script, ...args !== undefined && args.length > 0 ? { args } : {} }, result.ok, result.ok ? { result: String(result.value) } : { error: result.exception })
801
842
  return
802
843
  }
803
844
  default:
@@ -872,7 +913,12 @@ function withTimeout<T>(
872
913
  const timer = setTimeout(() => {
873
914
  if (done) return
874
915
  done = true
875
- reject(new Error(message))
916
+ // A fired timeout must also release the abort listener; { once: true }
917
+ // only releases it on the next abort, which may never come.
918
+ if (signal !== undefined) signal.removeEventListener('abort', onAbort)
919
+ const error = new Error(message)
920
+ error.name = 'TimeoutError'
921
+ reject(error)
876
922
  }, ms)
877
923
  const finish = (fn: () => void): void => {
878
924
  if (done) return
@@ -2,9 +2,9 @@
2
2
  * Self-hosted Electron browser host (parent side): an
3
3
  * {@link ElectronBrowserViewHost} implementation that spawns the plugin's own
4
4
  * Electron child process (host-main.js) and drives it over line-delimited
5
- * JSON-RPC on stdio. This is what makes the plugin work on surfaces without a
6
- * desktop shell's electronViewHost (plain dsh web): installing the plugin is
7
- * enough — the browser window appears on first use.
5
+ * JSON-RPC on a loopback TCP socket. This is what makes the plugin work on
6
+ * surfaces without a desktop shell's electronViewHost (plain dsh web):
7
+ * installing the plugin is enough — the browser window appears on first use.
8
8
  *
9
9
  * Protocol (one JSON object per line, both directions):
10
10
  * -> { id, op: 'createView' } | { id, op: 'destroyView', viewId } |
@@ -159,10 +159,13 @@ class ElectronChildClient {
159
159
  private socket: import('node:net').Socket | undefined
160
160
  private connected = false
161
161
  private outbox: string[] = []
162
+ /** Set once the child has exited; further calls fail fast instead of queueing. */
163
+ private dead = false
162
164
 
163
165
  constructor(
164
166
  private readonly hostMainPath: string,
165
167
  private readonly port: number,
168
+ private readonly onExit?: () => void,
166
169
  ) {
167
170
  const electron = resolveElectronPath()
168
171
  // ELECTRON_RUN_AS_NODE (even an empty string) makes Electron run as plain
@@ -181,24 +184,45 @@ class ElectronChildClient {
181
184
  // Diagnostics only; never parse stderr as protocol.
182
185
  process.stderr.write(`[dsh-browser host] ${String(chunk)}`)
183
186
  })
187
+ // A failed spawn (bad/corrupt binary) emits 'error' — without a listener
188
+ // that would crash the whole DSH process.
189
+ this.child.on('error', error => {
190
+ process.stderr.write(`[dsh-browser host] spawn error: ${String(error)}\n`)
191
+ this.fail(new Error(`dsh-builtin-browser: browser host failed to start: ${String(error)}`))
192
+ })
184
193
  this.child.on('exit', (code, signal) => {
185
- const err = new Error(`dsh-builtin-browser: browser host exited (code=${String(code)} signal=${String(signal)})`)
186
- for (const pending of this.pending.values()) pending.reject(err)
187
- this.pending.clear()
194
+ this.fail(new Error(`dsh-builtin-browser: browser host exited (code=${String(code)} signal=${String(signal)})`))
188
195
  })
189
196
  }
190
197
 
198
+ /** Reject everything in flight, mark the client dead, and notify the host. */
199
+ private fail(err: Error): void {
200
+ if (this.dead) return
201
+ this.dead = true
202
+ this.connected = false
203
+ for (const pending of this.pending.values()) pending.reject(err)
204
+ this.pending.clear()
205
+ this.outbox = []
206
+ this.onExit?.()
207
+ }
208
+
191
209
  /** Accept the child's connection (called by the server). */
192
210
  attach(socket: import('node:net').Socket): void {
193
211
  this.socket = socket
194
212
  this.connected = true
195
213
  socket.setEncoding('utf8')
214
+ // Without an 'error' listener a remote reset (ECONNRESET/EPIPE) throws an
215
+ // uncaught 'error' event and crashes the whole DSH process; 'close' below
216
+ // does the cleanup.
217
+ socket.on('error', error => {
218
+ process.stderr.write(`[dsh-browser host] socket error: ${String(error)}\n`)
219
+ })
196
220
  socket.on('data', chunk => this.onData(chunk))
197
221
  socket.on('close', () => {
198
222
  this.connected = false
199
- const err = new Error('dsh-builtin-browser: browser host connection closed')
200
- for (const pending of this.pending.values()) pending.reject(err)
201
- this.pending.clear()
223
+ if (!this.dead) {
224
+ this.fail(new Error('dsh-builtin-browser: browser host connection closed'))
225
+ }
202
226
  })
203
227
  // Flush anything queued while disconnected.
204
228
  if (this.outbox.length > 0) {
@@ -232,6 +256,9 @@ class ElectronChildClient {
232
256
 
233
257
  /** Send one command and await the reply. */
234
258
  call<T = unknown>(op: string, payload: Record<string, unknown> = {}): Promise<T> {
259
+ if (this.dead) {
260
+ return Promise.reject(new Error('dsh-builtin-browser: browser host is not running'))
261
+ }
235
262
  const id = this.nextId++
236
263
  const line = JSON.stringify({ id, op, ...payload })
237
264
  return new Promise<T>((resolve, reject) => {
@@ -292,10 +319,11 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
292
319
  private pendingSocket: Socket | undefined
293
320
  private readonly views = new Map<string, ElectronViewHandle>()
294
321
  private readyPromise: Promise<void> | undefined
322
+ private disposed = false
295
323
 
296
324
  constructor(private readonly hostMainPath: string) {}
297
325
 
298
- /** Ensure the child is up and ready (lazy on first use). */
326
+ /** Ensure the child is up and ready (lazy on first use; restarts after a crash). */
299
327
  private ready(): Promise<void> {
300
328
  if (this.readyPromise !== undefined) return this.readyPromise
301
329
  this.readyPromise = this.start()
@@ -312,10 +340,15 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
312
340
  server.once('error', reject)
313
341
  server.listen(0, '127.0.0.1', () => resolve())
314
342
  })
343
+ // A later server error (rare on a loopback ephemeral port) must not crash
344
+ // the process; the client's fail path handles the actual recovery.
345
+ server.on('error', error => {
346
+ process.stderr.write(`[dsh-browser host] rpc server error: ${String(error)}\n`)
347
+ })
315
348
  const address = server.address()
316
349
  const port = typeof address === 'object' && address !== null ? address.port : 0
317
350
  this.server = server
318
- this.client = new ElectronChildClient(this.hostMainPath, port)
351
+ this.client = new ElectronChildClient(this.hostMainPath, port, () => this.onChildExit())
319
352
  if (this.pendingSocket !== undefined) {
320
353
  this.client.attach(this.pendingSocket)
321
354
  this.pendingSocket = undefined
@@ -324,6 +357,18 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
324
357
  await withTimeout(this.client.call('ping'), READY_TIMEOUT_MS, 'browser host did not become ready')
325
358
  }
326
359
 
360
+ /** The child died: tear down so the next use starts a fresh child. */
361
+ private onChildExit(): void {
362
+ if (this.disposed) return
363
+ this.client = undefined
364
+ this.server?.close()
365
+ this.server = undefined
366
+ this.pendingSocket = undefined
367
+ this.readyPromise = undefined
368
+ // Keep the views map: handles still resolve to ids; a fresh child simply
369
+ // has no such views yet, and reset_session reopens clean sessions.
370
+ }
371
+
327
372
  createView(): ElectronViewHandle {
328
373
  // The seam is synchronous; the provider uses the handle immediately, so
329
374
  // commands are deferred until the child is up and the view materialized.
@@ -344,7 +389,11 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
344
389
  }
345
390
 
346
391
  showView(handle: ElectronViewHandle): void {
347
- void this.ready().then(() => this.client?.call('showView', { viewId: handle.id }))
392
+ // Fire-and-forget by design (visibility is best-effort), but a rejected
393
+ // promise must not become an unhandled rejection (crash on Node >= 15).
394
+ void this.ready()
395
+ .then(() => this.client?.call('showView', { viewId: handle.id }))
396
+ .catch(() => { /* host unavailable */ })
348
397
  }
349
398
 
350
399
  destroyView(handle: ElectronViewHandle): void {
@@ -358,6 +407,7 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
358
407
 
359
408
  /** Shut the child and the RPC server down. */
360
409
  dispose(): void {
410
+ this.disposed = true
361
411
  this.client?.kill()
362
412
  this.client = undefined
363
413
  this.server?.close()
package/src/index.ts CHANGED
@@ -1,22 +1,26 @@
1
1
  /**
2
- * dsh-browser plugin entry: aggregates the shared-browser capability pieces.
3
- * The cordis.patch.yml rows reference subpath exports:
4
- * - `dsh-browser/browser` -> the ctx.browser seam (Service Definition)
5
- * - `dsh-browser/browser-electron` -> the Electron CDP provider
6
- * - `dsh-browser/tool-browser` -> the model-facing browser_* tools
2
+ * dsh-builtin-browser plugin entry: aggregates the shared-browser capability
3
+ * pieces. The cordis.patch.yml rows reference subpath exports:
4
+ * - `dsh-builtin-browser/browser` -> the ctx.browser seam (Service)
5
+ * - `dsh-builtin-browser/browser-electron` -> the Electron CDP provider
6
+ * - `dsh-builtin-browser/tool-browser` -> the model-facing browser_* tools
7
7
  * This root entry only re-exports for programmatic use; the loader rows are
8
8
  * the composition surface.
9
- * @module dsh-browser
9
+ * @module dsh-builtin-browser
10
10
  */
11
11
 
12
12
  export { BrowserError } from './browser/types.ts'
13
13
  export type {
14
+ BrowserChallenge,
14
15
  BrowserClickRequest,
15
16
  BrowserContentFormat,
16
17
  BrowserContentRequest,
17
18
  BrowserContentResult,
18
19
  BrowserExecuteRequest,
19
20
  BrowserExecuteResult,
21
+ BrowserFillField,
22
+ BrowserFillRequest,
23
+ BrowserFillResult,
20
24
  BrowserNavigateRequest,
21
25
  BrowserOpenRequest,
22
26
  BrowserProvider,
@@ -27,7 +31,9 @@ export type {
27
31
  BrowserSnapshotResult,
28
32
  BrowserTab,
29
33
  BrowserTypeRequest,
34
+ ExportedCookie,
30
35
  } from './browser/types.ts'
31
36
  export { BrowserRuntime } from './browser/runtime.ts'
32
37
  export { ElectronBrowserProvider } from './browser-electron/provider.ts'
33
38
  export type { ElectronBrowserViewHost, ElectronViewHandle } from './browser-electron/provider.ts'
39
+ export { RemoteElectronViewHost, defaultHostMainPath } from './browser-electron/remote-host.ts'