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.
- package/lib/browser-electron/host-main.js +24 -12
- package/lib/browser-electron/provider.js +11 -4
- package/lib/browser-electron/remote-host.js +52 -13
- package/package.json +1 -1
- package/src/browser-electron/host-main.ts +25 -12
- package/src/browser-electron/provider.ts +21 -4
- package/src/browser-electron/remote-host.ts +53 -13
|
@@ -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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
333
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
330
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
452
|
-
const view = await this.materialized
|
|
492
|
+
const view = await this.materializeOnce()
|
|
453
493
|
return view.restoreAuth(cookies)
|
|
454
494
|
}
|
|
455
495
|
}
|