dsh-builtin-browser 0.1.21 → 0.1.22
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/README.en.md +18 -10
- package/README.md +18 -10
- package/docs/README.md +1 -1
- package/docs/architecture.md +2 -2
- package/docs/tool-reference.md +2 -2
- package/docs/user-guide.md +6 -6
- package/lib/browser-electron/host-main.js +97 -15
- package/lib/browser-electron/provider.d.ts +82 -8
- package/lib/browser-electron/provider.js +256 -38
- package/lib/browser-electron/remote-host.d.ts +25 -0
- package/lib/browser-electron/remote-host.js +97 -15
- package/lib/tool-browser/index.js +2 -2
- package/package.json +1 -1
- package/src/browser-electron/host-main.ts +106 -9
- package/src/browser-electron/provider.ts +284 -38
- package/src/browser-electron/remote-host.ts +102 -15
- package/src/tool-browser/index.ts +2 -2
|
@@ -37,6 +37,20 @@ export interface ElectronBrowserViewHost {
|
|
|
37
37
|
* tell which task's page is currently visible.
|
|
38
38
|
*/
|
|
39
39
|
showView?(handle: ElectronViewHandle, label?: string): void;
|
|
40
|
+
/**
|
|
41
|
+
* Optional: (re)present a view and WAIT until the host confirms it. Chromium
|
|
42
|
+
* silently drops CDP-synthesized input (`Input.*`) for a view that has no
|
|
43
|
+
* display surface, which made clicks/typing report success while the page
|
|
44
|
+
* received nothing (and made the first input after a navigation vanish,
|
|
45
|
+
* because the replaced renderer has no surface yet). Unlike
|
|
46
|
+
* {@link showView}, this round-trips an RPC barrier that is ordered AFTER
|
|
47
|
+
* the show on the same socket, so its reply means the view is on screen.
|
|
48
|
+
* Rejects when the view cannot be presented; a host without this method is
|
|
49
|
+
* assumed to present every view (headless/probe hosts).
|
|
50
|
+
* @param handle - the handle to present.
|
|
51
|
+
* @param label - human-readable session/task label, as in {@link showView}.
|
|
52
|
+
*/
|
|
53
|
+
presentView?(handle: ElectronViewHandle, label?: string): Promise<void>;
|
|
40
54
|
/**
|
|
41
55
|
* Optional cheap usability probe (no network): whether the host can back
|
|
42
56
|
* views at all right now. The self-hosted host checks for a usable Electron
|
|
@@ -226,6 +240,44 @@ export declare class ElectronBrowserProvider implements BrowserProvider {
|
|
|
226
240
|
navigate(session: BrowserSessionId, request: {
|
|
227
241
|
readonly url: string;
|
|
228
242
|
}, signal?: AbortSignal): Promise<void>;
|
|
243
|
+
/**
|
|
244
|
+
* A per-document stamp: `performance.timeOrigin` is unique per document load,
|
|
245
|
+
* so it tells a same-URL reload and an A→B→A redirect apart from the document
|
|
246
|
+
* that was current before the navigation. Empty string when unreadable.
|
|
247
|
+
*/
|
|
248
|
+
private documentStamp;
|
|
249
|
+
/**
|
|
250
|
+
* One cheap in-page reading of the document's identity and parse state.
|
|
251
|
+
*
|
|
252
|
+
* Returns null when the page did NOT answer (mid-commit, execution context
|
|
253
|
+
* destroyed) — the caller keeps waiting. A page that answered with an
|
|
254
|
+
* unexpected shape (an override or a non-conforming host; the production
|
|
255
|
+
* expression always yields a string) is reported as `unknown` rather than as
|
|
256
|
+
* "no answer": blocking the navigation for the whole budget on a page that
|
|
257
|
+
* demonstrably responded has no upside.
|
|
258
|
+
*/
|
|
259
|
+
private documentProbe;
|
|
260
|
+
/**
|
|
261
|
+
* Page.navigate resolves at navigation COMMIT, not at load: the new document
|
|
262
|
+
* is already current (so location.href and document.title are the new page's)
|
|
263
|
+
* while its DOM is still being parsed. browser_open snapshots immediately
|
|
264
|
+
* after navigating, which is why it could report the right title with zero
|
|
265
|
+
* interactive elements — and why a separate browser_snapshot right after
|
|
266
|
+
* always found them.
|
|
267
|
+
*
|
|
268
|
+
* Wait — bounded, best-effort — for the new document to settle: readyState
|
|
269
|
+
* must reach interactive/complete AND the document identity must have
|
|
270
|
+
* changed. A page that never settles must not fail a navigation that already
|
|
271
|
+
* succeeded, so a timeout here is swallowed.
|
|
272
|
+
*/
|
|
273
|
+
private settleDocument;
|
|
274
|
+
/**
|
|
275
|
+
* Make sure the active tab's view can actually receive synthesized input.
|
|
276
|
+
* Chromium drops `Input.*` events for a view with no display surface, so this
|
|
277
|
+
* runs before click/type/key and fails loudly (BROWSER_VIEW_NOT_PRESENTED)
|
|
278
|
+
* rather than reporting a success the page never saw.
|
|
279
|
+
*/
|
|
280
|
+
private present;
|
|
229
281
|
/** Execute JS in the active tab's page context. */
|
|
230
282
|
execute(session: BrowserSessionId, request: BrowserExecuteRequest, signal?: AbortSignal): Promise<BrowserExecuteResult>;
|
|
231
283
|
/**
|
|
@@ -320,13 +372,29 @@ export declare class ElectronBrowserProvider implements BrowserProvider {
|
|
|
320
372
|
*/
|
|
321
373
|
scrape(session: BrowserSessionId, request: BrowserScrapeRequest, signal?: AbortSignal): Promise<BrowserScrapeResult>;
|
|
322
374
|
/**
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
375
|
+
* Admit a caller-supplied save path for a file the browser writes to disk.
|
|
376
|
+
* ONE gate for both `browser_download` and `browser_screenshot`: the path
|
|
377
|
+
* must be absolute, must resolve inside `downloadDir`, and must not already
|
|
378
|
+
* exist. Without it a prompt-injected agent could write — or silently
|
|
379
|
+
* replace — any file the DSH process can reach, which also escapes the file
|
|
380
|
+
* sandbox every other tool in the set runs under.
|
|
381
|
+
* @param savePath - the caller's target path.
|
|
382
|
+
* @param kind - the operation, used in the error code and message.
|
|
383
|
+
* @returns the resolved absolute target path.
|
|
384
|
+
* @throws BrowserError when the path is relative, outside the directory, or occupied.
|
|
385
|
+
*/
|
|
386
|
+
private admitSavePath;
|
|
387
|
+
/**
|
|
388
|
+
* Download an HTTP(S) URL with the session's cookies to a local file.
|
|
389
|
+
* Admission is the shared {@link admitSavePath} gate; only the self-hosted
|
|
390
|
+
* host implements it (the desktop shell's embedded views delegate downloads
|
|
391
|
+
* to the real browser UI). Both admitted paths and screenshots are confined
|
|
392
|
+
* to `downloadDir`, so a prompt-injected agent cannot write arbitrary
|
|
393
|
+
* machine paths.
|
|
394
|
+
* @param session - the session whose cookies are used.
|
|
395
|
+
* @param request - the URL plus the absolute target path.
|
|
396
|
+
* @param signal - optional cancellation.
|
|
397
|
+
* @returns the path the file was written to.
|
|
330
398
|
*/
|
|
331
399
|
download(session: BrowserSessionId, request: {
|
|
332
400
|
readonly url: string;
|
|
@@ -346,7 +414,13 @@ export declare class ElectronBrowserProvider implements BrowserProvider {
|
|
|
346
414
|
readonly dataUrl: string;
|
|
347
415
|
readonly path?: string;
|
|
348
416
|
}>;
|
|
349
|
-
/**
|
|
417
|
+
/**
|
|
418
|
+
* Build the data URL and optionally write the image to disk. The caller's
|
|
419
|
+
* path goes through the SAME {@link admitSavePath} gate as a download — it
|
|
420
|
+
* must be absolute, resolve inside `downloadDir`, and not be an existing
|
|
421
|
+
* file — so a screenshot cannot be used to write to, or silently replace,
|
|
422
|
+
* files anywhere the DSH process happens to have permission (issue #13).
|
|
423
|
+
*/
|
|
350
424
|
private saveScreenshot;
|
|
351
425
|
/** Append one operation to the session's history. */
|
|
352
426
|
private record;
|
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
* @module dsh-browser/browser-electron
|
|
8
8
|
*/
|
|
9
9
|
import { randomUUID } from 'node:crypto';
|
|
10
|
-
import { writeFileSync } from 'node:fs';
|
|
10
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
11
11
|
import { homedir } from 'node:os';
|
|
12
|
-
import { isAbsolute, join, resolve, sep } from 'node:path';
|
|
12
|
+
import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
|
|
13
13
|
import { BrowserError } from '../browser/types.js';
|
|
14
14
|
/**
|
|
15
15
|
* Page-context human-verification (CAPTCHA / bot-detection) detection. Runs
|
|
@@ -95,6 +95,31 @@ const KEY_SPECS = {
|
|
|
95
95
|
};
|
|
96
96
|
/** Supported key names, exported for the tool's enum and error messages. */
|
|
97
97
|
export const BROWSER_KEY_NAMES = Object.keys(KEY_SPECS);
|
|
98
|
+
/**
|
|
99
|
+
* Localized names a system Downloads folder may carry, in probe order: the
|
|
100
|
+
* English default first, then the Chinese spellings — a zh-CN desktop calls
|
|
101
|
+
* the folder `下载`, a zh-TW one `下載`, and neither is `Downloads`.
|
|
102
|
+
*/
|
|
103
|
+
const DOWNLOAD_DIR_NAMES = ['Downloads', '下载', '下載'];
|
|
104
|
+
/**
|
|
105
|
+
* Resolve the default download directory when the config names none: an
|
|
106
|
+
* existing `XDG_DOWNLOAD_DIR` wins (the freedesktop standard, which is what a
|
|
107
|
+
* localized Linux desktop writes), then the first localized `Downloads` folder
|
|
108
|
+
* that exists under the home directory, and finally the English name — created
|
|
109
|
+
* on first use — when none exists yet.
|
|
110
|
+
* @returns the absolute default download directory.
|
|
111
|
+
*/
|
|
112
|
+
function defaultDownloadDir() {
|
|
113
|
+
const xdg = process.env.XDG_DOWNLOAD_DIR;
|
|
114
|
+
if (typeof xdg === 'string' && xdg !== '' && existsSync(xdg))
|
|
115
|
+
return xdg;
|
|
116
|
+
for (const name of DOWNLOAD_DIR_NAMES) {
|
|
117
|
+
const candidate = join(homedir(), name);
|
|
118
|
+
if (existsSync(candidate))
|
|
119
|
+
return candidate;
|
|
120
|
+
}
|
|
121
|
+
return join(homedir(), DOWNLOAD_DIR_NAMES[0] ?? 'Downloads');
|
|
122
|
+
}
|
|
98
123
|
/**
|
|
99
124
|
* Browser provider over Electron views. Sessions hold an ordered list of
|
|
100
125
|
* tabs; each tab is one view created by the host. The active tab receives
|
|
@@ -115,9 +140,12 @@ export class ElectronBrowserProvider {
|
|
|
115
140
|
this.httpOnly = config.httpOnly ?? true;
|
|
116
141
|
this.snapshotMaxElements = config.snapshotMaxElements ?? 60;
|
|
117
142
|
this.contentMaxChars = config.contentMaxChars ?? 100_000;
|
|
118
|
-
// Confine downloads to a dedicated directory by
|
|
119
|
-
// folder is the human-visible, browser-natural
|
|
120
|
-
|
|
143
|
+
// Confine downloads AND screenshot saves to a dedicated directory by
|
|
144
|
+
// default: the OS Downloads folder is the human-visible, browser-natural
|
|
145
|
+
// place for written files, and it is the one directory the DSH file
|
|
146
|
+
// sandbox does not have to arbitrate. The localized name is probed so a
|
|
147
|
+
// Chinese desktop (`~/下载`) works without configuring `downloadDir`.
|
|
148
|
+
this.downloadDir = config.downloadDir ?? defaultDownloadDir();
|
|
121
149
|
// Route toolbar (host UI) actions into the session model: the human and
|
|
122
150
|
// the agent then always drive the same tabs, history, and navigation.
|
|
123
151
|
this.host.onUserAction?.(action => { void this.handleUserAction(action); });
|
|
@@ -218,11 +246,16 @@ export class ElectronBrowserProvider {
|
|
|
218
246
|
*/
|
|
219
247
|
locateTab(session, tabId) {
|
|
220
248
|
const s = this.session(session);
|
|
221
|
-
|
|
249
|
+
// Accept the bare uuid as well as the "tab:<uuid>" form the tools print:
|
|
250
|
+
// ids are globally unique, so requiring the printed prefix only produced a
|
|
251
|
+
// misleading "is not open in this session" error that listed the very tab
|
|
252
|
+
// the caller had just named.
|
|
253
|
+
const wanted = stripTabPrefix(tabId);
|
|
254
|
+
const own = s.tabs.findIndex(tab => stripTabPrefix(tab.id) === wanted);
|
|
222
255
|
if (own >= 0)
|
|
223
256
|
return { s, index: own };
|
|
224
257
|
for (const other of this.sessions.values()) {
|
|
225
|
-
const index = other.tabs.findIndex(tab => tab.id ===
|
|
258
|
+
const index = other.tabs.findIndex(tab => stripTabPrefix(tab.id) === wanted);
|
|
226
259
|
if (index >= 0)
|
|
227
260
|
return { s: other, index };
|
|
228
261
|
}
|
|
@@ -263,6 +296,8 @@ export class ElectronBrowserProvider {
|
|
|
263
296
|
// evaluate paths so a wedged navigation surfaces as an error instead of
|
|
264
297
|
// blocking the tool call forever.
|
|
265
298
|
const timeoutMs = 30_000;
|
|
299
|
+
// Document identity of the page we are leaving (see settleDocument).
|
|
300
|
+
const before = await this.documentStamp(handle);
|
|
266
301
|
const result = await withTimeout(handle.sendCommand(CDP_PAGE_NAVIGATE, { url }), timeoutMs, signal, `browser: navigation timed out after ${timeoutMs}ms`,
|
|
267
302
|
// Best-effort: cancel the in-flight navigation so a wedged load does
|
|
268
303
|
// not leave the debugger queue busy.
|
|
@@ -273,7 +308,11 @@ export class ElectronBrowserProvider {
|
|
|
273
308
|
if (typeof errorText === 'string' && errorText !== '') {
|
|
274
309
|
throw new BrowserError(`browser: navigation to "${url}" failed: ${errorText}`, 'BROWSER_NAVIGATION_FAILED');
|
|
275
310
|
}
|
|
311
|
+
// Record as soon as the navigation has committed: history bookkeeping must
|
|
312
|
+
// not wait on the best-effort settle below (which only delays what the
|
|
313
|
+
// NEXT observation sees, not whether this action happened).
|
|
276
314
|
this.record(s, 'navigate', { url }, true);
|
|
315
|
+
await this.settleDocument(handle, before, signal);
|
|
277
316
|
this.showActive(s);
|
|
278
317
|
}
|
|
279
318
|
catch (error) {
|
|
@@ -283,6 +322,103 @@ export class ElectronBrowserProvider {
|
|
|
283
322
|
throw error;
|
|
284
323
|
}
|
|
285
324
|
}
|
|
325
|
+
/**
|
|
326
|
+
* A per-document stamp: `performance.timeOrigin` is unique per document load,
|
|
327
|
+
* so it tells a same-URL reload and an A→B→A redirect apart from the document
|
|
328
|
+
* that was current before the navigation. Empty string when unreadable.
|
|
329
|
+
*/
|
|
330
|
+
async documentStamp(handle) {
|
|
331
|
+
const probe = await this.documentProbe(handle);
|
|
332
|
+
return probe?.stamp ?? '';
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* One cheap in-page reading of the document's identity and parse state.
|
|
336
|
+
*
|
|
337
|
+
* Returns null when the page did NOT answer (mid-commit, execution context
|
|
338
|
+
* destroyed) — the caller keeps waiting. A page that answered with an
|
|
339
|
+
* unexpected shape (an override or a non-conforming host; the production
|
|
340
|
+
* expression always yields a string) is reported as `unknown` rather than as
|
|
341
|
+
* "no answer": blocking the navigation for the whole budget on a page that
|
|
342
|
+
* demonstrably responded has no upside.
|
|
343
|
+
*/
|
|
344
|
+
async documentProbe(handle) {
|
|
345
|
+
try {
|
|
346
|
+
const result = await withTimeout(handleSendEvaluate(handle, 'String(performance.timeOrigin) + "|" + document.readyState'), 3_000, undefined, 'browser: document probe timed out');
|
|
347
|
+
if (!result.ok)
|
|
348
|
+
return null;
|
|
349
|
+
if (typeof result.value !== 'string')
|
|
350
|
+
return { stamp: '', readyState: 'unknown' };
|
|
351
|
+
const [stamp = '', readyState = ''] = result.value.split('|');
|
|
352
|
+
return { stamp, readyState };
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Page.navigate resolves at navigation COMMIT, not at load: the new document
|
|
360
|
+
* is already current (so location.href and document.title are the new page's)
|
|
361
|
+
* while its DOM is still being parsed. browser_open snapshots immediately
|
|
362
|
+
* after navigating, which is why it could report the right title with zero
|
|
363
|
+
* interactive elements — and why a separate browser_snapshot right after
|
|
364
|
+
* always found them.
|
|
365
|
+
*
|
|
366
|
+
* Wait — bounded, best-effort — for the new document to settle: readyState
|
|
367
|
+
* must reach interactive/complete AND the document identity must have
|
|
368
|
+
* changed. A page that never settles must not fail a navigation that already
|
|
369
|
+
* succeeded, so a timeout here is swallowed.
|
|
370
|
+
*/
|
|
371
|
+
async settleDocument(handle, before, signal) {
|
|
372
|
+
const started = Date.now();
|
|
373
|
+
const deadline = started + SETTLE_TIMEOUT_MS;
|
|
374
|
+
for (;;) {
|
|
375
|
+
if (signal?.aborted === true)
|
|
376
|
+
return;
|
|
377
|
+
// A probe failure is expected while the navigation commits (the context is
|
|
378
|
+
// being destroyed) — keep polling rather than giving up.
|
|
379
|
+
const probe = await this.documentProbe(handle);
|
|
380
|
+
if (probe !== null) {
|
|
381
|
+
const settled = probe.readyState === 'interactive'
|
|
382
|
+
|| probe.readyState === 'complete'
|
|
383
|
+
|| probe.readyState === 'unknown';
|
|
384
|
+
const isNewDocument = before === '' || probe.stamp === '' || probe.stamp !== before;
|
|
385
|
+
// The common case: the new document exists and is parsed — return now.
|
|
386
|
+
if (settled && isNewDocument)
|
|
387
|
+
return;
|
|
388
|
+
// Same document (hash navigation, cancelled/aborted load): there is no
|
|
389
|
+
// parse to wait for, so do not burn the budget. Outlast the brief
|
|
390
|
+
// window in which the OUTGOING document may still answer instead.
|
|
391
|
+
if (settled && Date.now() - started >= SETTLE_GRACE_MS)
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
if (Date.now() >= deadline)
|
|
395
|
+
return;
|
|
396
|
+
await delay(SETTLE_POLL_MS, signal);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Make sure the active tab's view can actually receive synthesized input.
|
|
401
|
+
* Chromium drops `Input.*` events for a view with no display surface, so this
|
|
402
|
+
* runs before click/type/key and fails loudly (BROWSER_VIEW_NOT_PRESENTED)
|
|
403
|
+
* rather than reporting a success the page never saw.
|
|
404
|
+
*/
|
|
405
|
+
async present(s, signal) {
|
|
406
|
+
const { handle } = this.activeTab(s);
|
|
407
|
+
const present = this.host.presentView?.bind(this.host);
|
|
408
|
+
if (present === undefined) {
|
|
409
|
+
this.showActive(s);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
signal?.throwIfAborted();
|
|
413
|
+
const timeoutMs = 10_000;
|
|
414
|
+
try {
|
|
415
|
+
await withTimeout(present(handle, s.label), timeoutMs, signal, `browser: presenting the page view timed out after ${timeoutMs}ms`);
|
|
416
|
+
this.showActive(s);
|
|
417
|
+
}
|
|
418
|
+
catch (error) {
|
|
419
|
+
throw new BrowserError(`browser: the page view is not presented, so synthesized input would be silently dropped (${String(error)})`, 'BROWSER_VIEW_NOT_PRESENTED', { cause: error });
|
|
420
|
+
}
|
|
421
|
+
}
|
|
286
422
|
/** Execute JS in the active tab's page context. */
|
|
287
423
|
async execute(session, request, signal) {
|
|
288
424
|
const s = this.session(session);
|
|
@@ -776,6 +912,9 @@ export class ElectronBrowserProvider {
|
|
|
776
912
|
x = request.x;
|
|
777
913
|
y = request.y;
|
|
778
914
|
}
|
|
915
|
+
// Input.* is only delivered to a view with a current display surface; the
|
|
916
|
+
// barrier also re-presents after a navigation replaced the renderer.
|
|
917
|
+
await this.present(s, signal);
|
|
779
918
|
const press = (type) => handle.sendCommand('Input.dispatchMouseEvent', { type, x, y, button: 'left', clickCount: 1 });
|
|
780
919
|
const timeoutMs = 30_000;
|
|
781
920
|
try {
|
|
@@ -817,6 +956,9 @@ export class ElectronBrowserProvider {
|
|
|
817
956
|
}
|
|
818
957
|
const text = 'text' in request ? request.text : '';
|
|
819
958
|
const timeoutMs = 30_000;
|
|
959
|
+
// Input.insertText goes through the Input domain: same display-surface
|
|
960
|
+
// requirement as click/key.
|
|
961
|
+
await this.present(s, signal);
|
|
820
962
|
try {
|
|
821
963
|
await withTimeout(handle.sendCommand('Input.insertText', { text }), timeoutMs, signal, `browser: type timed out after ${timeoutMs}ms`);
|
|
822
964
|
}
|
|
@@ -874,8 +1016,12 @@ export class ElectronBrowserProvider {
|
|
|
874
1016
|
const entry = entries[target];
|
|
875
1017
|
if (entry?.id === undefined)
|
|
876
1018
|
throw new BrowserError('browser: history entry missing id', 'BROWSER_HISTORY_INVALID');
|
|
1019
|
+
// A history step is a navigation too: the classic A→B→A redirect would
|
|
1020
|
+
// otherwise read the outgoing document's identity (see settleDocument).
|
|
1021
|
+
const before = await this.documentStamp(handle);
|
|
877
1022
|
await withTimeout(handle.sendCommand('Page.navigateToHistoryEntry', { entryId: entry.id }), timeoutMs, signal, `browser: history navigation timed out after ${timeoutMs}ms`, () => { void handle.sendCommand('Page.stopLoading').catch(() => { }); });
|
|
878
1023
|
this.record(s, direction === -1 ? 'back' : 'forward', {}, true);
|
|
1024
|
+
await this.settleDocument(handle, before, signal);
|
|
879
1025
|
this.showActive(s);
|
|
880
1026
|
}
|
|
881
1027
|
/** Go back in the active tab's history. */
|
|
@@ -892,10 +1038,14 @@ export class ElectronBrowserProvider {
|
|
|
892
1038
|
const { handle } = this.activeTab(s);
|
|
893
1039
|
signal?.throwIfAborted();
|
|
894
1040
|
const timeoutMs = 30_000;
|
|
1041
|
+
// Same-URL reload: only the per-load document identity can tell the new
|
|
1042
|
+
// document from the old one, so capture it before reloading.
|
|
1043
|
+
const before = await this.documentStamp(handle);
|
|
895
1044
|
await withTimeout(handle.sendCommand('Page.reload', { ignoreCache: false }), timeoutMs, signal, `browser: reload timed out after ${timeoutMs}ms`,
|
|
896
1045
|
// Best-effort: cancel the reload if it wedges the debugger queue.
|
|
897
1046
|
() => { void handle.sendCommand('Page.stopLoading').catch(() => { }); });
|
|
898
1047
|
this.record(s, 'reload', {}, true);
|
|
1048
|
+
await this.settleDocument(handle, before, signal);
|
|
899
1049
|
this.showActive(s);
|
|
900
1050
|
}
|
|
901
1051
|
/** Press one named key (Enter/Tab/arrows/…) via CDP key events. */
|
|
@@ -909,6 +1059,8 @@ export class ElectronBrowserProvider {
|
|
|
909
1059
|
}
|
|
910
1060
|
const params = { key: spec.key, code: spec.code, windowsVirtualKeyCode: spec.vk, nativeVirtualKeyCode: spec.vk, ...spec.text !== undefined ? { text: spec.text } : {} };
|
|
911
1061
|
const timeoutMs = 15_000;
|
|
1062
|
+
// Input.dispatchKeyEvent is subject to the same display-surface rule.
|
|
1063
|
+
await this.present(s, signal);
|
|
912
1064
|
const release = () => handle.sendCommand('Input.dispatchKeyEvent', { type: 'keyUp', ...params });
|
|
913
1065
|
try {
|
|
914
1066
|
await withTimeout(handle.sendCommand('Input.dispatchKeyEvent', { type: 'keyDown', ...params }), timeoutMs, signal, `browser: key press timed out after ${timeoutMs}ms`);
|
|
@@ -1357,13 +1509,53 @@ export class ElectronBrowserProvider {
|
|
|
1357
1509
|
return { count: items.length, items };
|
|
1358
1510
|
}
|
|
1359
1511
|
/**
|
|
1360
|
-
*
|
|
1361
|
-
*
|
|
1362
|
-
*
|
|
1363
|
-
*
|
|
1364
|
-
*
|
|
1365
|
-
*
|
|
1366
|
-
*
|
|
1512
|
+
* Admit a caller-supplied save path for a file the browser writes to disk.
|
|
1513
|
+
* ONE gate for both `browser_download` and `browser_screenshot`: the path
|
|
1514
|
+
* must be absolute, must resolve inside `downloadDir`, and must not already
|
|
1515
|
+
* exist. Without it a prompt-injected agent could write — or silently
|
|
1516
|
+
* replace — any file the DSH process can reach, which also escapes the file
|
|
1517
|
+
* sandbox every other tool in the set runs under.
|
|
1518
|
+
* @param savePath - the caller's target path.
|
|
1519
|
+
* @param kind - the operation, used in the error code and message.
|
|
1520
|
+
* @returns the resolved absolute target path.
|
|
1521
|
+
* @throws BrowserError when the path is relative, outside the directory, or occupied.
|
|
1522
|
+
*/
|
|
1523
|
+
admitSavePath(savePath, kind) {
|
|
1524
|
+
const code = kind === 'download' ? 'BROWSER_DOWNLOAD_BLOCKED' : 'BROWSER_SCREENSHOT_BLOCKED';
|
|
1525
|
+
if (!isAbsolute(savePath)) {
|
|
1526
|
+
throw new BrowserError(`browser: ${kind} savePath must be an absolute path`, code);
|
|
1527
|
+
}
|
|
1528
|
+
const file = resolve(savePath);
|
|
1529
|
+
if (this.downloadDir !== undefined) {
|
|
1530
|
+
const dir = resolve(this.downloadDir);
|
|
1531
|
+
// Case-insensitive comparison: Windows paths are case-insensitive, and
|
|
1532
|
+
// resolve() does not normalize case. Without this, C:\Users\X\Downloads
|
|
1533
|
+
// and c:\users\x\downloads would be treated as different roots.
|
|
1534
|
+
const dirLower = dir.toLowerCase();
|
|
1535
|
+
const fileLower = file.toLowerCase();
|
|
1536
|
+
if (fileLower !== dirLower && !fileLower.startsWith(dirLower + sep.toLowerCase())) {
|
|
1537
|
+
throw new BrowserError(`browser: ${kind} savePath must be inside downloadDir "${dir}"`, code);
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
// Never replace an existing file: its previous content is unrecoverable,
|
|
1541
|
+
// and the admitted directory may hold files the human put there. A new
|
|
1542
|
+
// name is one tool call away.
|
|
1543
|
+
if (existsSync(file)) {
|
|
1544
|
+
throw new BrowserError(`browser: refusing to overwrite existing file "${file}" — use another name`, code);
|
|
1545
|
+
}
|
|
1546
|
+
return file;
|
|
1547
|
+
}
|
|
1548
|
+
/**
|
|
1549
|
+
* Download an HTTP(S) URL with the session's cookies to a local file.
|
|
1550
|
+
* Admission is the shared {@link admitSavePath} gate; only the self-hosted
|
|
1551
|
+
* host implements it (the desktop shell's embedded views delegate downloads
|
|
1552
|
+
* to the real browser UI). Both admitted paths and screenshots are confined
|
|
1553
|
+
* to `downloadDir`, so a prompt-injected agent cannot write arbitrary
|
|
1554
|
+
* machine paths.
|
|
1555
|
+
* @param session - the session whose cookies are used.
|
|
1556
|
+
* @param request - the URL plus the absolute target path.
|
|
1557
|
+
* @param signal - optional cancellation.
|
|
1558
|
+
* @returns the path the file was written to.
|
|
1367
1559
|
*/
|
|
1368
1560
|
async download(session, request, signal) {
|
|
1369
1561
|
const s = this.session(session);
|
|
@@ -1382,23 +1574,9 @@ export class ElectronBrowserProvider {
|
|
|
1382
1574
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
1383
1575
|
throw new BrowserError(`browser: refusing download of non-HTTP(S) URL "${request.url}"`, 'BROWSER_DOWNLOAD_BLOCKED');
|
|
1384
1576
|
}
|
|
1385
|
-
// savePath admission:
|
|
1386
|
-
//
|
|
1387
|
-
|
|
1388
|
-
throw new BrowserError('browser: download savePath must be an absolute path', 'BROWSER_DOWNLOAD_BLOCKED');
|
|
1389
|
-
}
|
|
1390
|
-
if (this.downloadDir !== undefined) {
|
|
1391
|
-
const dir = resolve(this.downloadDir);
|
|
1392
|
-
const file = resolve(request.savePath);
|
|
1393
|
-
// Case-insensitive comparison: Windows paths are case-insensitive, and
|
|
1394
|
-
// resolve() does not normalize case. Without this, C:\Users\X\Downloads
|
|
1395
|
-
// and c:\users\x\downloads would be treated as different roots.
|
|
1396
|
-
const dirLower = dir.toLowerCase();
|
|
1397
|
-
const fileLower = file.toLowerCase();
|
|
1398
|
-
if (fileLower !== dirLower && !fileLower.startsWith(dirLower + sep.toLowerCase())) {
|
|
1399
|
-
throw new BrowserError(`browser: download savePath must be inside downloadDir "${dir}"`, 'BROWSER_DOWNLOAD_BLOCKED');
|
|
1400
|
-
}
|
|
1401
|
-
}
|
|
1577
|
+
// savePath admission: the shared gate — absolute, inside `downloadDir`,
|
|
1578
|
+
// and never an existing file. Screenshots use the very same one.
|
|
1579
|
+
const savePath = this.admitSavePath(request.savePath, 'download');
|
|
1402
1580
|
const downloadable = handle;
|
|
1403
1581
|
if (typeof downloadable.download !== 'function') {
|
|
1404
1582
|
throw new BrowserError('browser: download is only available on the self-hosted browser', 'BROWSER_DOWNLOAD_UNSUPPORTED');
|
|
@@ -1410,9 +1588,9 @@ export class ElectronBrowserProvider {
|
|
|
1410
1588
|
// entry is cleaned up (the promise settles) and the child's eventual
|
|
1411
1589
|
// reply is harmlessly consumed by the RPC layer.
|
|
1412
1590
|
const timeoutMs = 60_000;
|
|
1413
|
-
await withTimeout(downloadable.download(request.url,
|
|
1414
|
-
this.record(s, 'download', { url: request.url, savePath
|
|
1415
|
-
return { path:
|
|
1591
|
+
await withTimeout(downloadable.download(request.url, savePath), timeoutMs, signal, `browser: download timed out after ${timeoutMs}ms`);
|
|
1592
|
+
this.record(s, 'download', { url: request.url, savePath }, true, { result: savePath });
|
|
1593
|
+
return { path: savePath };
|
|
1416
1594
|
}
|
|
1417
1595
|
/**
|
|
1418
1596
|
* Export the session's cookies (login state) as serializable objects.
|
|
@@ -1487,16 +1665,26 @@ export class ElectronBrowserProvider {
|
|
|
1487
1665
|
}
|
|
1488
1666
|
return this.saveScreenshot(data, request?.savePath, 'image/png');
|
|
1489
1667
|
}
|
|
1490
|
-
/**
|
|
1668
|
+
/**
|
|
1669
|
+
* Build the data URL and optionally write the image to disk. The caller's
|
|
1670
|
+
* path goes through the SAME {@link admitSavePath} gate as a download — it
|
|
1671
|
+
* must be absolute, resolve inside `downloadDir`, and not be an existing
|
|
1672
|
+
* file — so a screenshot cannot be used to write to, or silently replace,
|
|
1673
|
+
* files anywhere the DSH process happens to have permission (issue #13).
|
|
1674
|
+
*/
|
|
1491
1675
|
saveScreenshot(base64, savePath, mime) {
|
|
1492
1676
|
if (savePath !== undefined) {
|
|
1677
|
+
// Admission failures propagate unchanged: they describe the caller's
|
|
1678
|
+
// path, not a disk problem.
|
|
1679
|
+
const target = this.admitSavePath(savePath, 'screenshot');
|
|
1493
1680
|
try {
|
|
1494
|
-
|
|
1495
|
-
|
|
1681
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
1682
|
+
writeFileSync(target, Buffer.from(base64, 'base64'));
|
|
1683
|
+
return { dataUrl: `data:${mime};base64,${base64}`, path: target };
|
|
1496
1684
|
}
|
|
1497
1685
|
catch (error) {
|
|
1498
1686
|
// Report the write problem but keep the capture usable.
|
|
1499
|
-
throw new BrowserError(`browser: screenshot save to "${
|
|
1687
|
+
throw new BrowserError(`browser: screenshot save to "${target}" failed: ${String(error)}`, 'BROWSER_SCREENSHOT_SAVE_FAILED', { cause: error });
|
|
1500
1688
|
}
|
|
1501
1689
|
}
|
|
1502
1690
|
return { dataUrl: `data:${mime};base64,${base64}` };
|
|
@@ -1712,6 +1900,36 @@ export class ElectronBrowserProvider {
|
|
|
1712
1900
|
return result.ok && typeof result.value === 'string' ? result.value : '';
|
|
1713
1901
|
}
|
|
1714
1902
|
}
|
|
1903
|
+
/**
|
|
1904
|
+
* Hard cap on waiting for a navigation's new document to be parsed. Bounded and
|
|
1905
|
+
* best-effort: a page that never settles must not fail the navigation.
|
|
1906
|
+
*/
|
|
1907
|
+
const SETTLE_TIMEOUT_MS = 5_000;
|
|
1908
|
+
/**
|
|
1909
|
+
* When the document already looks settled but its identity has NOT changed
|
|
1910
|
+
* (same-document navigation, or the outgoing document still answering), wait
|
|
1911
|
+
* only this long before returning — there is no parse in flight to wait for.
|
|
1912
|
+
*/
|
|
1913
|
+
const SETTLE_GRACE_MS = 150;
|
|
1914
|
+
/** Poll interval for the document-settle loop. */
|
|
1915
|
+
const SETTLE_POLL_MS = 25;
|
|
1916
|
+
/** `tab:<uuid>` ↔ `<uuid>`: ids accept either form (see locateTab). */
|
|
1917
|
+
function stripTabPrefix(id) {
|
|
1918
|
+
return id.startsWith('tab:') ? id.slice(4) : id;
|
|
1919
|
+
}
|
|
1920
|
+
/** Abortable sleep used by the settle loop. */
|
|
1921
|
+
function delay(ms, signal) {
|
|
1922
|
+
return new Promise((resolve) => {
|
|
1923
|
+
const timer = setTimeout(() => {
|
|
1924
|
+
if (signal !== undefined)
|
|
1925
|
+
signal.removeEventListener('abort', onAbort);
|
|
1926
|
+
resolve();
|
|
1927
|
+
}, ms);
|
|
1928
|
+
const onAbort = () => { clearTimeout(timer); resolve(); };
|
|
1929
|
+
if (signal !== undefined)
|
|
1930
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
1931
|
+
});
|
|
1932
|
+
}
|
|
1715
1933
|
/**
|
|
1716
1934
|
* Bound a promise so a wedged CDP call surfaces as an error instead of
|
|
1717
1935
|
* hanging the tool call forever. The caller's signal, when provided, wins
|
|
@@ -83,6 +83,12 @@ declare function resolveElectronPathImpl(inputs: ElectronPathInputs): string;
|
|
|
83
83
|
* resources dir at all) likewise cannot run a script argument.
|
|
84
84
|
*/
|
|
85
85
|
declare function isBareElectron(exe: string): boolean;
|
|
86
|
+
/** Candidate executable layouts used by the Electron npm package on one platform. */
|
|
87
|
+
declare function electronDistCandidates(pkgRoot: string, platform?: NodeJS.Platform): string[];
|
|
88
|
+
/** From an electron package entry file, find the dist executable beside it. */
|
|
89
|
+
declare function electronExeBeside(entry: string, platform?: NodeJS.Platform): string | undefined;
|
|
90
|
+
/** From an electron package root, find its dist executable. */
|
|
91
|
+
declare function electronDistExe(pkgRoot: string, platform?: NodeJS.Platform): string | undefined;
|
|
86
92
|
/**
|
|
87
93
|
* Self-hosted view host: spawns the plugin's Electron child on first use and
|
|
88
94
|
* keeps it alive until dispose(). Fallback when no desktop shell provides
|
|
@@ -126,6 +132,22 @@ export declare class RemoteElectronViewHost implements ElectronBrowserViewHost {
|
|
|
126
132
|
createView(): ElectronViewHandle;
|
|
127
133
|
private ensureView;
|
|
128
134
|
showView(handle: ElectronViewHandle, label?: string): void;
|
|
135
|
+
/**
|
|
136
|
+
* (Re)present a view and WAIT for the child to confirm it, so the provider
|
|
137
|
+
* can dispatch CDP `Input.*` knowing the view has a display surface.
|
|
138
|
+
*
|
|
139
|
+
* Two things this fixes, both silent before:
|
|
140
|
+
* - showing a view that does not exist yet is a no-op in the child, so the
|
|
141
|
+
* view must be materialized first (a fresh tab's createView races the
|
|
142
|
+
* provider's showActive);
|
|
143
|
+
* - a view whose renderer was replaced by a navigation is \"already\n * visible\" and therefore skipped by the child's flicker-avoidance fast
|
|
144
|
+
* path, leaving the new renderer without a surface.
|
|
145
|
+
*
|
|
146
|
+
* The barrier ping is meaningful because the child handles messages strictly
|
|
147
|
+
* in order: its reply is queued behind the showView and so only arrives once
|
|
148
|
+
* the visibility change has been applied.
|
|
149
|
+
*/
|
|
150
|
+
presentView(handle: ElectronViewHandle, label?: string): Promise<void>;
|
|
129
151
|
/** Route a view into its session's own window (one window per session). */
|
|
130
152
|
groupView(handle: ElectronViewHandle, windowId: string, label?: string): void;
|
|
131
153
|
/** Register the provider's handler for user-initiated toolbar actions. */
|
|
@@ -144,6 +166,9 @@ export declare function defaultHostMainPath(): string;
|
|
|
144
166
|
* steps; `resolveElectronPath` is the full resolution order.
|
|
145
167
|
*/
|
|
146
168
|
export declare const internals: {
|
|
169
|
+
electronDistCandidates: typeof electronDistCandidates;
|
|
170
|
+
electronDistExe: typeof electronDistExe;
|
|
171
|
+
electronExeBeside: typeof electronExeBeside;
|
|
147
172
|
isBareElectron: typeof isBareElectron;
|
|
148
173
|
resolveElectronPath: typeof resolveElectronPath;
|
|
149
174
|
resolveElectronPathImpl: typeof resolveElectronPathImpl;
|