dsh-builtin-browser 0.1.9 → 0.1.10

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.
@@ -18,6 +18,8 @@ import { createInterface } from 'node:readline';
18
18
  import { createConnection } from 'node:net';
19
19
  /** CDP protocol version attached to every view's debugger. */
20
20
  const CDP_VERSION = '1.3';
21
+ /** Download cap: the body is shipped base64 as one JSON line; bound the memory. */
22
+ const MAX_DOWNLOAD_BYTES = 256 * 1024 * 1024;
21
23
  /** Views by the id the parent assigned at createView time. */
22
24
  const views = new Map();
23
25
  /** The single browser window; created lazily on first createView. */
@@ -48,6 +50,9 @@ async function handle(op, msg) {
48
50
  window.on('closed', () => { window = undefined; });
49
51
  }
50
52
  const view = new WebContentsView();
53
+ // Attach the debugger BEFORE the view can be seen: an attach failure
54
+ // then leaves nothing in the window (no visible ghost view).
55
+ view.webContents.debugger.attach(CDP_VERSION);
51
56
  // New views start hidden: with several sessions/tabs only the shown
52
57
  // one may be visible (they stack in contentView child order).
53
58
  view.setVisible(false);
@@ -56,7 +61,6 @@ async function handle(op, msg) {
56
61
  view.setBounds({ x: 0, y: 0, width: width ?? 0, height: height ?? 0 });
57
62
  if (views.size === 0)
58
63
  view.setVisible(true);
59
- view.webContents.debugger.attach(CDP_VERSION);
60
64
  views.set(viewId, { webContentsView: view });
61
65
  reply(msg.id, { ok: true });
62
66
  return;
@@ -137,6 +141,7 @@ async function handle(op, msg) {
137
141
  if (!r.ok) throw new Error('HTTP ' + r.status)
138
142
  const b = await r.arrayBuffer()
139
143
  const bytes = new Uint8Array(b)
144
+ if (bytes.length > ${String(MAX_DOWNLOAD_BYTES)}) throw new Error('download too large (limit ' + ${String(MAX_DOWNLOAD_BYTES)} + ' bytes, got ' + bytes.length + ')')
140
145
  let bin = ''
141
146
  for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000))
142
147
  return btoa(bin)
@@ -162,16 +167,21 @@ async function handle(op, msg) {
162
167
  // Export the session's cookies so login state can be saved/restored
163
168
  // across browser hosts (or shared with another machine).
164
169
  const cookies = await entry.webContentsView.webContents.session.cookies.get({});
165
- const exported = cookies.map(c => ({
166
- url: `http${c.secure ? 's' : ''}://${c.domain.startsWith('.') ? c.domain.slice(1) : c.domain}${c.path}`,
167
- name: c.name,
168
- value: c.value,
169
- domain: c.domain,
170
- path: c.path,
171
- secure: c.secure,
172
- httpOnly: c.httpOnly,
173
- expirationDate: c.expirationDate,
174
- }));
170
+ const exported = cookies.map(c => {
171
+ const host = c.domain.startsWith('.') ? c.domain.slice(1) : c.domain;
172
+ // IPv6 literal domains need brackets in a URL (e.g. http://[::1]/).
173
+ const hostPart = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
174
+ return {
175
+ url: `http${c.secure ? 's' : ''}://${hostPart}${c.path}`,
176
+ name: c.name,
177
+ value: c.value,
178
+ domain: c.domain,
179
+ path: c.path,
180
+ secure: c.secure,
181
+ httpOnly: c.httpOnly,
182
+ expirationDate: c.expirationDate,
183
+ };
184
+ });
175
185
  reply(msg.id, { ok: true, result: { cookies: exported } });
176
186
  return;
177
187
  }
@@ -185,6 +195,7 @@ async function handle(op, msg) {
185
195
  throw new Error(`restoreAuth: unknown view ${viewId}`);
186
196
  if (!Array.isArray(cookies))
187
197
  throw new Error('restoreAuth missing cookies array');
198
+ let restored = 0;
188
199
  for (const c of cookies) {
189
200
  if (typeof c.url !== 'string' || typeof c.name !== 'string' || typeof c.value !== 'string')
190
201
  continue;
@@ -198,8 +209,9 @@ async function handle(op, msg) {
198
209
  ...typeof c.httpOnly === 'boolean' ? { httpOnly: c.httpOnly } : {},
199
210
  ...typeof c.expirationDate === 'number' ? { expirationDate: c.expirationDate } : {},
200
211
  });
212
+ restored++;
201
213
  }
202
- reply(msg.id, { ok: true, result: { restored: cookies.length } });
214
+ reply(msg.id, { ok: true, result: { restored } });
203
215
  return;
204
216
  }
205
217
  default:
@@ -531,7 +531,10 @@ export class ElectronBrowserProvider {
531
531
  if (typeof downloadable.download !== 'function') {
532
532
  throw new BrowserError('browser: download is only available on the self-hosted browser', 'BROWSER_DOWNLOAD_UNSUPPORTED');
533
533
  }
534
- await downloadable.download(request.url, request.savePath);
534
+ // The child fetches in-page with awaitPromise; a slow/hung network can
535
+ // block it well past the tool budget, so bound it like every other call.
536
+ const timeoutMs = 60_000;
537
+ await withTimeout(downloadable.download(request.url, request.savePath), timeoutMs, signal, `browser: download timed out after ${timeoutMs}ms`);
535
538
  this.record(s, 'download', { url: request.url, savePath: request.savePath }, true, { result: request.savePath });
536
539
  return { path: request.savePath };
537
540
  }
@@ -546,7 +549,8 @@ export class ElectronBrowserProvider {
546
549
  if (typeof host.flushAuth !== 'function') {
547
550
  throw new BrowserError('browser: auth export is only available on the self-hosted browser', 'BROWSER_AUTH_UNSUPPORTED');
548
551
  }
549
- const cookies = await host.flushAuth();
552
+ const timeoutMs = 30_000;
553
+ const cookies = await withTimeout(host.flushAuth(), timeoutMs, undefined, `browser: auth export timed out after ${timeoutMs}ms`);
550
554
  this.record(s, 'flushAuth', {}, true, { result: `${cookies.length} cookies` });
551
555
  return cookies;
552
556
  }
@@ -558,7 +562,8 @@ export class ElectronBrowserProvider {
558
562
  if (typeof host.restoreAuth !== 'function') {
559
563
  throw new BrowserError('browser: auth restore is only available on the self-hosted browser', 'BROWSER_AUTH_UNSUPPORTED');
560
564
  }
561
- const restored = await host.restoreAuth(cookies);
565
+ const timeoutMs = 30_000;
566
+ const restored = await withTimeout(host.restoreAuth(cookies), timeoutMs, undefined, `browser: auth restore timed out after ${timeoutMs}ms`);
562
567
  this.record(s, 'restoreAuth', { count: cookies.length }, true, { result: `${restored} cookies` });
563
568
  return restored;
564
569
  }
@@ -706,7 +711,9 @@ export class ElectronBrowserProvider {
706
711
  }
707
712
  /** Read the current URL of a view through CDP. */
708
713
  async currentUrl(handle) {
709
- const result = await handleSendEvaluate(handle, 'location.href');
714
+ // Bound the read: a wedged renderer would otherwise hang listTabs.
715
+ const timeoutMs = 10_000;
716
+ const result = await withTimeout(handleSendEvaluate(handle, 'location.href'), timeoutMs, undefined, `browser: url read timed out after ${timeoutMs}ms`);
710
717
  return result.ok && typeof result.value === 'string' ? result.value : '';
711
718
  }
712
719
  }
@@ -23,6 +23,8 @@ import { createServer } from 'node:net';
23
23
  import { fileURLToPath } from 'node:url';
24
24
  /** How long to wait for the child to signal readiness before failing. */
25
25
  const READY_TIMEOUT_MS = 20_000;
26
+ /** Safety cap on a single RPC reply line (base64 downloads are the big ones). */
27
+ const MAX_RPC_BUFFER_BYTES = 512 * 1024 * 1024;
26
28
  /**
27
29
  * Locate the Electron binary, trying, in order:
28
30
  * 1. require('electron') from this module (optional peer dependency),
@@ -227,6 +229,14 @@ class ElectronChildClient {
227
229
  }
228
230
  onData(chunk) {
229
231
  this.buffer += typeof chunk === 'string' ? chunk : chunk.toString('utf8');
232
+ // Safety net: a pathological child (or a reply larger than expected)
233
+ // must not grow the parent's memory without bound. The child caps
234
+ // downloads at 256 MiB, so a healthy stream never approaches this.
235
+ if (this.buffer.length > MAX_RPC_BUFFER_BYTES) {
236
+ this.buffer = '';
237
+ this.fail(new Error(`dsh-builtin-browser: RPC reply exceeded ${MAX_RPC_BUFFER_BYTES} bytes`));
238
+ return;
239
+ }
230
240
  let nl;
231
241
  while ((nl = this.buffer.indexOf('\n')) >= 0) {
232
242
  const line = this.buffer.slice(0, nl).trim();
@@ -329,8 +339,22 @@ export class RemoteElectronViewHost {
329
339
  ready() {
330
340
  if (this.readyPromise !== undefined)
331
341
  return this.readyPromise;
332
- this.readyPromise = this.start();
333
- return this.readyPromise;
342
+ const started = this.start();
343
+ const wrapped = started.catch(error => {
344
+ // A failed startup must not poison the host forever: tear down whatever
345
+ // was half-created and let the next call retry from scratch.
346
+ if (this.readyPromise === wrapped) {
347
+ this.readyPromise = undefined;
348
+ this.client?.kill();
349
+ this.client = undefined;
350
+ this.server?.close();
351
+ this.server = undefined;
352
+ this.pendingSocket = undefined;
353
+ }
354
+ throw error;
355
+ });
356
+ this.readyPromise = wrapped;
357
+ return wrapped;
334
358
  }
335
359
  async start() {
336
360
  // Listen on an ephemeral loopback port; the child connects back.
@@ -386,6 +410,11 @@ export class RemoteElectronViewHost {
386
410
  if (client === undefined)
387
411
  throw new Error('browser host unavailable');
388
412
  await client.call('createView', { viewId: id });
413
+ // If the view was destroyed while the createView RPC was in flight, do
414
+ // not re-insert a stale entry that would resurrect a dead child view.
415
+ if (this.views.get(id) === undefined) {
416
+ throw new Error('browser: view destroyed while starting');
417
+ }
389
418
  const view = new RemoteView(id, client);
390
419
  this.views.set(id, view);
391
420
  return view;
@@ -426,27 +455,37 @@ class DeferredRemoteView {
426
455
  this.id = id;
427
456
  this.materialize = materialize;
428
457
  }
458
+ /**
459
+ * Materialize once and cache: every sendCommand on the same handle must
460
+ * target the SAME child view (re-materializing would re-run createView and
461
+ * duplicate the window). A FAILED materialization is reset so a later call
462
+ * (e.g. after the host restarted) can retry instead of being poisoned.
463
+ */
464
+ materializeOnce() {
465
+ if (this.materialized === undefined) {
466
+ const pending = this.materialize();
467
+ this.materialized = pending.catch(error => {
468
+ if (this.materialized === pending)
469
+ this.materialized = undefined;
470
+ throw error;
471
+ });
472
+ }
473
+ return this.materialized;
474
+ }
429
475
  async sendCommand(method, params) {
430
- // Materialize once and cache: every sendCommand on the same handle must
431
- // target the SAME child view. Re-materializing would re-run createView
432
- // (duplicate window) and drop the established view.
433
- this.materialized ??= this.materialize();
434
- const view = await this.materialized;
476
+ const view = await this.materializeOnce();
435
477
  return view.sendCommand(method, params);
436
478
  }
437
479
  async download(url, savePath) {
438
- this.materialized ??= this.materialize();
439
- const view = await this.materialized;
480
+ const view = await this.materializeOnce();
440
481
  return view.download(url, savePath);
441
482
  }
442
483
  async flushAuth() {
443
- this.materialized ??= this.materialize();
444
- const view = await this.materialized;
484
+ const view = await this.materializeOnce();
445
485
  return view.flushAuth();
446
486
  }
447
487
  async restoreAuth(cookies) {
448
- this.materialized ??= this.materialize();
449
- const view = await this.materialized;
488
+ const view = await this.materializeOnce();
450
489
  return view.restoreAuth(cookies);
451
490
  }
452
491
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-builtin-browser",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
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",
@@ -21,6 +21,9 @@ import { createConnection } from 'node:net'
21
21
  /** CDP protocol version attached to every view's debugger. */
22
22
  const CDP_VERSION = '1.3'
23
23
 
24
+ /** Download cap: the body is shipped base64 as one JSON line; bound the memory. */
25
+ const MAX_DOWNLOAD_BYTES = 256 * 1024 * 1024
26
+
24
27
  /** One view: the Electron object plus its CDP-backed surface. */
25
28
  interface HostView {
26
29
  readonly webContentsView: WebContentsView
@@ -59,6 +62,9 @@ async function handle(op: string, msg: { id: number; viewId?: string; method?: s
59
62
  window.on('closed', () => { window = undefined })
60
63
  }
61
64
  const view = new WebContentsView()
65
+ // Attach the debugger BEFORE the view can be seen: an attach failure
66
+ // then leaves nothing in the window (no visible ghost view).
67
+ view.webContents.debugger.attach(CDP_VERSION)
62
68
  // New views start hidden: with several sessions/tabs only the shown
63
69
  // one may be visible (they stack in contentView child order).
64
70
  view.setVisible(false)
@@ -66,7 +72,6 @@ async function handle(op: string, msg: { id: number; viewId?: string; method?: s
66
72
  const [width, height] = window.getContentSize()
67
73
  view.setBounds({ x: 0, y: 0, width: width ?? 0, height: height ?? 0 })
68
74
  if (views.size === 0) view.setVisible(true)
69
- view.webContents.debugger.attach(CDP_VERSION)
70
75
  views.set(viewId, { webContentsView: view })
71
76
  reply(msg.id, { ok: true })
72
77
  return
@@ -134,6 +139,7 @@ async function handle(op: string, msg: { id: number; viewId?: string; method?: s
134
139
  if (!r.ok) throw new Error('HTTP ' + r.status)
135
140
  const b = await r.arrayBuffer()
136
141
  const bytes = new Uint8Array(b)
142
+ if (bytes.length > ${String(MAX_DOWNLOAD_BYTES)}) throw new Error('download too large (limit ' + ${String(MAX_DOWNLOAD_BYTES)} + ' bytes, got ' + bytes.length + ')')
137
143
  let bin = ''
138
144
  for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000))
139
145
  return btoa(bin)
@@ -157,16 +163,21 @@ async function handle(op: string, msg: { id: number; viewId?: string; method?: s
157
163
  // Export the session's cookies so login state can be saved/restored
158
164
  // across browser hosts (or shared with another machine).
159
165
  const cookies = await entry.webContentsView.webContents.session.cookies.get({})
160
- const exported = cookies.map(c => ({
161
- url: `http${c.secure ? 's' : ''}://${c.domain.startsWith('.') ? c.domain.slice(1) : c.domain}${c.path}`,
162
- name: c.name,
163
- value: c.value,
164
- domain: c.domain,
165
- path: c.path,
166
- secure: c.secure,
167
- httpOnly: c.httpOnly,
168
- expirationDate: c.expirationDate,
169
- }))
166
+ const exported = cookies.map(c => {
167
+ const host = c.domain.startsWith('.') ? c.domain.slice(1) : c.domain
168
+ // IPv6 literal domains need brackets in a URL (e.g. http://[::1]/).
169
+ const hostPart = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host
170
+ return {
171
+ url: `http${c.secure ? 's' : ''}://${hostPart}${c.path}`,
172
+ name: c.name,
173
+ value: c.value,
174
+ domain: c.domain,
175
+ path: c.path,
176
+ secure: c.secure,
177
+ httpOnly: c.httpOnly,
178
+ expirationDate: c.expirationDate,
179
+ }
180
+ })
170
181
  reply(msg.id, { ok: true, result: { cookies: exported } })
171
182
  return
172
183
  }
@@ -177,6 +188,7 @@ async function handle(op: string, msg: { id: number; viewId?: string; method?: s
177
188
  const entry = views.get(viewId)
178
189
  if (entry === undefined) throw new Error(`restoreAuth: unknown view ${viewId}`)
179
190
  if (!Array.isArray(cookies)) throw new Error('restoreAuth missing cookies array')
191
+ let restored = 0
180
192
  for (const c of cookies as Array<{ url?: string; name?: string; value?: string; domain?: string; path?: string; secure?: boolean; httpOnly?: boolean; expirationDate?: number }>) {
181
193
  if (typeof c.url !== 'string' || typeof c.name !== 'string' || typeof c.value !== 'string') continue
182
194
  await entry.webContentsView.webContents.session.cookies.set({
@@ -189,8 +201,9 @@ async function handle(op: string, msg: { id: number; viewId?: string; method?: s
189
201
  ...typeof c.httpOnly === 'boolean' ? { httpOnly: c.httpOnly } : {},
190
202
  ...typeof c.expirationDate === 'number' ? { expirationDate: c.expirationDate } : {},
191
203
  })
204
+ restored++
192
205
  }
193
- reply(msg.id, { ok: true, result: { restored: (cookies as unknown[]).length } })
206
+ reply(msg.id, { ok: true, result: { restored } })
194
207
  return
195
208
  }
196
209
  default:
@@ -693,7 +693,15 @@ export class ElectronBrowserProvider implements BrowserProvider {
693
693
  if (typeof downloadable.download !== 'function') {
694
694
  throw new BrowserError('browser: download is only available on the self-hosted browser', 'BROWSER_DOWNLOAD_UNSUPPORTED')
695
695
  }
696
- await downloadable.download(request.url, request.savePath)
696
+ // The child fetches in-page with awaitPromise; a slow/hung network can
697
+ // block it well past the tool budget, so bound it like every other call.
698
+ const timeoutMs = 60_000
699
+ await withTimeout(
700
+ downloadable.download(request.url, request.savePath),
701
+ timeoutMs,
702
+ signal,
703
+ `browser: download timed out after ${timeoutMs}ms`,
704
+ )
697
705
  this.record(s, 'download', { url: request.url, savePath: request.savePath }, true, { result: request.savePath })
698
706
  return { path: request.savePath }
699
707
  }
@@ -709,7 +717,8 @@ export class ElectronBrowserProvider implements BrowserProvider {
709
717
  if (typeof host.flushAuth !== 'function') {
710
718
  throw new BrowserError('browser: auth export is only available on the self-hosted browser', 'BROWSER_AUTH_UNSUPPORTED')
711
719
  }
712
- const cookies = await host.flushAuth()
720
+ const timeoutMs = 30_000
721
+ const cookies = await withTimeout(host.flushAuth(), timeoutMs, undefined, `browser: auth export timed out after ${timeoutMs}ms`)
713
722
  this.record(s, 'flushAuth', {}, true, { result: `${cookies.length} cookies` })
714
723
  return cookies
715
724
  }
@@ -722,7 +731,8 @@ export class ElectronBrowserProvider implements BrowserProvider {
722
731
  if (typeof host.restoreAuth !== 'function') {
723
732
  throw new BrowserError('browser: auth restore is only available on the self-hosted browser', 'BROWSER_AUTH_UNSUPPORTED')
724
733
  }
725
- const restored = await host.restoreAuth(cookies)
734
+ const timeoutMs = 30_000
735
+ const restored = await withTimeout(host.restoreAuth(cookies), timeoutMs, undefined, `browser: auth restore timed out after ${timeoutMs}ms`)
726
736
  this.record(s, 'restoreAuth', { count: cookies.length }, true, { result: `${restored} cookies` })
727
737
  return restored
728
738
  }
@@ -887,7 +897,14 @@ export class ElectronBrowserProvider implements BrowserProvider {
887
897
 
888
898
  /** Read the current URL of a view through CDP. */
889
899
  private async currentUrl(handle: ElectronViewHandle): Promise<string> {
890
- const result = await handleSendEvaluate(handle, 'location.href')
900
+ // Bound the read: a wedged renderer would otherwise hang listTabs.
901
+ const timeoutMs = 10_000
902
+ const result = await withTimeout(
903
+ handleSendEvaluate(handle, 'location.href'),
904
+ timeoutMs,
905
+ undefined,
906
+ `browser: url read timed out after ${timeoutMs}ms`,
907
+ )
891
908
  return result.ok && typeof result.value === 'string' ? result.value : ''
892
909
  }
893
910
  }
@@ -28,6 +28,9 @@ import type { ExportedCookie } from '../browser/types.ts'
28
28
  /** How long to wait for the child to signal readiness before failing. */
29
29
  const READY_TIMEOUT_MS = 20_000
30
30
 
31
+ /** Safety cap on a single RPC reply line (base64 downloads are the big ones). */
32
+ const MAX_RPC_BUFFER_BYTES = 512 * 1024 * 1024
33
+
31
34
  /**
32
35
  * Locate the Electron binary, trying, in order:
33
36
  * 1. require('electron') from this module (optional peer dependency),
@@ -233,6 +236,14 @@ class ElectronChildClient {
233
236
 
234
237
  private onData(chunk: string | Buffer): void {
235
238
  this.buffer += typeof chunk === 'string' ? chunk : chunk.toString('utf8')
239
+ // Safety net: a pathological child (or a reply larger than expected)
240
+ // must not grow the parent's memory without bound. The child caps
241
+ // downloads at 256 MiB, so a healthy stream never approaches this.
242
+ if (this.buffer.length > MAX_RPC_BUFFER_BYTES) {
243
+ this.buffer = ''
244
+ this.fail(new Error(`dsh-builtin-browser: RPC reply exceeded ${MAX_RPC_BUFFER_BYTES} bytes`))
245
+ return
246
+ }
236
247
  let nl: number
237
248
  while ((nl = this.buffer.indexOf('\n')) >= 0) {
238
249
  const line = this.buffer.slice(0, nl).trim()
@@ -326,8 +337,22 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
326
337
  /** Ensure the child is up and ready (lazy on first use; restarts after a crash). */
327
338
  private ready(): Promise<void> {
328
339
  if (this.readyPromise !== undefined) return this.readyPromise
329
- this.readyPromise = this.start()
330
- return this.readyPromise
340
+ const started = this.start()
341
+ const wrapped = started.catch(error => {
342
+ // A failed startup must not poison the host forever: tear down whatever
343
+ // was half-created and let the next call retry from scratch.
344
+ if (this.readyPromise === wrapped) {
345
+ this.readyPromise = undefined
346
+ this.client?.kill()
347
+ this.client = undefined
348
+ this.server?.close()
349
+ this.server = undefined
350
+ this.pendingSocket = undefined
351
+ }
352
+ throw error
353
+ })
354
+ this.readyPromise = wrapped
355
+ return wrapped
331
356
  }
332
357
 
333
358
  private async start(): Promise<void> {
@@ -383,6 +408,11 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
383
408
  const client = this.client
384
409
  if (client === undefined) throw new Error('browser host unavailable')
385
410
  await client.call('createView', { viewId: id })
411
+ // If the view was destroyed while the createView RPC was in flight, do
412
+ // not re-insert a stale entry that would resurrect a dead child view.
413
+ if (this.views.get(id) === undefined) {
414
+ throw new Error('browser: view destroyed while starting')
415
+ }
386
416
  const view = new RemoteView(id, client)
387
417
  this.views.set(id, view)
388
418
  return view
@@ -426,30 +456,40 @@ class DeferredRemoteView implements ElectronViewHandle {
426
456
  private readonly materialize: () => Promise<RemoteView>,
427
457
  ) {}
428
458
 
459
+ /**
460
+ * Materialize once and cache: every sendCommand on the same handle must
461
+ * target the SAME child view (re-materializing would re-run createView and
462
+ * duplicate the window). A FAILED materialization is reset so a later call
463
+ * (e.g. after the host restarted) can retry instead of being poisoned.
464
+ */
465
+ private materializeOnce(): Promise<RemoteView> {
466
+ if (this.materialized === undefined) {
467
+ const pending = this.materialize()
468
+ this.materialized = pending.catch(error => {
469
+ if (this.materialized === pending) this.materialized = undefined
470
+ throw error
471
+ })
472
+ }
473
+ return this.materialized
474
+ }
475
+
429
476
  async sendCommand(method: string, params?: Record<string, unknown>): Promise<Record<string, unknown>> {
430
- // Materialize once and cache: every sendCommand on the same handle must
431
- // target the SAME child view. Re-materializing would re-run createView
432
- // (duplicate window) and drop the established view.
433
- this.materialized ??= this.materialize()
434
- const view = await this.materialized
477
+ const view = await this.materializeOnce()
435
478
  return view.sendCommand(method, params)
436
479
  }
437
480
 
438
481
  async download(url: string, savePath: string): Promise<void> {
439
- this.materialized ??= this.materialize()
440
- const view = await this.materialized
482
+ const view = await this.materializeOnce()
441
483
  return view.download(url, savePath)
442
484
  }
443
485
 
444
486
  async flushAuth(): Promise<ExportedCookie[]> {
445
- this.materialized ??= this.materialize()
446
- const view = await this.materialized
487
+ const view = await this.materializeOnce()
447
488
  return view.flushAuth()
448
489
  }
449
490
 
450
491
  async restoreAuth(cookies: ExportedCookie[]): Promise<number> {
451
- this.materialized ??= this.materialize()
452
- const view = await this.materialized
492
+ const view = await this.materializeOnce()
453
493
  return view.restoreAuth(cookies)
454
494
  }
455
495
  }