dsh-builtin-browser 0.1.21 → 0.1.23
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 +22 -10
- package/README.md +22 -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/runtime.js +9 -1
- package/lib/browser-electron/host-main.d.ts +1 -0
- package/lib/browser-electron/host-main.js +157 -15
- package/lib/browser-electron/provider.d.ts +107 -12
- package/lib/browser-electron/provider.js +340 -50
- package/lib/browser-electron/remote-host.d.ts +46 -6
- package/lib/browser-electron/remote-host.js +147 -33
- package/lib/tool-browser/index.js +2 -2
- package/package.json +8 -6
- package/src/browser/runtime.ts +15 -1
- package/src/browser-electron/host-main.ts +163 -9
- package/src/browser-electron/provider.ts +385 -55
- package/src/browser-electron/remote-host.ts +149 -31
- package/src/tool-browser/index.ts +2 -2
|
@@ -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,124 @@ 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
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Give the view web focus before synthesizing keyboard input.
|
|
424
|
+
*
|
|
425
|
+
* A renderer only delivers key events to the view that holds web focus. A
|
|
426
|
+
* view that was created but never clicked holds none — and `Input` events
|
|
427
|
+
* injected over CDP do not grant it — so the FIRST `browser_key` of a fresh
|
|
428
|
+
* session vanished inside the renderer while the command still answered
|
|
429
|
+
* success. Hosts that cannot focus a view (a test double, a different shell
|
|
430
|
+
* adapter) simply have no `focus`, and nothing changes for them.
|
|
431
|
+
*/
|
|
432
|
+
async focusView(handle) {
|
|
433
|
+
if (handle.focus === undefined)
|
|
434
|
+
return;
|
|
435
|
+
try {
|
|
436
|
+
await handle.focus();
|
|
437
|
+
}
|
|
438
|
+
catch {
|
|
439
|
+
// Best-effort: a view that refuses focus must not fail the caller's key
|
|
440
|
+
// press; the dispatch below reports a real channel failure itself.
|
|
441
|
+
}
|
|
442
|
+
}
|
|
286
443
|
/** Execute JS in the active tab's page context. */
|
|
287
444
|
async execute(session, request, signal) {
|
|
288
445
|
const s = this.session(session);
|
|
@@ -776,24 +933,51 @@ export class ElectronBrowserProvider {
|
|
|
776
933
|
x = request.x;
|
|
777
934
|
y = request.y;
|
|
778
935
|
}
|
|
779
|
-
|
|
936
|
+
// Input.* is only delivered to a view with a current display surface; the
|
|
937
|
+
// barrier also re-presents after a navigation replaced the renderer.
|
|
938
|
+
await this.present(s, signal);
|
|
780
939
|
const timeoutMs = 30_000;
|
|
940
|
+
const send = (params, label) => withTimeout(handle.sendCommand('Input.dispatchMouseEvent', params), timeoutMs, signal, `browser: click ${label} timed out after ${timeoutMs}ms`);
|
|
941
|
+
// Chromium routes a synthesized `mousePressed` to the widget's *current*
|
|
942
|
+
// hover target rather than hit-testing the coordinates, and a real pointer
|
|
943
|
+
// click is always preceded by movement. A view that has never received a
|
|
944
|
+
// mouse event has no hover target yet, so the press of the FIRST click on a
|
|
945
|
+
// fresh tab went nowhere while CDP still answered success; the second click
|
|
946
|
+
// landed because the first had quietly established the target. Move the
|
|
947
|
+
// pointer first so a click is self-contained.
|
|
781
948
|
try {
|
|
782
|
-
await
|
|
949
|
+
await send({ type: 'mouseMoved', x, y }, 'move');
|
|
950
|
+
}
|
|
951
|
+
catch (error) {
|
|
952
|
+
throw new BrowserError(`browser: click failed: ${String(error)}`, 'BROWSER_CLICK_FAILED', { cause: error });
|
|
953
|
+
}
|
|
954
|
+
const release = () => {
|
|
955
|
+
void handle
|
|
956
|
+
.sendCommand('Input.dispatchMouseEvent', {
|
|
957
|
+
type: 'mouseReleased',
|
|
958
|
+
x,
|
|
959
|
+
y,
|
|
960
|
+
button: 'left',
|
|
961
|
+
clickCount: 1,
|
|
962
|
+
})
|
|
963
|
+
.catch(() => { });
|
|
964
|
+
};
|
|
965
|
+
try {
|
|
966
|
+
await send({ type: 'mousePressed', x, y, button: 'left', clickCount: 1 }, 'press');
|
|
783
967
|
}
|
|
784
968
|
catch (error) {
|
|
785
969
|
// The press may still land late; release best-effort so the button is
|
|
786
970
|
// never left in a stuck pressed state.
|
|
787
|
-
|
|
971
|
+
release();
|
|
788
972
|
throw new BrowserError(`browser: click failed: ${String(error)}`, 'BROWSER_CLICK_FAILED', { cause: error });
|
|
789
973
|
}
|
|
790
974
|
try {
|
|
791
|
-
await
|
|
975
|
+
await send({ type: 'mouseReleased', x, y, button: 'left', clickCount: 1 }, 'release');
|
|
792
976
|
}
|
|
793
977
|
catch (error) {
|
|
794
978
|
// The press already landed; retry the release so the button is not
|
|
795
979
|
// left pressed before surfacing the failure.
|
|
796
|
-
|
|
980
|
+
release();
|
|
797
981
|
throw new BrowserError(`browser: click failed: ${String(error)}`, 'BROWSER_CLICK_FAILED', { cause: error });
|
|
798
982
|
}
|
|
799
983
|
this.record(s, 'click', 'target' in request ? { target: request.target } : { x, y }, true);
|
|
@@ -817,6 +1001,9 @@ export class ElectronBrowserProvider {
|
|
|
817
1001
|
}
|
|
818
1002
|
const text = 'text' in request ? request.text : '';
|
|
819
1003
|
const timeoutMs = 30_000;
|
|
1004
|
+
// Input.insertText goes through the Input domain: same display-surface
|
|
1005
|
+
// requirement as click/key.
|
|
1006
|
+
await this.present(s, signal);
|
|
820
1007
|
try {
|
|
821
1008
|
await withTimeout(handle.sendCommand('Input.insertText', { text }), timeoutMs, signal, `browser: type timed out after ${timeoutMs}ms`);
|
|
822
1009
|
}
|
|
@@ -874,8 +1061,12 @@ export class ElectronBrowserProvider {
|
|
|
874
1061
|
const entry = entries[target];
|
|
875
1062
|
if (entry?.id === undefined)
|
|
876
1063
|
throw new BrowserError('browser: history entry missing id', 'BROWSER_HISTORY_INVALID');
|
|
1064
|
+
// A history step is a navigation too: the classic A→B→A redirect would
|
|
1065
|
+
// otherwise read the outgoing document's identity (see settleDocument).
|
|
1066
|
+
const before = await this.documentStamp(handle);
|
|
877
1067
|
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
1068
|
this.record(s, direction === -1 ? 'back' : 'forward', {}, true);
|
|
1069
|
+
await this.settleDocument(handle, before, signal);
|
|
879
1070
|
this.showActive(s);
|
|
880
1071
|
}
|
|
881
1072
|
/** Go back in the active tab's history. */
|
|
@@ -892,10 +1083,14 @@ export class ElectronBrowserProvider {
|
|
|
892
1083
|
const { handle } = this.activeTab(s);
|
|
893
1084
|
signal?.throwIfAborted();
|
|
894
1085
|
const timeoutMs = 30_000;
|
|
1086
|
+
// Same-URL reload: only the per-load document identity can tell the new
|
|
1087
|
+
// document from the old one, so capture it before reloading.
|
|
1088
|
+
const before = await this.documentStamp(handle);
|
|
895
1089
|
await withTimeout(handle.sendCommand('Page.reload', { ignoreCache: false }), timeoutMs, signal, `browser: reload timed out after ${timeoutMs}ms`,
|
|
896
1090
|
// Best-effort: cancel the reload if it wedges the debugger queue.
|
|
897
1091
|
() => { void handle.sendCommand('Page.stopLoading').catch(() => { }); });
|
|
898
1092
|
this.record(s, 'reload', {}, true);
|
|
1093
|
+
await this.settleDocument(handle, before, signal);
|
|
899
1094
|
this.showActive(s);
|
|
900
1095
|
}
|
|
901
1096
|
/** Press one named key (Enter/Tab/arrows/…) via CDP key events. */
|
|
@@ -909,6 +1104,9 @@ export class ElectronBrowserProvider {
|
|
|
909
1104
|
}
|
|
910
1105
|
const params = { key: spec.key, code: spec.code, windowsVirtualKeyCode: spec.vk, nativeVirtualKeyCode: spec.vk, ...spec.text !== undefined ? { text: spec.text } : {} };
|
|
911
1106
|
const timeoutMs = 15_000;
|
|
1107
|
+
// Input.dispatchKeyEvent is subject to the same display-surface rule.
|
|
1108
|
+
await this.present(s, signal);
|
|
1109
|
+
await this.focusView(handle);
|
|
912
1110
|
const release = () => handle.sendCommand('Input.dispatchKeyEvent', { type: 'keyUp', ...params });
|
|
913
1111
|
try {
|
|
914
1112
|
await withTimeout(handle.sendCommand('Input.dispatchKeyEvent', { type: 'keyDown', ...params }), timeoutMs, signal, `browser: key press timed out after ${timeoutMs}ms`);
|
|
@@ -1079,10 +1277,16 @@ export class ElectronBrowserProvider {
|
|
|
1079
1277
|
* (polling until it appears or the budget runs out) and then runs `body`
|
|
1080
1278
|
* with `el` in scope. Shared by the target-based tools: click/type (②),
|
|
1081
1279
|
* setValue/check/select/clear/getValue (③), and the scrape item wait.
|
|
1280
|
+
* A selector that fails to PARSE is reported immediately instead of being
|
|
1281
|
+
* polled until the budget expires — see the comment in `match`.
|
|
1082
1282
|
*/
|
|
1083
1283
|
buildTargetScript(spec, timeoutMs, body) {
|
|
1284
|
+
// `by` defaults to css in-page; report the strategy that WAS used, or a
|
|
1285
|
+
// miss says only `{"value":"Learn more"}` and the caller cannot tell that a
|
|
1286
|
+
// plain label was queried as a CSS selector.
|
|
1287
|
+
const resolvedSpec = { ...spec, by: spec.by ?? 'css' };
|
|
1084
1288
|
return `(async () => {
|
|
1085
|
-
const spec = ${JSON.stringify(
|
|
1289
|
+
const spec = ${JSON.stringify(resolvedSpec)}
|
|
1086
1290
|
const timeoutMs = ${String(timeoutMs)}
|
|
1087
1291
|
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
|
1088
1292
|
const isVisible = el => el instanceof HTMLElement && (el.offsetWidth > 0 || el.offsetHeight > 0 || el.getClientRects().length > 0)
|
|
@@ -1094,7 +1298,13 @@ export class ElectronBrowserProvider {
|
|
|
1094
1298
|
const index = typeof spec.index === 'number' ? spec.index : 0
|
|
1095
1299
|
let els = []
|
|
1096
1300
|
if (by === 'css') {
|
|
1097
|
-
|
|
1301
|
+
// A selector that does not PARSE can never match on a later poll, so
|
|
1302
|
+
// it must not look like a miss: the old "return null" kept the caller
|
|
1303
|
+
// polling until the whole budget was gone, and "element not found
|
|
1304
|
+
// after 10s" reads like a slow page while the real cause is a
|
|
1305
|
+
// malformed selector (a label or plain text passed where one belongs).
|
|
1306
|
+
try { els = Array.from(document.querySelectorAll(value)) }
|
|
1307
|
+
catch (error) { throw new Error('invalid CSS selector ' + JSON.stringify(value) + ' (' + String((error && error.message) || error) + ')') }
|
|
1098
1308
|
} else if (by === 'xpath') {
|
|
1099
1309
|
try {
|
|
1100
1310
|
const snap = document.evaluate(value, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null)
|
|
@@ -1102,7 +1312,7 @@ export class ElectronBrowserProvider {
|
|
|
1102
1312
|
const n = snap.snapshotItem(i)
|
|
1103
1313
|
if (n instanceof Element) els.push(n)
|
|
1104
1314
|
}
|
|
1105
|
-
} catch {
|
|
1315
|
+
} catch (error) { throw new Error('invalid XPath ' + JSON.stringify(value) + ' (' + String((error && error.message) || error) + ')') }
|
|
1106
1316
|
} else {
|
|
1107
1317
|
const all = Array.from(document.querySelectorAll('body *'))
|
|
1108
1318
|
const exact = all.filter(el => isVisible(el) && ownText(el) === value)
|
|
@@ -1116,9 +1326,10 @@ export class ElectronBrowserProvider {
|
|
|
1116
1326
|
const deadline = Date.now() + timeoutMs
|
|
1117
1327
|
let el = null
|
|
1118
1328
|
for (;;) {
|
|
1119
|
-
|
|
1329
|
+
// Only a parse error escapes the poll: see the comment in match() above.
|
|
1330
|
+
try { el = match() } catch (error) { return { ok: false, error: String((error && error.message) || error) } }
|
|
1120
1331
|
if (el !== null) break
|
|
1121
|
-
if (Date.now() >= deadline) return { ok: false, error: 'element not found: ' + JSON.stringify(spec) }
|
|
1332
|
+
if (Date.now() >= deadline) return { ok: false, error: 'element not found: ' + JSON.stringify(spec) + ' (looked for ' + timeoutMs + 'ms)' }
|
|
1122
1333
|
await sleep(100)
|
|
1123
1334
|
}
|
|
1124
1335
|
${body}
|
|
@@ -1130,7 +1341,12 @@ export class ElectronBrowserProvider {
|
|
|
1130
1341
|
* evaluation failure or an in-page `{ ok: false, error }` verdict.
|
|
1131
1342
|
*/
|
|
1132
1343
|
async runTargetScript(tab, script, timeoutMs, signal, errorCode, errorLabel) {
|
|
1133
|
-
|
|
1344
|
+
// The script polls for its whole `timeoutMs` and only then answers, so an
|
|
1345
|
+
// outer budget of the same size fires at the same instant and wins: the
|
|
1346
|
+
// caller saw `click timed out after 10000ms` while the page had already
|
|
1347
|
+
// decided WHY (selector not found / malformed). Give the answer a chance to
|
|
1348
|
+
// get back before the generic timeout replaces it.
|
|
1349
|
+
const result = await withTimeout(handleSendEvaluate(tab.handle, script), timeoutMs + TARGET_SCRIPT_GRACE_MS, signal, `${errorLabel} timed out after ${timeoutMs}ms`, () => terminatePage(tab.handle));
|
|
1134
1350
|
if (!result.ok)
|
|
1135
1351
|
throw new BrowserError(`${errorLabel} failed: ${result.exception}`, errorCode);
|
|
1136
1352
|
const value = result.value;
|
|
@@ -1322,7 +1538,9 @@ export class ElectronBrowserProvider {
|
|
|
1322
1538
|
const deadline = Date.now() + timeoutMs
|
|
1323
1539
|
let items = []
|
|
1324
1540
|
for (;;) {
|
|
1325
|
-
|
|
1541
|
+
// Immediate, like match() above: an unparseable selector can never match.
|
|
1542
|
+
try { items = Array.from(document.querySelectorAll(itemSel)) }
|
|
1543
|
+
catch (error) { return { ok: false, error: 'invalid CSS selector ' + JSON.stringify(itemSel) + ' (' + String((error && error.message) || error) + ')' } }
|
|
1326
1544
|
if (items.length > 0) break
|
|
1327
1545
|
if (Date.now() >= deadline) return { ok: false, error: 'no elements matched: ' + itemSel }
|
|
1328
1546
|
await sleep(100)
|
|
@@ -1357,13 +1575,53 @@ export class ElectronBrowserProvider {
|
|
|
1357
1575
|
return { count: items.length, items };
|
|
1358
1576
|
}
|
|
1359
1577
|
/**
|
|
1360
|
-
*
|
|
1361
|
-
*
|
|
1362
|
-
*
|
|
1363
|
-
*
|
|
1364
|
-
*
|
|
1365
|
-
*
|
|
1366
|
-
*
|
|
1578
|
+
* Admit a caller-supplied save path for a file the browser writes to disk.
|
|
1579
|
+
* ONE gate for both `browser_download` and `browser_screenshot`: the path
|
|
1580
|
+
* must be absolute, must resolve inside `downloadDir`, and must not already
|
|
1581
|
+
* exist. Without it a prompt-injected agent could write — or silently
|
|
1582
|
+
* replace — any file the DSH process can reach, which also escapes the file
|
|
1583
|
+
* sandbox every other tool in the set runs under.
|
|
1584
|
+
* @param savePath - the caller's target path.
|
|
1585
|
+
* @param kind - the operation, used in the error code and message.
|
|
1586
|
+
* @returns the resolved absolute target path.
|
|
1587
|
+
* @throws BrowserError when the path is relative, outside the directory, or occupied.
|
|
1588
|
+
*/
|
|
1589
|
+
admitSavePath(savePath, kind) {
|
|
1590
|
+
const code = kind === 'download' ? 'BROWSER_DOWNLOAD_BLOCKED' : 'BROWSER_SCREENSHOT_BLOCKED';
|
|
1591
|
+
if (!isAbsolute(savePath)) {
|
|
1592
|
+
throw new BrowserError(`browser: ${kind} savePath must be an absolute path`, code);
|
|
1593
|
+
}
|
|
1594
|
+
const file = resolve(savePath);
|
|
1595
|
+
if (this.downloadDir !== undefined) {
|
|
1596
|
+
const dir = resolve(this.downloadDir);
|
|
1597
|
+
// Case-insensitive comparison: Windows paths are case-insensitive, and
|
|
1598
|
+
// resolve() does not normalize case. Without this, C:\Users\X\Downloads
|
|
1599
|
+
// and c:\users\x\downloads would be treated as different roots.
|
|
1600
|
+
const dirLower = dir.toLowerCase();
|
|
1601
|
+
const fileLower = file.toLowerCase();
|
|
1602
|
+
if (fileLower !== dirLower && !fileLower.startsWith(dirLower + sep.toLowerCase())) {
|
|
1603
|
+
throw new BrowserError(`browser: ${kind} savePath must be inside downloadDir "${dir}"`, code);
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
// Never replace an existing file: its previous content is unrecoverable,
|
|
1607
|
+
// and the admitted directory may hold files the human put there. A new
|
|
1608
|
+
// name is one tool call away.
|
|
1609
|
+
if (existsSync(file)) {
|
|
1610
|
+
throw new BrowserError(`browser: refusing to overwrite existing file "${file}" — use another name`, code);
|
|
1611
|
+
}
|
|
1612
|
+
return file;
|
|
1613
|
+
}
|
|
1614
|
+
/**
|
|
1615
|
+
* Download an HTTP(S) URL with the session's cookies to a local file.
|
|
1616
|
+
* Admission is the shared {@link admitSavePath} gate; only the self-hosted
|
|
1617
|
+
* host implements it (the desktop shell's embedded views delegate downloads
|
|
1618
|
+
* to the real browser UI). Both admitted paths and screenshots are confined
|
|
1619
|
+
* to `downloadDir`, so a prompt-injected agent cannot write arbitrary
|
|
1620
|
+
* machine paths.
|
|
1621
|
+
* @param session - the session whose cookies are used.
|
|
1622
|
+
* @param request - the URL plus the absolute target path.
|
|
1623
|
+
* @param signal - optional cancellation.
|
|
1624
|
+
* @returns the path the file was written to.
|
|
1367
1625
|
*/
|
|
1368
1626
|
async download(session, request, signal) {
|
|
1369
1627
|
const s = this.session(session);
|
|
@@ -1382,23 +1640,9 @@ export class ElectronBrowserProvider {
|
|
|
1382
1640
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
1383
1641
|
throw new BrowserError(`browser: refusing download of non-HTTP(S) URL "${request.url}"`, 'BROWSER_DOWNLOAD_BLOCKED');
|
|
1384
1642
|
}
|
|
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
|
-
}
|
|
1643
|
+
// savePath admission: the shared gate — absolute, inside `downloadDir`,
|
|
1644
|
+
// and never an existing file. Screenshots use the very same one.
|
|
1645
|
+
const savePath = this.admitSavePath(request.savePath, 'download');
|
|
1402
1646
|
const downloadable = handle;
|
|
1403
1647
|
if (typeof downloadable.download !== 'function') {
|
|
1404
1648
|
throw new BrowserError('browser: download is only available on the self-hosted browser', 'BROWSER_DOWNLOAD_UNSUPPORTED');
|
|
@@ -1410,9 +1654,9 @@ export class ElectronBrowserProvider {
|
|
|
1410
1654
|
// entry is cleaned up (the promise settles) and the child's eventual
|
|
1411
1655
|
// reply is harmlessly consumed by the RPC layer.
|
|
1412
1656
|
const timeoutMs = 60_000;
|
|
1413
|
-
await withTimeout(downloadable.download(request.url,
|
|
1414
|
-
this.record(s, 'download', { url: request.url, savePath
|
|
1415
|
-
return { path:
|
|
1657
|
+
await withTimeout(downloadable.download(request.url, savePath), timeoutMs, signal, `browser: download timed out after ${timeoutMs}ms`);
|
|
1658
|
+
this.record(s, 'download', { url: request.url, savePath }, true, { result: savePath });
|
|
1659
|
+
return { path: savePath };
|
|
1416
1660
|
}
|
|
1417
1661
|
/**
|
|
1418
1662
|
* Export the session's cookies (login state) as serializable objects.
|
|
@@ -1487,16 +1731,26 @@ export class ElectronBrowserProvider {
|
|
|
1487
1731
|
}
|
|
1488
1732
|
return this.saveScreenshot(data, request?.savePath, 'image/png');
|
|
1489
1733
|
}
|
|
1490
|
-
/**
|
|
1734
|
+
/**
|
|
1735
|
+
* Build the data URL and optionally write the image to disk. The caller's
|
|
1736
|
+
* path goes through the SAME {@link admitSavePath} gate as a download — it
|
|
1737
|
+
* must be absolute, resolve inside `downloadDir`, and not be an existing
|
|
1738
|
+
* file — so a screenshot cannot be used to write to, or silently replace,
|
|
1739
|
+
* files anywhere the DSH process happens to have permission (issue #13).
|
|
1740
|
+
*/
|
|
1491
1741
|
saveScreenshot(base64, savePath, mime) {
|
|
1492
1742
|
if (savePath !== undefined) {
|
|
1743
|
+
// Admission failures propagate unchanged: they describe the caller's
|
|
1744
|
+
// path, not a disk problem.
|
|
1745
|
+
const target = this.admitSavePath(savePath, 'screenshot');
|
|
1493
1746
|
try {
|
|
1494
|
-
|
|
1495
|
-
|
|
1747
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
1748
|
+
writeFileSync(target, Buffer.from(base64, 'base64'));
|
|
1749
|
+
return { dataUrl: `data:${mime};base64,${base64}`, path: target };
|
|
1496
1750
|
}
|
|
1497
1751
|
catch (error) {
|
|
1498
1752
|
// Report the write problem but keep the capture usable.
|
|
1499
|
-
throw new BrowserError(`browser: screenshot save to "${
|
|
1753
|
+
throw new BrowserError(`browser: screenshot save to "${target}" failed: ${String(error)}`, 'BROWSER_SCREENSHOT_SAVE_FAILED', { cause: error });
|
|
1500
1754
|
}
|
|
1501
1755
|
}
|
|
1502
1756
|
return { dataUrl: `data:${mime};base64,${base64}` };
|
|
@@ -1712,6 +1966,42 @@ export class ElectronBrowserProvider {
|
|
|
1712
1966
|
return result.ok && typeof result.value === 'string' ? result.value : '';
|
|
1713
1967
|
}
|
|
1714
1968
|
}
|
|
1969
|
+
/**
|
|
1970
|
+
* Hard cap on waiting for a navigation's new document to be parsed. Bounded and
|
|
1971
|
+
* best-effort: a page that never settles must not fail the navigation.
|
|
1972
|
+
*/
|
|
1973
|
+
const SETTLE_TIMEOUT_MS = 5_000;
|
|
1974
|
+
/**
|
|
1975
|
+
* When the document already looks settled but its identity has NOT changed
|
|
1976
|
+
* (same-document navigation, or the outgoing document still answering), wait
|
|
1977
|
+
* only this long before returning — there is no parse in flight to wait for.
|
|
1978
|
+
*/
|
|
1979
|
+
const SETTLE_GRACE_MS = 150;
|
|
1980
|
+
/** Poll interval for the document-settle loop. */
|
|
1981
|
+
const SETTLE_POLL_MS = 25;
|
|
1982
|
+
/**
|
|
1983
|
+
* Extra time the transport gets over an in-page locate script's own budget: the
|
|
1984
|
+
* script polls for `timeoutMs` and answers only afterwards, so the outer wait
|
|
1985
|
+
* must outlast it or its generic timeout hides the in-page reason.
|
|
1986
|
+
*/
|
|
1987
|
+
const TARGET_SCRIPT_GRACE_MS = 2_000;
|
|
1988
|
+
/** `tab:<uuid>` ↔ `<uuid>`: ids accept either form (see locateTab). */
|
|
1989
|
+
function stripTabPrefix(id) {
|
|
1990
|
+
return id.startsWith('tab:') ? id.slice(4) : id;
|
|
1991
|
+
}
|
|
1992
|
+
/** Abortable sleep used by the settle loop. */
|
|
1993
|
+
function delay(ms, signal) {
|
|
1994
|
+
return new Promise((resolve) => {
|
|
1995
|
+
const timer = setTimeout(() => {
|
|
1996
|
+
if (signal !== undefined)
|
|
1997
|
+
signal.removeEventListener('abort', onAbort);
|
|
1998
|
+
resolve();
|
|
1999
|
+
}, ms);
|
|
2000
|
+
const onAbort = () => { clearTimeout(timer); resolve(); };
|
|
2001
|
+
if (signal !== undefined)
|
|
2002
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
2003
|
+
});
|
|
2004
|
+
}
|
|
1715
2005
|
/**
|
|
1716
2006
|
* Bound a promise so a wedged CDP call surfaces as an error instead of
|
|
1717
2007
|
* hanging the tool call forever. The caller's signal, when provided, wins
|