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
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { randomUUID } from 'node:crypto'
|
|
11
|
-
import { writeFileSync } from 'node:fs'
|
|
11
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
12
12
|
import { homedir } from 'node:os'
|
|
13
|
-
import { isAbsolute, join, resolve, sep } from 'node:path'
|
|
13
|
+
import { dirname, isAbsolute, join, resolve, sep } from 'node:path'
|
|
14
14
|
import type {
|
|
15
15
|
BrowserA11yRequest,
|
|
16
16
|
BrowserA11yResult,
|
|
@@ -134,6 +134,20 @@ export interface ElectronBrowserViewHost {
|
|
|
134
134
|
* tell which task's page is currently visible.
|
|
135
135
|
*/
|
|
136
136
|
showView?(handle: ElectronViewHandle, label?: string): void
|
|
137
|
+
/**
|
|
138
|
+
* Optional: (re)present a view and WAIT until the host confirms it. Chromium
|
|
139
|
+
* silently drops CDP-synthesized input (`Input.*`) for a view that has no
|
|
140
|
+
* display surface, which made clicks/typing report success while the page
|
|
141
|
+
* received nothing (and made the first input after a navigation vanish,
|
|
142
|
+
* because the replaced renderer has no surface yet). Unlike
|
|
143
|
+
* {@link showView}, this round-trips an RPC barrier that is ordered AFTER
|
|
144
|
+
* the show on the same socket, so its reply means the view is on screen.
|
|
145
|
+
* Rejects when the view cannot be presented; a host without this method is
|
|
146
|
+
* assumed to present every view (headless/probe hosts).
|
|
147
|
+
* @param handle - the handle to present.
|
|
148
|
+
* @param label - human-readable session/task label, as in {@link showView}.
|
|
149
|
+
*/
|
|
150
|
+
presentView?(handle: ElectronViewHandle, label?: string): Promise<void>
|
|
137
151
|
/**
|
|
138
152
|
* Optional cheap usability probe (no network): whether the host can back
|
|
139
153
|
* views at all right now. The self-hosted host checks for a usable Electron
|
|
@@ -193,6 +207,12 @@ export interface ElectronViewHandle {
|
|
|
193
207
|
* @returns the CDP `result` object.
|
|
194
208
|
*/
|
|
195
209
|
sendCommand(method: string, params?: Record<string, unknown>): Promise<Record<string, unknown>>
|
|
210
|
+
/**
|
|
211
|
+
* Give the backing view web focus so keyboard input reaches its page.
|
|
212
|
+
* Optional: an adapter that cannot focus a view omits it, and the provider
|
|
213
|
+
* then behaves exactly as before.
|
|
214
|
+
*/
|
|
215
|
+
focus?(): Promise<void>
|
|
196
216
|
}
|
|
197
217
|
|
|
198
218
|
/** One tab inside a session: its view plus a stable id. */
|
|
@@ -240,14 +260,16 @@ export interface CdpNavigateParams {
|
|
|
240
260
|
}
|
|
241
261
|
|
|
242
262
|
/**
|
|
243
|
-
* CDP method/params for `Input.dispatchMouseEvent
|
|
263
|
+
* CDP method/params for `Input.dispatchMouseEvent`: a pointer move, or one half
|
|
264
|
+
* of a click's press+release pair. `button`/`clickCount` belong to the press and
|
|
265
|
+
* release halves; a move carries only the position.
|
|
244
266
|
*/
|
|
245
267
|
export interface CdpMouseParams {
|
|
246
|
-
readonly type: 'mousePressed' | 'mouseReleased'
|
|
268
|
+
readonly type: 'mouseMoved' | 'mousePressed' | 'mouseReleased'
|
|
247
269
|
readonly x: number
|
|
248
270
|
readonly y: number
|
|
249
|
-
readonly button
|
|
250
|
-
readonly clickCount
|
|
271
|
+
readonly button?: 'left'
|
|
272
|
+
readonly clickCount?: number
|
|
251
273
|
}
|
|
252
274
|
|
|
253
275
|
/** CDP method/params for `Input.insertText`. */
|
|
@@ -303,6 +325,31 @@ const KEY_SPECS: Readonly<Record<string, { readonly key: string; readonly code:
|
|
|
303
325
|
/** Supported key names, exported for the tool's enum and error messages. */
|
|
304
326
|
export const BROWSER_KEY_NAMES: readonly string[] = Object.keys(KEY_SPECS)
|
|
305
327
|
|
|
328
|
+
/**
|
|
329
|
+
* Localized names a system Downloads folder may carry, in probe order: the
|
|
330
|
+
* English default first, then the Chinese spellings — a zh-CN desktop calls
|
|
331
|
+
* the folder `下载`, a zh-TW one `下載`, and neither is `Downloads`.
|
|
332
|
+
*/
|
|
333
|
+
const DOWNLOAD_DIR_NAMES: readonly string[] = ['Downloads', '下载', '下載']
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Resolve the default download directory when the config names none: an
|
|
337
|
+
* existing `XDG_DOWNLOAD_DIR` wins (the freedesktop standard, which is what a
|
|
338
|
+
* localized Linux desktop writes), then the first localized `Downloads` folder
|
|
339
|
+
* that exists under the home directory, and finally the English name — created
|
|
340
|
+
* on first use — when none exists yet.
|
|
341
|
+
* @returns the absolute default download directory.
|
|
342
|
+
*/
|
|
343
|
+
function defaultDownloadDir(): string {
|
|
344
|
+
const xdg = process.env.XDG_DOWNLOAD_DIR
|
|
345
|
+
if (typeof xdg === 'string' && xdg !== '' && existsSync(xdg)) return xdg
|
|
346
|
+
for (const name of DOWNLOAD_DIR_NAMES) {
|
|
347
|
+
const candidate = join(homedir(), name)
|
|
348
|
+
if (existsSync(candidate)) return candidate
|
|
349
|
+
}
|
|
350
|
+
return join(homedir(), DOWNLOAD_DIR_NAMES[0] ?? 'Downloads')
|
|
351
|
+
}
|
|
352
|
+
|
|
306
353
|
/**
|
|
307
354
|
* Browser provider over Electron views. Sessions hold an ordered list of
|
|
308
355
|
* tabs; each tab is one view created by the host. The active tab receives
|
|
@@ -326,9 +373,12 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
326
373
|
this.httpOnly = config.httpOnly ?? true
|
|
327
374
|
this.snapshotMaxElements = config.snapshotMaxElements ?? 60
|
|
328
375
|
this.contentMaxChars = config.contentMaxChars ?? 100_000
|
|
329
|
-
// Confine downloads to a dedicated directory by
|
|
330
|
-
// folder is the human-visible, browser-natural
|
|
331
|
-
|
|
376
|
+
// Confine downloads AND screenshot saves to a dedicated directory by
|
|
377
|
+
// default: the OS Downloads folder is the human-visible, browser-natural
|
|
378
|
+
// place for written files, and it is the one directory the DSH file
|
|
379
|
+
// sandbox does not have to arbitrate. The localized name is probed so a
|
|
380
|
+
// Chinese desktop (`~/下载`) works without configuring `downloadDir`.
|
|
381
|
+
this.downloadDir = config.downloadDir ?? defaultDownloadDir()
|
|
332
382
|
// Route toolbar (host UI) actions into the session model: the human and
|
|
333
383
|
// the agent then always drive the same tabs, history, and navigation.
|
|
334
384
|
this.host.onUserAction?.(action => { void this.handleUserAction(action) })
|
|
@@ -433,10 +483,15 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
433
483
|
*/
|
|
434
484
|
private locateTab(session: BrowserSessionId, tabId: string): { s: Session; index: number } {
|
|
435
485
|
const s = this.session(session)
|
|
436
|
-
|
|
486
|
+
// Accept the bare uuid as well as the "tab:<uuid>" form the tools print:
|
|
487
|
+
// ids are globally unique, so requiring the printed prefix only produced a
|
|
488
|
+
// misleading "is not open in this session" error that listed the very tab
|
|
489
|
+
// the caller had just named.
|
|
490
|
+
const wanted = stripTabPrefix(tabId)
|
|
491
|
+
const own = s.tabs.findIndex(tab => stripTabPrefix(tab.id) === wanted)
|
|
437
492
|
if (own >= 0) return { s, index: own }
|
|
438
493
|
for (const other of this.sessions.values()) {
|
|
439
|
-
const index = other.tabs.findIndex(tab => tab.id ===
|
|
494
|
+
const index = other.tabs.findIndex(tab => stripTabPrefix(tab.id) === wanted)
|
|
440
495
|
if (index >= 0) return { s: other, index }
|
|
441
496
|
}
|
|
442
497
|
const ownIds = s.tabs.map(t => t.id).join(', ') || '(none)'
|
|
@@ -479,6 +534,8 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
479
534
|
// evaluate paths so a wedged navigation surfaces as an error instead of
|
|
480
535
|
// blocking the tool call forever.
|
|
481
536
|
const timeoutMs = 30_000
|
|
537
|
+
// Document identity of the page we are leaving (see settleDocument).
|
|
538
|
+
const before = await this.documentStamp(handle)
|
|
482
539
|
const result = await withTimeout(
|
|
483
540
|
handle.sendCommand(CDP_PAGE_NAVIGATE, { url } satisfies CdpNavigateParams),
|
|
484
541
|
timeoutMs,
|
|
@@ -494,7 +551,11 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
494
551
|
if (typeof errorText === 'string' && errorText !== '') {
|
|
495
552
|
throw new BrowserError(`browser: navigation to "${url}" failed: ${errorText}`, 'BROWSER_NAVIGATION_FAILED')
|
|
496
553
|
}
|
|
554
|
+
// Record as soon as the navigation has committed: history bookkeeping must
|
|
555
|
+
// not wait on the best-effort settle below (which only delays what the
|
|
556
|
+
// NEXT observation sees, not whether this action happened).
|
|
497
557
|
this.record(s, 'navigate', { url }, true)
|
|
558
|
+
await this.settleDocument(handle, before, signal)
|
|
498
559
|
this.showActive(s)
|
|
499
560
|
} catch (error) {
|
|
500
561
|
if (!(error instanceof BrowserError && (error as { code?: string }).code === 'BROWSER_NAVIGATION_BLOCKED')) {
|
|
@@ -504,6 +565,133 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
504
565
|
}
|
|
505
566
|
}
|
|
506
567
|
|
|
568
|
+
/**
|
|
569
|
+
* A per-document stamp: `performance.timeOrigin` is unique per document load,
|
|
570
|
+
* so it tells a same-URL reload and an A→B→A redirect apart from the document
|
|
571
|
+
* that was current before the navigation. Empty string when unreadable.
|
|
572
|
+
*/
|
|
573
|
+
private async documentStamp(handle: ElectronViewHandle): Promise<string> {
|
|
574
|
+
const probe = await this.documentProbe(handle)
|
|
575
|
+
return probe?.stamp ?? ''
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* One cheap in-page reading of the document's identity and parse state.
|
|
580
|
+
*
|
|
581
|
+
* Returns null when the page did NOT answer (mid-commit, execution context
|
|
582
|
+
* destroyed) — the caller keeps waiting. A page that answered with an
|
|
583
|
+
* unexpected shape (an override or a non-conforming host; the production
|
|
584
|
+
* expression always yields a string) is reported as `unknown` rather than as
|
|
585
|
+
* "no answer": blocking the navigation for the whole budget on a page that
|
|
586
|
+
* demonstrably responded has no upside.
|
|
587
|
+
*/
|
|
588
|
+
private async documentProbe(handle: ElectronViewHandle): Promise<{ stamp: string; readyState: string } | null> {
|
|
589
|
+
try {
|
|
590
|
+
const result = await withTimeout(
|
|
591
|
+
handleSendEvaluate(handle, 'String(performance.timeOrigin) + "|" + document.readyState'),
|
|
592
|
+
3_000,
|
|
593
|
+
undefined,
|
|
594
|
+
'browser: document probe timed out',
|
|
595
|
+
)
|
|
596
|
+
if (!result.ok) return null
|
|
597
|
+
if (typeof result.value !== 'string') return { stamp: '', readyState: 'unknown' }
|
|
598
|
+
const [stamp = '', readyState = ''] = result.value.split('|')
|
|
599
|
+
return { stamp, readyState }
|
|
600
|
+
} catch {
|
|
601
|
+
return null
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Page.navigate resolves at navigation COMMIT, not at load: the new document
|
|
607
|
+
* is already current (so location.href and document.title are the new page's)
|
|
608
|
+
* while its DOM is still being parsed. browser_open snapshots immediately
|
|
609
|
+
* after navigating, which is why it could report the right title with zero
|
|
610
|
+
* interactive elements — and why a separate browser_snapshot right after
|
|
611
|
+
* always found them.
|
|
612
|
+
*
|
|
613
|
+
* Wait — bounded, best-effort — for the new document to settle: readyState
|
|
614
|
+
* must reach interactive/complete AND the document identity must have
|
|
615
|
+
* changed. A page that never settles must not fail a navigation that already
|
|
616
|
+
* succeeded, so a timeout here is swallowed.
|
|
617
|
+
*/
|
|
618
|
+
private async settleDocument(handle: ElectronViewHandle, before: string, signal?: AbortSignal): Promise<void> {
|
|
619
|
+
const started = Date.now()
|
|
620
|
+
const deadline = started + SETTLE_TIMEOUT_MS
|
|
621
|
+
for (;;) {
|
|
622
|
+
if (signal?.aborted === true) return
|
|
623
|
+
// A probe failure is expected while the navigation commits (the context is
|
|
624
|
+
// being destroyed) — keep polling rather than giving up.
|
|
625
|
+
const probe = await this.documentProbe(handle)
|
|
626
|
+
if (probe !== null) {
|
|
627
|
+
const settled = probe.readyState === 'interactive'
|
|
628
|
+
|| probe.readyState === 'complete'
|
|
629
|
+
|| probe.readyState === 'unknown'
|
|
630
|
+
const isNewDocument = before === '' || probe.stamp === '' || probe.stamp !== before
|
|
631
|
+
// The common case: the new document exists and is parsed — return now.
|
|
632
|
+
if (settled && isNewDocument) return
|
|
633
|
+
// Same document (hash navigation, cancelled/aborted load): there is no
|
|
634
|
+
// parse to wait for, so do not burn the budget. Outlast the brief
|
|
635
|
+
// window in which the OUTGOING document may still answer instead.
|
|
636
|
+
if (settled && Date.now() - started >= SETTLE_GRACE_MS) return
|
|
637
|
+
}
|
|
638
|
+
if (Date.now() >= deadline) return
|
|
639
|
+
await delay(SETTLE_POLL_MS, signal)
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Make sure the active tab's view can actually receive synthesized input.
|
|
645
|
+
* Chromium drops `Input.*` events for a view with no display surface, so this
|
|
646
|
+
* runs before click/type/key and fails loudly (BROWSER_VIEW_NOT_PRESENTED)
|
|
647
|
+
* rather than reporting a success the page never saw.
|
|
648
|
+
*/
|
|
649
|
+
private async present(s: Session, signal?: AbortSignal): Promise<void> {
|
|
650
|
+
const { handle } = this.activeTab(s)
|
|
651
|
+
const present = this.host.presentView?.bind(this.host)
|
|
652
|
+
if (present === undefined) {
|
|
653
|
+
this.showActive(s)
|
|
654
|
+
return
|
|
655
|
+
}
|
|
656
|
+
signal?.throwIfAborted()
|
|
657
|
+
const timeoutMs = 10_000
|
|
658
|
+
try {
|
|
659
|
+
await withTimeout(
|
|
660
|
+
present(handle, s.label),
|
|
661
|
+
timeoutMs,
|
|
662
|
+
signal,
|
|
663
|
+
`browser: presenting the page view timed out after ${timeoutMs}ms`,
|
|
664
|
+
)
|
|
665
|
+
this.showActive(s)
|
|
666
|
+
} catch (error) {
|
|
667
|
+
throw new BrowserError(
|
|
668
|
+
`browser: the page view is not presented, so synthesized input would be silently dropped (${String(error)})`,
|
|
669
|
+
'BROWSER_VIEW_NOT_PRESENTED',
|
|
670
|
+
{ cause: error },
|
|
671
|
+
)
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* Give the view web focus before synthesizing keyboard input.
|
|
677
|
+
*
|
|
678
|
+
* A renderer only delivers key events to the view that holds web focus. A
|
|
679
|
+
* view that was created but never clicked holds none — and `Input` events
|
|
680
|
+
* injected over CDP do not grant it — so the FIRST `browser_key` of a fresh
|
|
681
|
+
* session vanished inside the renderer while the command still answered
|
|
682
|
+
* success. Hosts that cannot focus a view (a test double, a different shell
|
|
683
|
+
* adapter) simply have no `focus`, and nothing changes for them.
|
|
684
|
+
*/
|
|
685
|
+
private async focusView(handle: ElectronViewHandle): Promise<void> {
|
|
686
|
+
if (handle.focus === undefined) return
|
|
687
|
+
try {
|
|
688
|
+
await handle.focus()
|
|
689
|
+
} catch {
|
|
690
|
+
// Best-effort: a view that refuses focus must not fail the caller's key
|
|
691
|
+
// press; the dispatch below reports a real channel failure itself.
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
507
695
|
/** Execute JS in the active tab's page context. */
|
|
508
696
|
async execute(session: BrowserSessionId, request: BrowserExecuteRequest, signal?: AbortSignal): Promise<BrowserExecuteResult> {
|
|
509
697
|
const s = this.session(session)
|
|
@@ -1033,23 +1221,54 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1033
1221
|
x = request.x
|
|
1034
1222
|
y = request.y
|
|
1035
1223
|
}
|
|
1036
|
-
|
|
1037
|
-
|
|
1224
|
+
// Input.* is only delivered to a view with a current display surface; the
|
|
1225
|
+
// barrier also re-presents after a navigation replaced the renderer.
|
|
1226
|
+
await this.present(s, signal)
|
|
1038
1227
|
const timeoutMs = 30_000
|
|
1228
|
+
const send = (params: Record<string, unknown>, label: string): Promise<unknown> =>
|
|
1229
|
+
withTimeout(
|
|
1230
|
+
handle.sendCommand('Input.dispatchMouseEvent', params),
|
|
1231
|
+
timeoutMs,
|
|
1232
|
+
signal,
|
|
1233
|
+
`browser: click ${label} timed out after ${timeoutMs}ms`,
|
|
1234
|
+
)
|
|
1235
|
+
// Chromium routes a synthesized `mousePressed` to the widget's *current*
|
|
1236
|
+
// hover target rather than hit-testing the coordinates, and a real pointer
|
|
1237
|
+
// click is always preceded by movement. A view that has never received a
|
|
1238
|
+
// mouse event has no hover target yet, so the press of the FIRST click on a
|
|
1239
|
+
// fresh tab went nowhere while CDP still answered success; the second click
|
|
1240
|
+
// landed because the first had quietly established the target. Move the
|
|
1241
|
+
// pointer first so a click is self-contained.
|
|
1242
|
+
try {
|
|
1243
|
+
await send({ type: 'mouseMoved', x, y } satisfies CdpMouseParams, 'move')
|
|
1244
|
+
} catch (error) {
|
|
1245
|
+
throw new BrowserError(`browser: click failed: ${String(error)}`, 'BROWSER_CLICK_FAILED', { cause: error })
|
|
1246
|
+
}
|
|
1247
|
+
const release = (): void => {
|
|
1248
|
+
void handle
|
|
1249
|
+
.sendCommand('Input.dispatchMouseEvent', {
|
|
1250
|
+
type: 'mouseReleased',
|
|
1251
|
+
x,
|
|
1252
|
+
y,
|
|
1253
|
+
button: 'left',
|
|
1254
|
+
clickCount: 1,
|
|
1255
|
+
} satisfies CdpMouseParams)
|
|
1256
|
+
.catch(() => {})
|
|
1257
|
+
}
|
|
1039
1258
|
try {
|
|
1040
|
-
await
|
|
1259
|
+
await send({ type: 'mousePressed', x, y, button: 'left', clickCount: 1 }, 'press')
|
|
1041
1260
|
} catch (error) {
|
|
1042
1261
|
// The press may still land late; release best-effort so the button is
|
|
1043
1262
|
// never left in a stuck pressed state.
|
|
1044
|
-
|
|
1263
|
+
release()
|
|
1045
1264
|
throw new BrowserError(`browser: click failed: ${String(error)}`, 'BROWSER_CLICK_FAILED', { cause: error })
|
|
1046
1265
|
}
|
|
1047
1266
|
try {
|
|
1048
|
-
await
|
|
1267
|
+
await send({ type: 'mouseReleased', x, y, button: 'left', clickCount: 1 }, 'release')
|
|
1049
1268
|
} catch (error) {
|
|
1050
1269
|
// The press already landed; retry the release so the button is not
|
|
1051
1270
|
// left pressed before surfacing the failure.
|
|
1052
|
-
|
|
1271
|
+
release()
|
|
1053
1272
|
throw new BrowserError(`browser: click failed: ${String(error)}`, 'BROWSER_CLICK_FAILED', { cause: error })
|
|
1054
1273
|
}
|
|
1055
1274
|
this.record(s, 'click', 'target' in request ? { target: request.target } : { x, y }, true)
|
|
@@ -1078,6 +1297,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1078
1297
|
}
|
|
1079
1298
|
const text = 'text' in request ? request.text : ''
|
|
1080
1299
|
const timeoutMs = 30_000
|
|
1300
|
+
// Input.insertText goes through the Input domain: same display-surface
|
|
1301
|
+
// requirement as click/key.
|
|
1302
|
+
await this.present(s, signal)
|
|
1081
1303
|
try {
|
|
1082
1304
|
await withTimeout(
|
|
1083
1305
|
handle.sendCommand('Input.insertText', { text } satisfies CdpInsertTextParams),
|
|
@@ -1149,6 +1371,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1149
1371
|
}
|
|
1150
1372
|
const entry = entries[target]
|
|
1151
1373
|
if (entry?.id === undefined) throw new BrowserError('browser: history entry missing id', 'BROWSER_HISTORY_INVALID')
|
|
1374
|
+
// A history step is a navigation too: the classic A→B→A redirect would
|
|
1375
|
+
// otherwise read the outgoing document's identity (see settleDocument).
|
|
1376
|
+
const before = await this.documentStamp(handle)
|
|
1152
1377
|
await withTimeout(
|
|
1153
1378
|
handle.sendCommand('Page.navigateToHistoryEntry', { entryId: entry.id }),
|
|
1154
1379
|
timeoutMs,
|
|
@@ -1157,6 +1382,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1157
1382
|
() => { void handle.sendCommand('Page.stopLoading').catch(() => {}) },
|
|
1158
1383
|
)
|
|
1159
1384
|
this.record(s, direction === -1 ? 'back' : 'forward', {}, true)
|
|
1385
|
+
await this.settleDocument(handle, before, signal)
|
|
1160
1386
|
this.showActive(s)
|
|
1161
1387
|
}
|
|
1162
1388
|
|
|
@@ -1176,6 +1402,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1176
1402
|
const { handle } = this.activeTab(s)
|
|
1177
1403
|
signal?.throwIfAborted()
|
|
1178
1404
|
const timeoutMs = 30_000
|
|
1405
|
+
// Same-URL reload: only the per-load document identity can tell the new
|
|
1406
|
+
// document from the old one, so capture it before reloading.
|
|
1407
|
+
const before = await this.documentStamp(handle)
|
|
1179
1408
|
await withTimeout(
|
|
1180
1409
|
handle.sendCommand('Page.reload', { ignoreCache: false }),
|
|
1181
1410
|
timeoutMs,
|
|
@@ -1185,6 +1414,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1185
1414
|
() => { void handle.sendCommand('Page.stopLoading').catch(() => {}) },
|
|
1186
1415
|
)
|
|
1187
1416
|
this.record(s, 'reload', {}, true)
|
|
1417
|
+
await this.settleDocument(handle, before, signal)
|
|
1188
1418
|
this.showActive(s)
|
|
1189
1419
|
}
|
|
1190
1420
|
|
|
@@ -1199,6 +1429,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1199
1429
|
}
|
|
1200
1430
|
const params = { key: spec.key, code: spec.code, windowsVirtualKeyCode: spec.vk, nativeVirtualKeyCode: spec.vk, ...spec.text !== undefined ? { text: spec.text } : {} }
|
|
1201
1431
|
const timeoutMs = 15_000
|
|
1432
|
+
// Input.dispatchKeyEvent is subject to the same display-surface rule.
|
|
1433
|
+
await this.present(s, signal)
|
|
1434
|
+
await this.focusView(handle)
|
|
1202
1435
|
const release = (): Promise<Record<string, unknown>> =>
|
|
1203
1436
|
handle.sendCommand('Input.dispatchKeyEvent', { type: 'keyUp', ...params })
|
|
1204
1437
|
try {
|
|
@@ -1376,10 +1609,16 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1376
1609
|
* (polling until it appears or the budget runs out) and then runs `body`
|
|
1377
1610
|
* with `el` in scope. Shared by the target-based tools: click/type (②),
|
|
1378
1611
|
* setValue/check/select/clear/getValue (③), and the scrape item wait.
|
|
1612
|
+
* A selector that fails to PARSE is reported immediately instead of being
|
|
1613
|
+
* polled until the budget expires — see the comment in `match`.
|
|
1379
1614
|
*/
|
|
1380
1615
|
private buildTargetScript(spec: BrowserElementTarget, timeoutMs: number, body: string): string {
|
|
1616
|
+
// `by` defaults to css in-page; report the strategy that WAS used, or a
|
|
1617
|
+
// miss says only `{"value":"Learn more"}` and the caller cannot tell that a
|
|
1618
|
+
// plain label was queried as a CSS selector.
|
|
1619
|
+
const resolvedSpec = { ...spec, by: spec.by ?? 'css' }
|
|
1381
1620
|
return `(async () => {
|
|
1382
|
-
const spec = ${JSON.stringify(
|
|
1621
|
+
const spec = ${JSON.stringify(resolvedSpec)}
|
|
1383
1622
|
const timeoutMs = ${String(timeoutMs)}
|
|
1384
1623
|
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
|
1385
1624
|
const isVisible = el => el instanceof HTMLElement && (el.offsetWidth > 0 || el.offsetHeight > 0 || el.getClientRects().length > 0)
|
|
@@ -1391,7 +1630,13 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1391
1630
|
const index = typeof spec.index === 'number' ? spec.index : 0
|
|
1392
1631
|
let els = []
|
|
1393
1632
|
if (by === 'css') {
|
|
1394
|
-
|
|
1633
|
+
// A selector that does not PARSE can never match on a later poll, so
|
|
1634
|
+
// it must not look like a miss: the old "return null" kept the caller
|
|
1635
|
+
// polling until the whole budget was gone, and "element not found
|
|
1636
|
+
// after 10s" reads like a slow page while the real cause is a
|
|
1637
|
+
// malformed selector (a label or plain text passed where one belongs).
|
|
1638
|
+
try { els = Array.from(document.querySelectorAll(value)) }
|
|
1639
|
+
catch (error) { throw new Error('invalid CSS selector ' + JSON.stringify(value) + ' (' + String((error && error.message) || error) + ')') }
|
|
1395
1640
|
} else if (by === 'xpath') {
|
|
1396
1641
|
try {
|
|
1397
1642
|
const snap = document.evaluate(value, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null)
|
|
@@ -1399,7 +1644,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1399
1644
|
const n = snap.snapshotItem(i)
|
|
1400
1645
|
if (n instanceof Element) els.push(n)
|
|
1401
1646
|
}
|
|
1402
|
-
} catch {
|
|
1647
|
+
} catch (error) { throw new Error('invalid XPath ' + JSON.stringify(value) + ' (' + String((error && error.message) || error) + ')') }
|
|
1403
1648
|
} else {
|
|
1404
1649
|
const all = Array.from(document.querySelectorAll('body *'))
|
|
1405
1650
|
const exact = all.filter(el => isVisible(el) && ownText(el) === value)
|
|
@@ -1413,9 +1658,10 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1413
1658
|
const deadline = Date.now() + timeoutMs
|
|
1414
1659
|
let el = null
|
|
1415
1660
|
for (;;) {
|
|
1416
|
-
|
|
1661
|
+
// Only a parse error escapes the poll: see the comment in match() above.
|
|
1662
|
+
try { el = match() } catch (error) { return { ok: false, error: String((error && error.message) || error) } }
|
|
1417
1663
|
if (el !== null) break
|
|
1418
|
-
if (Date.now() >= deadline) return { ok: false, error: 'element not found: ' + JSON.stringify(spec) }
|
|
1664
|
+
if (Date.now() >= deadline) return { ok: false, error: 'element not found: ' + JSON.stringify(spec) + ' (looked for ' + timeoutMs + 'ms)' }
|
|
1419
1665
|
await sleep(100)
|
|
1420
1666
|
}
|
|
1421
1667
|
${body}
|
|
@@ -1435,9 +1681,14 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1435
1681
|
errorCode: string,
|
|
1436
1682
|
errorLabel: string,
|
|
1437
1683
|
): Promise<Record<string, unknown>> {
|
|
1684
|
+
// The script polls for its whole `timeoutMs` and only then answers, so an
|
|
1685
|
+
// outer budget of the same size fires at the same instant and wins: the
|
|
1686
|
+
// caller saw `click timed out after 10000ms` while the page had already
|
|
1687
|
+
// decided WHY (selector not found / malformed). Give the answer a chance to
|
|
1688
|
+
// get back before the generic timeout replaces it.
|
|
1438
1689
|
const result = await withTimeout(
|
|
1439
1690
|
handleSendEvaluate(tab.handle, script),
|
|
1440
|
-
timeoutMs,
|
|
1691
|
+
timeoutMs + TARGET_SCRIPT_GRACE_MS,
|
|
1441
1692
|
signal,
|
|
1442
1693
|
`${errorLabel} timed out after ${timeoutMs}ms`,
|
|
1443
1694
|
() => terminatePage(tab.handle),
|
|
@@ -1637,7 +1888,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1637
1888
|
const deadline = Date.now() + timeoutMs
|
|
1638
1889
|
let items = []
|
|
1639
1890
|
for (;;) {
|
|
1640
|
-
|
|
1891
|
+
// Immediate, like match() above: an unparseable selector can never match.
|
|
1892
|
+
try { items = Array.from(document.querySelectorAll(itemSel)) }
|
|
1893
|
+
catch (error) { return { ok: false, error: 'invalid CSS selector ' + JSON.stringify(itemSel) + ' (' + String((error && error.message) || error) + ')' } }
|
|
1641
1894
|
if (items.length > 0) break
|
|
1642
1895
|
if (Date.now() >= deadline) return { ok: false, error: 'no elements matched: ' + itemSel }
|
|
1643
1896
|
await sleep(100)
|
|
@@ -1677,13 +1930,54 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1677
1930
|
}
|
|
1678
1931
|
|
|
1679
1932
|
/**
|
|
1680
|
-
*
|
|
1681
|
-
*
|
|
1682
|
-
*
|
|
1683
|
-
*
|
|
1684
|
-
*
|
|
1685
|
-
*
|
|
1686
|
-
*
|
|
1933
|
+
* Admit a caller-supplied save path for a file the browser writes to disk.
|
|
1934
|
+
* ONE gate for both `browser_download` and `browser_screenshot`: the path
|
|
1935
|
+
* must be absolute, must resolve inside `downloadDir`, and must not already
|
|
1936
|
+
* exist. Without it a prompt-injected agent could write — or silently
|
|
1937
|
+
* replace — any file the DSH process can reach, which also escapes the file
|
|
1938
|
+
* sandbox every other tool in the set runs under.
|
|
1939
|
+
* @param savePath - the caller's target path.
|
|
1940
|
+
* @param kind - the operation, used in the error code and message.
|
|
1941
|
+
* @returns the resolved absolute target path.
|
|
1942
|
+
* @throws BrowserError when the path is relative, outside the directory, or occupied.
|
|
1943
|
+
*/
|
|
1944
|
+
private admitSavePath(savePath: string, kind: 'download' | 'screenshot'): string {
|
|
1945
|
+
const code = kind === 'download' ? 'BROWSER_DOWNLOAD_BLOCKED' : 'BROWSER_SCREENSHOT_BLOCKED'
|
|
1946
|
+
if (!isAbsolute(savePath)) {
|
|
1947
|
+
throw new BrowserError(`browser: ${kind} savePath must be an absolute path`, code)
|
|
1948
|
+
}
|
|
1949
|
+
const file = resolve(savePath)
|
|
1950
|
+
if (this.downloadDir !== undefined) {
|
|
1951
|
+
const dir = resolve(this.downloadDir)
|
|
1952
|
+
// Case-insensitive comparison: Windows paths are case-insensitive, and
|
|
1953
|
+
// resolve() does not normalize case. Without this, C:\Users\X\Downloads
|
|
1954
|
+
// and c:\users\x\downloads would be treated as different roots.
|
|
1955
|
+
const dirLower = dir.toLowerCase()
|
|
1956
|
+
const fileLower = file.toLowerCase()
|
|
1957
|
+
if (fileLower !== dirLower && !fileLower.startsWith(dirLower + sep.toLowerCase())) {
|
|
1958
|
+
throw new BrowserError(`browser: ${kind} savePath must be inside downloadDir "${dir}"`, code)
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
// Never replace an existing file: its previous content is unrecoverable,
|
|
1962
|
+
// and the admitted directory may hold files the human put there. A new
|
|
1963
|
+
// name is one tool call away.
|
|
1964
|
+
if (existsSync(file)) {
|
|
1965
|
+
throw new BrowserError(`browser: refusing to overwrite existing file "${file}" — use another name`, code)
|
|
1966
|
+
}
|
|
1967
|
+
return file
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
/**
|
|
1971
|
+
* Download an HTTP(S) URL with the session's cookies to a local file.
|
|
1972
|
+
* Admission is the shared {@link admitSavePath} gate; only the self-hosted
|
|
1973
|
+
* host implements it (the desktop shell's embedded views delegate downloads
|
|
1974
|
+
* to the real browser UI). Both admitted paths and screenshots are confined
|
|
1975
|
+
* to `downloadDir`, so a prompt-injected agent cannot write arbitrary
|
|
1976
|
+
* machine paths.
|
|
1977
|
+
* @param session - the session whose cookies are used.
|
|
1978
|
+
* @param request - the URL plus the absolute target path.
|
|
1979
|
+
* @param signal - optional cancellation.
|
|
1980
|
+
* @returns the path the file was written to.
|
|
1687
1981
|
*/
|
|
1688
1982
|
async download(session: BrowserSessionId, request: { readonly url: string; readonly savePath: string }, signal?: AbortSignal): Promise<{ readonly path: string }> {
|
|
1689
1983
|
const s = this.session(session)
|
|
@@ -1701,23 +1995,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1701
1995
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
1702
1996
|
throw new BrowserError(`browser: refusing download of non-HTTP(S) URL "${request.url}"`, 'BROWSER_DOWNLOAD_BLOCKED')
|
|
1703
1997
|
}
|
|
1704
|
-
// savePath admission:
|
|
1705
|
-
//
|
|
1706
|
-
|
|
1707
|
-
throw new BrowserError('browser: download savePath must be an absolute path', 'BROWSER_DOWNLOAD_BLOCKED')
|
|
1708
|
-
}
|
|
1709
|
-
if (this.downloadDir !== undefined) {
|
|
1710
|
-
const dir = resolve(this.downloadDir)
|
|
1711
|
-
const file = resolve(request.savePath)
|
|
1712
|
-
// Case-insensitive comparison: Windows paths are case-insensitive, and
|
|
1713
|
-
// resolve() does not normalize case. Without this, C:\Users\X\Downloads
|
|
1714
|
-
// and c:\users\x\downloads would be treated as different roots.
|
|
1715
|
-
const dirLower = dir.toLowerCase()
|
|
1716
|
-
const fileLower = file.toLowerCase()
|
|
1717
|
-
if (fileLower !== dirLower && !fileLower.startsWith(dirLower + sep.toLowerCase())) {
|
|
1718
|
-
throw new BrowserError(`browser: download savePath must be inside downloadDir "${dir}"`, 'BROWSER_DOWNLOAD_BLOCKED')
|
|
1719
|
-
}
|
|
1720
|
-
}
|
|
1998
|
+
// savePath admission: the shared gate — absolute, inside `downloadDir`,
|
|
1999
|
+
// and never an existing file. Screenshots use the very same one.
|
|
2000
|
+
const savePath = this.admitSavePath(request.savePath, 'download')
|
|
1721
2001
|
const downloadable = handle as { download?(url: string, savePath: string): Promise<void> }
|
|
1722
2002
|
if (typeof downloadable.download !== 'function') {
|
|
1723
2003
|
throw new BrowserError('browser: download is only available on the self-hosted browser', 'BROWSER_DOWNLOAD_UNSUPPORTED')
|
|
@@ -1730,13 +2010,13 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1730
2010
|
// reply is harmlessly consumed by the RPC layer.
|
|
1731
2011
|
const timeoutMs = 60_000
|
|
1732
2012
|
await withTimeout(
|
|
1733
|
-
downloadable.download(request.url,
|
|
2013
|
+
downloadable.download(request.url, savePath),
|
|
1734
2014
|
timeoutMs,
|
|
1735
2015
|
signal,
|
|
1736
2016
|
`browser: download timed out after ${timeoutMs}ms`,
|
|
1737
2017
|
)
|
|
1738
|
-
this.record(s, 'download', { url: request.url, savePath
|
|
1739
|
-
return { path:
|
|
2018
|
+
this.record(s, 'download', { url: request.url, savePath }, true, { result: savePath })
|
|
2019
|
+
return { path: savePath }
|
|
1740
2020
|
}
|
|
1741
2021
|
|
|
1742
2022
|
/**
|
|
@@ -1829,15 +2109,25 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1829
2109
|
return this.saveScreenshot(data, request?.savePath, 'image/png')
|
|
1830
2110
|
}
|
|
1831
2111
|
|
|
1832
|
-
/**
|
|
2112
|
+
/**
|
|
2113
|
+
* Build the data URL and optionally write the image to disk. The caller's
|
|
2114
|
+
* path goes through the SAME {@link admitSavePath} gate as a download — it
|
|
2115
|
+
* must be absolute, resolve inside `downloadDir`, and not be an existing
|
|
2116
|
+
* file — so a screenshot cannot be used to write to, or silently replace,
|
|
2117
|
+
* files anywhere the DSH process happens to have permission (issue #13).
|
|
2118
|
+
*/
|
|
1833
2119
|
private saveScreenshot(base64: string, savePath: string | undefined, mime: string): { dataUrl: string; path?: string } {
|
|
1834
2120
|
if (savePath !== undefined) {
|
|
2121
|
+
// Admission failures propagate unchanged: they describe the caller's
|
|
2122
|
+
// path, not a disk problem.
|
|
2123
|
+
const target = this.admitSavePath(savePath, 'screenshot')
|
|
1835
2124
|
try {
|
|
1836
|
-
|
|
1837
|
-
|
|
2125
|
+
mkdirSync(dirname(target), { recursive: true })
|
|
2126
|
+
writeFileSync(target, Buffer.from(base64, 'base64'))
|
|
2127
|
+
return { dataUrl: `data:${mime};base64,${base64}`, path: target }
|
|
1838
2128
|
} catch (error) {
|
|
1839
2129
|
// Report the write problem but keep the capture usable.
|
|
1840
|
-
throw new BrowserError(`browser: screenshot save to "${
|
|
2130
|
+
throw new BrowserError(`browser: screenshot save to "${target}" failed: ${String(error)}`, 'BROWSER_SCREENSHOT_SAVE_FAILED', { cause: error })
|
|
1841
2131
|
}
|
|
1842
2132
|
}
|
|
1843
2133
|
return { dataUrl: `data:${mime};base64,${base64}` }
|
|
@@ -2066,6 +2356,46 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2066
2356
|
}
|
|
2067
2357
|
}
|
|
2068
2358
|
|
|
2359
|
+
/**
|
|
2360
|
+
* Hard cap on waiting for a navigation's new document to be parsed. Bounded and
|
|
2361
|
+
* best-effort: a page that never settles must not fail the navigation.
|
|
2362
|
+
*/
|
|
2363
|
+
const SETTLE_TIMEOUT_MS = 5_000
|
|
2364
|
+
|
|
2365
|
+
/**
|
|
2366
|
+
* When the document already looks settled but its identity has NOT changed
|
|
2367
|
+
* (same-document navigation, or the outgoing document still answering), wait
|
|
2368
|
+
* only this long before returning — there is no parse in flight to wait for.
|
|
2369
|
+
*/
|
|
2370
|
+
const SETTLE_GRACE_MS = 150
|
|
2371
|
+
|
|
2372
|
+
/** Poll interval for the document-settle loop. */
|
|
2373
|
+
const SETTLE_POLL_MS = 25
|
|
2374
|
+
|
|
2375
|
+
/**
|
|
2376
|
+
* Extra time the transport gets over an in-page locate script's own budget: the
|
|
2377
|
+
* script polls for `timeoutMs` and answers only afterwards, so the outer wait
|
|
2378
|
+
* must outlast it or its generic timeout hides the in-page reason.
|
|
2379
|
+
*/
|
|
2380
|
+
const TARGET_SCRIPT_GRACE_MS = 2_000
|
|
2381
|
+
|
|
2382
|
+
/** `tab:<uuid>` ↔ `<uuid>`: ids accept either form (see locateTab). */
|
|
2383
|
+
function stripTabPrefix(id: string): string {
|
|
2384
|
+
return id.startsWith('tab:') ? id.slice(4) : id
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2387
|
+
/** Abortable sleep used by the settle loop. */
|
|
2388
|
+
function delay(ms: number, signal?: AbortSignal): Promise<void> {
|
|
2389
|
+
return new Promise<void>((resolve) => {
|
|
2390
|
+
const timer = setTimeout(() => {
|
|
2391
|
+
if (signal !== undefined) signal.removeEventListener('abort', onAbort)
|
|
2392
|
+
resolve()
|
|
2393
|
+
}, ms)
|
|
2394
|
+
const onAbort = (): void => { clearTimeout(timer); resolve() }
|
|
2395
|
+
if (signal !== undefined) signal.addEventListener('abort', onAbort, { once: true })
|
|
2396
|
+
})
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2069
2399
|
/**
|
|
2070
2400
|
* Bound a promise so a wedged CDP call surfaces as an error instead of
|
|
2071
2401
|
* hanging the tool call forever. The caller's signal, when provided, wins
|