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
|
@@ -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
|
|
@@ -303,6 +317,31 @@ const KEY_SPECS: Readonly<Record<string, { readonly key: string; readonly code:
|
|
|
303
317
|
/** Supported key names, exported for the tool's enum and error messages. */
|
|
304
318
|
export const BROWSER_KEY_NAMES: readonly string[] = Object.keys(KEY_SPECS)
|
|
305
319
|
|
|
320
|
+
/**
|
|
321
|
+
* Localized names a system Downloads folder may carry, in probe order: the
|
|
322
|
+
* English default first, then the Chinese spellings — a zh-CN desktop calls
|
|
323
|
+
* the folder `下载`, a zh-TW one `下載`, and neither is `Downloads`.
|
|
324
|
+
*/
|
|
325
|
+
const DOWNLOAD_DIR_NAMES: readonly string[] = ['Downloads', '下载', '下載']
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Resolve the default download directory when the config names none: an
|
|
329
|
+
* existing `XDG_DOWNLOAD_DIR` wins (the freedesktop standard, which is what a
|
|
330
|
+
* localized Linux desktop writes), then the first localized `Downloads` folder
|
|
331
|
+
* that exists under the home directory, and finally the English name — created
|
|
332
|
+
* on first use — when none exists yet.
|
|
333
|
+
* @returns the absolute default download directory.
|
|
334
|
+
*/
|
|
335
|
+
function defaultDownloadDir(): string {
|
|
336
|
+
const xdg = process.env.XDG_DOWNLOAD_DIR
|
|
337
|
+
if (typeof xdg === 'string' && xdg !== '' && existsSync(xdg)) return xdg
|
|
338
|
+
for (const name of DOWNLOAD_DIR_NAMES) {
|
|
339
|
+
const candidate = join(homedir(), name)
|
|
340
|
+
if (existsSync(candidate)) return candidate
|
|
341
|
+
}
|
|
342
|
+
return join(homedir(), DOWNLOAD_DIR_NAMES[0] ?? 'Downloads')
|
|
343
|
+
}
|
|
344
|
+
|
|
306
345
|
/**
|
|
307
346
|
* Browser provider over Electron views. Sessions hold an ordered list of
|
|
308
347
|
* tabs; each tab is one view created by the host. The active tab receives
|
|
@@ -326,9 +365,12 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
326
365
|
this.httpOnly = config.httpOnly ?? true
|
|
327
366
|
this.snapshotMaxElements = config.snapshotMaxElements ?? 60
|
|
328
367
|
this.contentMaxChars = config.contentMaxChars ?? 100_000
|
|
329
|
-
// Confine downloads to a dedicated directory by
|
|
330
|
-
// folder is the human-visible, browser-natural
|
|
331
|
-
|
|
368
|
+
// Confine downloads AND screenshot saves to a dedicated directory by
|
|
369
|
+
// default: the OS Downloads folder is the human-visible, browser-natural
|
|
370
|
+
// place for written files, and it is the one directory the DSH file
|
|
371
|
+
// sandbox does not have to arbitrate. The localized name is probed so a
|
|
372
|
+
// Chinese desktop (`~/下载`) works without configuring `downloadDir`.
|
|
373
|
+
this.downloadDir = config.downloadDir ?? defaultDownloadDir()
|
|
332
374
|
// Route toolbar (host UI) actions into the session model: the human and
|
|
333
375
|
// the agent then always drive the same tabs, history, and navigation.
|
|
334
376
|
this.host.onUserAction?.(action => { void this.handleUserAction(action) })
|
|
@@ -433,10 +475,15 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
433
475
|
*/
|
|
434
476
|
private locateTab(session: BrowserSessionId, tabId: string): { s: Session; index: number } {
|
|
435
477
|
const s = this.session(session)
|
|
436
|
-
|
|
478
|
+
// Accept the bare uuid as well as the "tab:<uuid>" form the tools print:
|
|
479
|
+
// ids are globally unique, so requiring the printed prefix only produced a
|
|
480
|
+
// misleading "is not open in this session" error that listed the very tab
|
|
481
|
+
// the caller had just named.
|
|
482
|
+
const wanted = stripTabPrefix(tabId)
|
|
483
|
+
const own = s.tabs.findIndex(tab => stripTabPrefix(tab.id) === wanted)
|
|
437
484
|
if (own >= 0) return { s, index: own }
|
|
438
485
|
for (const other of this.sessions.values()) {
|
|
439
|
-
const index = other.tabs.findIndex(tab => tab.id ===
|
|
486
|
+
const index = other.tabs.findIndex(tab => stripTabPrefix(tab.id) === wanted)
|
|
440
487
|
if (index >= 0) return { s: other, index }
|
|
441
488
|
}
|
|
442
489
|
const ownIds = s.tabs.map(t => t.id).join(', ') || '(none)'
|
|
@@ -479,6 +526,8 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
479
526
|
// evaluate paths so a wedged navigation surfaces as an error instead of
|
|
480
527
|
// blocking the tool call forever.
|
|
481
528
|
const timeoutMs = 30_000
|
|
529
|
+
// Document identity of the page we are leaving (see settleDocument).
|
|
530
|
+
const before = await this.documentStamp(handle)
|
|
482
531
|
const result = await withTimeout(
|
|
483
532
|
handle.sendCommand(CDP_PAGE_NAVIGATE, { url } satisfies CdpNavigateParams),
|
|
484
533
|
timeoutMs,
|
|
@@ -494,7 +543,11 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
494
543
|
if (typeof errorText === 'string' && errorText !== '') {
|
|
495
544
|
throw new BrowserError(`browser: navigation to "${url}" failed: ${errorText}`, 'BROWSER_NAVIGATION_FAILED')
|
|
496
545
|
}
|
|
546
|
+
// Record as soon as the navigation has committed: history bookkeeping must
|
|
547
|
+
// not wait on the best-effort settle below (which only delays what the
|
|
548
|
+
// NEXT observation sees, not whether this action happened).
|
|
497
549
|
this.record(s, 'navigate', { url }, true)
|
|
550
|
+
await this.settleDocument(handle, before, signal)
|
|
498
551
|
this.showActive(s)
|
|
499
552
|
} catch (error) {
|
|
500
553
|
if (!(error instanceof BrowserError && (error as { code?: string }).code === 'BROWSER_NAVIGATION_BLOCKED')) {
|
|
@@ -504,6 +557,113 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
504
557
|
}
|
|
505
558
|
}
|
|
506
559
|
|
|
560
|
+
/**
|
|
561
|
+
* A per-document stamp: `performance.timeOrigin` is unique per document load,
|
|
562
|
+
* so it tells a same-URL reload and an A→B→A redirect apart from the document
|
|
563
|
+
* that was current before the navigation. Empty string when unreadable.
|
|
564
|
+
*/
|
|
565
|
+
private async documentStamp(handle: ElectronViewHandle): Promise<string> {
|
|
566
|
+
const probe = await this.documentProbe(handle)
|
|
567
|
+
return probe?.stamp ?? ''
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* One cheap in-page reading of the document's identity and parse state.
|
|
572
|
+
*
|
|
573
|
+
* Returns null when the page did NOT answer (mid-commit, execution context
|
|
574
|
+
* destroyed) — the caller keeps waiting. A page that answered with an
|
|
575
|
+
* unexpected shape (an override or a non-conforming host; the production
|
|
576
|
+
* expression always yields a string) is reported as `unknown` rather than as
|
|
577
|
+
* "no answer": blocking the navigation for the whole budget on a page that
|
|
578
|
+
* demonstrably responded has no upside.
|
|
579
|
+
*/
|
|
580
|
+
private async documentProbe(handle: ElectronViewHandle): Promise<{ stamp: string; readyState: string } | null> {
|
|
581
|
+
try {
|
|
582
|
+
const result = await withTimeout(
|
|
583
|
+
handleSendEvaluate(handle, 'String(performance.timeOrigin) + "|" + document.readyState'),
|
|
584
|
+
3_000,
|
|
585
|
+
undefined,
|
|
586
|
+
'browser: document probe timed out',
|
|
587
|
+
)
|
|
588
|
+
if (!result.ok) return null
|
|
589
|
+
if (typeof result.value !== 'string') return { stamp: '', readyState: 'unknown' }
|
|
590
|
+
const [stamp = '', readyState = ''] = result.value.split('|')
|
|
591
|
+
return { stamp, readyState }
|
|
592
|
+
} catch {
|
|
593
|
+
return null
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Page.navigate resolves at navigation COMMIT, not at load: the new document
|
|
599
|
+
* is already current (so location.href and document.title are the new page's)
|
|
600
|
+
* while its DOM is still being parsed. browser_open snapshots immediately
|
|
601
|
+
* after navigating, which is why it could report the right title with zero
|
|
602
|
+
* interactive elements — and why a separate browser_snapshot right after
|
|
603
|
+
* always found them.
|
|
604
|
+
*
|
|
605
|
+
* Wait — bounded, best-effort — for the new document to settle: readyState
|
|
606
|
+
* must reach interactive/complete AND the document identity must have
|
|
607
|
+
* changed. A page that never settles must not fail a navigation that already
|
|
608
|
+
* succeeded, so a timeout here is swallowed.
|
|
609
|
+
*/
|
|
610
|
+
private async settleDocument(handle: ElectronViewHandle, before: string, signal?: AbortSignal): Promise<void> {
|
|
611
|
+
const started = Date.now()
|
|
612
|
+
const deadline = started + SETTLE_TIMEOUT_MS
|
|
613
|
+
for (;;) {
|
|
614
|
+
if (signal?.aborted === true) return
|
|
615
|
+
// A probe failure is expected while the navigation commits (the context is
|
|
616
|
+
// being destroyed) — keep polling rather than giving up.
|
|
617
|
+
const probe = await this.documentProbe(handle)
|
|
618
|
+
if (probe !== null) {
|
|
619
|
+
const settled = probe.readyState === 'interactive'
|
|
620
|
+
|| probe.readyState === 'complete'
|
|
621
|
+
|| probe.readyState === 'unknown'
|
|
622
|
+
const isNewDocument = before === '' || probe.stamp === '' || probe.stamp !== before
|
|
623
|
+
// The common case: the new document exists and is parsed — return now.
|
|
624
|
+
if (settled && isNewDocument) return
|
|
625
|
+
// Same document (hash navigation, cancelled/aborted load): there is no
|
|
626
|
+
// parse to wait for, so do not burn the budget. Outlast the brief
|
|
627
|
+
// window in which the OUTGOING document may still answer instead.
|
|
628
|
+
if (settled && Date.now() - started >= SETTLE_GRACE_MS) return
|
|
629
|
+
}
|
|
630
|
+
if (Date.now() >= deadline) return
|
|
631
|
+
await delay(SETTLE_POLL_MS, signal)
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Make sure the active tab's view can actually receive synthesized input.
|
|
637
|
+
* Chromium drops `Input.*` events for a view with no display surface, so this
|
|
638
|
+
* runs before click/type/key and fails loudly (BROWSER_VIEW_NOT_PRESENTED)
|
|
639
|
+
* rather than reporting a success the page never saw.
|
|
640
|
+
*/
|
|
641
|
+
private async present(s: Session, signal?: AbortSignal): Promise<void> {
|
|
642
|
+
const { handle } = this.activeTab(s)
|
|
643
|
+
const present = this.host.presentView?.bind(this.host)
|
|
644
|
+
if (present === undefined) {
|
|
645
|
+
this.showActive(s)
|
|
646
|
+
return
|
|
647
|
+
}
|
|
648
|
+
signal?.throwIfAborted()
|
|
649
|
+
const timeoutMs = 10_000
|
|
650
|
+
try {
|
|
651
|
+
await withTimeout(
|
|
652
|
+
present(handle, s.label),
|
|
653
|
+
timeoutMs,
|
|
654
|
+
signal,
|
|
655
|
+
`browser: presenting the page view timed out after ${timeoutMs}ms`,
|
|
656
|
+
)
|
|
657
|
+
this.showActive(s)
|
|
658
|
+
} catch (error) {
|
|
659
|
+
throw new BrowserError(
|
|
660
|
+
`browser: the page view is not presented, so synthesized input would be silently dropped (${String(error)})`,
|
|
661
|
+
'BROWSER_VIEW_NOT_PRESENTED',
|
|
662
|
+
{ cause: error },
|
|
663
|
+
)
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
507
667
|
/** Execute JS in the active tab's page context. */
|
|
508
668
|
async execute(session: BrowserSessionId, request: BrowserExecuteRequest, signal?: AbortSignal): Promise<BrowserExecuteResult> {
|
|
509
669
|
const s = this.session(session)
|
|
@@ -1033,6 +1193,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1033
1193
|
x = request.x
|
|
1034
1194
|
y = request.y
|
|
1035
1195
|
}
|
|
1196
|
+
// Input.* is only delivered to a view with a current display surface; the
|
|
1197
|
+
// barrier also re-presents after a navigation replaced the renderer.
|
|
1198
|
+
await this.present(s, signal)
|
|
1036
1199
|
const press = (type: 'mousePressed' | 'mouseReleased'): Promise<Record<string, unknown>> =>
|
|
1037
1200
|
handle.sendCommand('Input.dispatchMouseEvent', { type, x, y, button: 'left', clickCount: 1 } satisfies CdpMouseParams)
|
|
1038
1201
|
const timeoutMs = 30_000
|
|
@@ -1078,6 +1241,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1078
1241
|
}
|
|
1079
1242
|
const text = 'text' in request ? request.text : ''
|
|
1080
1243
|
const timeoutMs = 30_000
|
|
1244
|
+
// Input.insertText goes through the Input domain: same display-surface
|
|
1245
|
+
// requirement as click/key.
|
|
1246
|
+
await this.present(s, signal)
|
|
1081
1247
|
try {
|
|
1082
1248
|
await withTimeout(
|
|
1083
1249
|
handle.sendCommand('Input.insertText', { text } satisfies CdpInsertTextParams),
|
|
@@ -1149,6 +1315,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1149
1315
|
}
|
|
1150
1316
|
const entry = entries[target]
|
|
1151
1317
|
if (entry?.id === undefined) throw new BrowserError('browser: history entry missing id', 'BROWSER_HISTORY_INVALID')
|
|
1318
|
+
// A history step is a navigation too: the classic A→B→A redirect would
|
|
1319
|
+
// otherwise read the outgoing document's identity (see settleDocument).
|
|
1320
|
+
const before = await this.documentStamp(handle)
|
|
1152
1321
|
await withTimeout(
|
|
1153
1322
|
handle.sendCommand('Page.navigateToHistoryEntry', { entryId: entry.id }),
|
|
1154
1323
|
timeoutMs,
|
|
@@ -1157,6 +1326,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1157
1326
|
() => { void handle.sendCommand('Page.stopLoading').catch(() => {}) },
|
|
1158
1327
|
)
|
|
1159
1328
|
this.record(s, direction === -1 ? 'back' : 'forward', {}, true)
|
|
1329
|
+
await this.settleDocument(handle, before, signal)
|
|
1160
1330
|
this.showActive(s)
|
|
1161
1331
|
}
|
|
1162
1332
|
|
|
@@ -1176,6 +1346,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1176
1346
|
const { handle } = this.activeTab(s)
|
|
1177
1347
|
signal?.throwIfAborted()
|
|
1178
1348
|
const timeoutMs = 30_000
|
|
1349
|
+
// Same-URL reload: only the per-load document identity can tell the new
|
|
1350
|
+
// document from the old one, so capture it before reloading.
|
|
1351
|
+
const before = await this.documentStamp(handle)
|
|
1179
1352
|
await withTimeout(
|
|
1180
1353
|
handle.sendCommand('Page.reload', { ignoreCache: false }),
|
|
1181
1354
|
timeoutMs,
|
|
@@ -1185,6 +1358,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1185
1358
|
() => { void handle.sendCommand('Page.stopLoading').catch(() => {}) },
|
|
1186
1359
|
)
|
|
1187
1360
|
this.record(s, 'reload', {}, true)
|
|
1361
|
+
await this.settleDocument(handle, before, signal)
|
|
1188
1362
|
this.showActive(s)
|
|
1189
1363
|
}
|
|
1190
1364
|
|
|
@@ -1199,6 +1373,8 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1199
1373
|
}
|
|
1200
1374
|
const params = { key: spec.key, code: spec.code, windowsVirtualKeyCode: spec.vk, nativeVirtualKeyCode: spec.vk, ...spec.text !== undefined ? { text: spec.text } : {} }
|
|
1201
1375
|
const timeoutMs = 15_000
|
|
1376
|
+
// Input.dispatchKeyEvent is subject to the same display-surface rule.
|
|
1377
|
+
await this.present(s, signal)
|
|
1202
1378
|
const release = (): Promise<Record<string, unknown>> =>
|
|
1203
1379
|
handle.sendCommand('Input.dispatchKeyEvent', { type: 'keyUp', ...params })
|
|
1204
1380
|
try {
|
|
@@ -1677,13 +1853,54 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1677
1853
|
}
|
|
1678
1854
|
|
|
1679
1855
|
/**
|
|
1680
|
-
*
|
|
1681
|
-
*
|
|
1682
|
-
*
|
|
1683
|
-
*
|
|
1684
|
-
*
|
|
1685
|
-
*
|
|
1686
|
-
*
|
|
1856
|
+
* Admit a caller-supplied save path for a file the browser writes to disk.
|
|
1857
|
+
* ONE gate for both `browser_download` and `browser_screenshot`: the path
|
|
1858
|
+
* must be absolute, must resolve inside `downloadDir`, and must not already
|
|
1859
|
+
* exist. Without it a prompt-injected agent could write — or silently
|
|
1860
|
+
* replace — any file the DSH process can reach, which also escapes the file
|
|
1861
|
+
* sandbox every other tool in the set runs under.
|
|
1862
|
+
* @param savePath - the caller's target path.
|
|
1863
|
+
* @param kind - the operation, used in the error code and message.
|
|
1864
|
+
* @returns the resolved absolute target path.
|
|
1865
|
+
* @throws BrowserError when the path is relative, outside the directory, or occupied.
|
|
1866
|
+
*/
|
|
1867
|
+
private admitSavePath(savePath: string, kind: 'download' | 'screenshot'): string {
|
|
1868
|
+
const code = kind === 'download' ? 'BROWSER_DOWNLOAD_BLOCKED' : 'BROWSER_SCREENSHOT_BLOCKED'
|
|
1869
|
+
if (!isAbsolute(savePath)) {
|
|
1870
|
+
throw new BrowserError(`browser: ${kind} savePath must be an absolute path`, code)
|
|
1871
|
+
}
|
|
1872
|
+
const file = resolve(savePath)
|
|
1873
|
+
if (this.downloadDir !== undefined) {
|
|
1874
|
+
const dir = resolve(this.downloadDir)
|
|
1875
|
+
// Case-insensitive comparison: Windows paths are case-insensitive, and
|
|
1876
|
+
// resolve() does not normalize case. Without this, C:\Users\X\Downloads
|
|
1877
|
+
// and c:\users\x\downloads would be treated as different roots.
|
|
1878
|
+
const dirLower = dir.toLowerCase()
|
|
1879
|
+
const fileLower = file.toLowerCase()
|
|
1880
|
+
if (fileLower !== dirLower && !fileLower.startsWith(dirLower + sep.toLowerCase())) {
|
|
1881
|
+
throw new BrowserError(`browser: ${kind} savePath must be inside downloadDir "${dir}"`, code)
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
// Never replace an existing file: its previous content is unrecoverable,
|
|
1885
|
+
// and the admitted directory may hold files the human put there. A new
|
|
1886
|
+
// name is one tool call away.
|
|
1887
|
+
if (existsSync(file)) {
|
|
1888
|
+
throw new BrowserError(`browser: refusing to overwrite existing file "${file}" — use another name`, code)
|
|
1889
|
+
}
|
|
1890
|
+
return file
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
/**
|
|
1894
|
+
* Download an HTTP(S) URL with the session's cookies to a local file.
|
|
1895
|
+
* Admission is the shared {@link admitSavePath} gate; only the self-hosted
|
|
1896
|
+
* host implements it (the desktop shell's embedded views delegate downloads
|
|
1897
|
+
* to the real browser UI). Both admitted paths and screenshots are confined
|
|
1898
|
+
* to `downloadDir`, so a prompt-injected agent cannot write arbitrary
|
|
1899
|
+
* machine paths.
|
|
1900
|
+
* @param session - the session whose cookies are used.
|
|
1901
|
+
* @param request - the URL plus the absolute target path.
|
|
1902
|
+
* @param signal - optional cancellation.
|
|
1903
|
+
* @returns the path the file was written to.
|
|
1687
1904
|
*/
|
|
1688
1905
|
async download(session: BrowserSessionId, request: { readonly url: string; readonly savePath: string }, signal?: AbortSignal): Promise<{ readonly path: string }> {
|
|
1689
1906
|
const s = this.session(session)
|
|
@@ -1701,23 +1918,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1701
1918
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
1702
1919
|
throw new BrowserError(`browser: refusing download of non-HTTP(S) URL "${request.url}"`, 'BROWSER_DOWNLOAD_BLOCKED')
|
|
1703
1920
|
}
|
|
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
|
-
}
|
|
1921
|
+
// savePath admission: the shared gate — absolute, inside `downloadDir`,
|
|
1922
|
+
// and never an existing file. Screenshots use the very same one.
|
|
1923
|
+
const savePath = this.admitSavePath(request.savePath, 'download')
|
|
1721
1924
|
const downloadable = handle as { download?(url: string, savePath: string): Promise<void> }
|
|
1722
1925
|
if (typeof downloadable.download !== 'function') {
|
|
1723
1926
|
throw new BrowserError('browser: download is only available on the self-hosted browser', 'BROWSER_DOWNLOAD_UNSUPPORTED')
|
|
@@ -1730,13 +1933,13 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1730
1933
|
// reply is harmlessly consumed by the RPC layer.
|
|
1731
1934
|
const timeoutMs = 60_000
|
|
1732
1935
|
await withTimeout(
|
|
1733
|
-
downloadable.download(request.url,
|
|
1936
|
+
downloadable.download(request.url, savePath),
|
|
1734
1937
|
timeoutMs,
|
|
1735
1938
|
signal,
|
|
1736
1939
|
`browser: download timed out after ${timeoutMs}ms`,
|
|
1737
1940
|
)
|
|
1738
|
-
this.record(s, 'download', { url: request.url, savePath
|
|
1739
|
-
return { path:
|
|
1941
|
+
this.record(s, 'download', { url: request.url, savePath }, true, { result: savePath })
|
|
1942
|
+
return { path: savePath }
|
|
1740
1943
|
}
|
|
1741
1944
|
|
|
1742
1945
|
/**
|
|
@@ -1829,15 +2032,25 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1829
2032
|
return this.saveScreenshot(data, request?.savePath, 'image/png')
|
|
1830
2033
|
}
|
|
1831
2034
|
|
|
1832
|
-
/**
|
|
2035
|
+
/**
|
|
2036
|
+
* Build the data URL and optionally write the image to disk. The caller's
|
|
2037
|
+
* path goes through the SAME {@link admitSavePath} gate as a download — it
|
|
2038
|
+
* must be absolute, resolve inside `downloadDir`, and not be an existing
|
|
2039
|
+
* file — so a screenshot cannot be used to write to, or silently replace,
|
|
2040
|
+
* files anywhere the DSH process happens to have permission (issue #13).
|
|
2041
|
+
*/
|
|
1833
2042
|
private saveScreenshot(base64: string, savePath: string | undefined, mime: string): { dataUrl: string; path?: string } {
|
|
1834
2043
|
if (savePath !== undefined) {
|
|
2044
|
+
// Admission failures propagate unchanged: they describe the caller's
|
|
2045
|
+
// path, not a disk problem.
|
|
2046
|
+
const target = this.admitSavePath(savePath, 'screenshot')
|
|
1835
2047
|
try {
|
|
1836
|
-
|
|
1837
|
-
|
|
2048
|
+
mkdirSync(dirname(target), { recursive: true })
|
|
2049
|
+
writeFileSync(target, Buffer.from(base64, 'base64'))
|
|
2050
|
+
return { dataUrl: `data:${mime};base64,${base64}`, path: target }
|
|
1838
2051
|
} catch (error) {
|
|
1839
2052
|
// Report the write problem but keep the capture usable.
|
|
1840
|
-
throw new BrowserError(`browser: screenshot save to "${
|
|
2053
|
+
throw new BrowserError(`browser: screenshot save to "${target}" failed: ${String(error)}`, 'BROWSER_SCREENSHOT_SAVE_FAILED', { cause: error })
|
|
1841
2054
|
}
|
|
1842
2055
|
}
|
|
1843
2056
|
return { dataUrl: `data:${mime};base64,${base64}` }
|
|
@@ -2066,6 +2279,39 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2066
2279
|
}
|
|
2067
2280
|
}
|
|
2068
2281
|
|
|
2282
|
+
/**
|
|
2283
|
+
* Hard cap on waiting for a navigation's new document to be parsed. Bounded and
|
|
2284
|
+
* best-effort: a page that never settles must not fail the navigation.
|
|
2285
|
+
*/
|
|
2286
|
+
const SETTLE_TIMEOUT_MS = 5_000
|
|
2287
|
+
|
|
2288
|
+
/**
|
|
2289
|
+
* When the document already looks settled but its identity has NOT changed
|
|
2290
|
+
* (same-document navigation, or the outgoing document still answering), wait
|
|
2291
|
+
* only this long before returning — there is no parse in flight to wait for.
|
|
2292
|
+
*/
|
|
2293
|
+
const SETTLE_GRACE_MS = 150
|
|
2294
|
+
|
|
2295
|
+
/** Poll interval for the document-settle loop. */
|
|
2296
|
+
const SETTLE_POLL_MS = 25
|
|
2297
|
+
|
|
2298
|
+
/** `tab:<uuid>` ↔ `<uuid>`: ids accept either form (see locateTab). */
|
|
2299
|
+
function stripTabPrefix(id: string): string {
|
|
2300
|
+
return id.startsWith('tab:') ? id.slice(4) : id
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
/** Abortable sleep used by the settle loop. */
|
|
2304
|
+
function delay(ms: number, signal?: AbortSignal): Promise<void> {
|
|
2305
|
+
return new Promise<void>((resolve) => {
|
|
2306
|
+
const timer = setTimeout(() => {
|
|
2307
|
+
if (signal !== undefined) signal.removeEventListener('abort', onAbort)
|
|
2308
|
+
resolve()
|
|
2309
|
+
}, ms)
|
|
2310
|
+
const onAbort = (): void => { clearTimeout(timer); resolve() }
|
|
2311
|
+
if (signal !== undefined) signal.addEventListener('abort', onAbort, { once: true })
|
|
2312
|
+
})
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2069
2315
|
/**
|
|
2070
2316
|
* Bound a promise so a wedged CDP call surfaces as an error instead of
|
|
2071
2317
|
* hanging the tool call forever. The caller's signal, when provided, wins
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
import { execFileSync, spawn, type ChildProcessByStdio } from 'node:child_process'
|
|
29
|
+
import { appendFileSync, mkdirSync, statSync, writeFileSync } from 'node:fs'
|
|
29
30
|
import { randomBytes } from 'node:crypto'
|
|
30
31
|
import { createRequire } from 'node:module'
|
|
31
32
|
import { existsSync, readFileSync, readdirSync, readlinkSync } from 'node:fs'
|
|
@@ -38,6 +39,9 @@ import type { ExportedCookie } from '../browser/types.js'
|
|
|
38
39
|
/** How long to wait for the child to signal readiness before failing. */
|
|
39
40
|
const READY_TIMEOUT_MS = 20_000
|
|
40
41
|
|
|
42
|
+
/** Budget for the present/barrier round-trips (see presentView). */
|
|
43
|
+
const PRESENT_TIMEOUT_MS = 10_000
|
|
44
|
+
|
|
41
45
|
/** Safety cap on a single RPC reply line (base64 downloads are the big ones). */
|
|
42
46
|
const MAX_RPC_BUFFER_BYTES = 512 * 1024 * 1024
|
|
43
47
|
|
|
@@ -355,23 +359,32 @@ function compareVersions(a: string, b: string): number {
|
|
|
355
359
|
return 0
|
|
356
360
|
}
|
|
357
361
|
|
|
358
|
-
/**
|
|
359
|
-
function
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
join(
|
|
364
|
-
|
|
362
|
+
/** Candidate executable layouts used by the Electron npm package on one platform. */
|
|
363
|
+
function electronDistCandidates(pkgRoot: string, platform: NodeJS.Platform = process.platform): string[] {
|
|
364
|
+
// macOS keeps the runnable binary inside the Electron.app bundle; unlike
|
|
365
|
+
// Windows/Linux there is no dist/electron(.exe) sibling to probe.
|
|
366
|
+
if (platform === 'darwin') {
|
|
367
|
+
return [join(pkgRoot, 'dist', 'Electron.app', 'Contents', 'MacOS', 'Electron')]
|
|
368
|
+
}
|
|
369
|
+
return [
|
|
370
|
+
join(pkgRoot, 'dist', 'electron.exe'),
|
|
371
|
+
join(pkgRoot, 'dist', 'electron'),
|
|
365
372
|
]
|
|
366
|
-
|
|
367
|
-
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** From an electron package entry file, find the dist executable beside it. */
|
|
376
|
+
function electronExeBeside(entry: string, platform: NodeJS.Platform = process.platform): string | undefined {
|
|
377
|
+
const packageRoots = [dirname(entry), join(dirname(entry), '..')]
|
|
378
|
+
for (const packageRoot of packageRoots) {
|
|
379
|
+
const candidate = electronDistExe(packageRoot, platform)
|
|
380
|
+
if (candidate !== undefined) return candidate
|
|
368
381
|
}
|
|
369
382
|
return undefined
|
|
370
383
|
}
|
|
371
384
|
|
|
372
385
|
/** From an electron package root, find its dist executable. */
|
|
373
|
-
function electronDistExe(pkgRoot: string): string | undefined {
|
|
374
|
-
for (const candidate of
|
|
386
|
+
function electronDistExe(pkgRoot: string, platform: NodeJS.Platform = process.platform): string | undefined {
|
|
387
|
+
for (const candidate of electronDistCandidates(pkgRoot, platform)) {
|
|
375
388
|
if (existsSync(candidate)) return candidate
|
|
376
389
|
}
|
|
377
390
|
return undefined
|
|
@@ -452,8 +465,13 @@ class ElectronChildClient {
|
|
|
452
465
|
})
|
|
453
466
|
this.child.stderr.setEncoding('utf8')
|
|
454
467
|
this.child.stderr.on('data', chunk => {
|
|
455
|
-
// Diagnostics only; never parse stderr as protocol.
|
|
456
|
-
|
|
468
|
+
// Diagnostics only; never parse stderr as protocol. Mirror to a log file
|
|
469
|
+
// as well: under a plain `dsh web` self-host these lines only ever reach
|
|
470
|
+
// the DSH process's stderr, so a host that crashes in a restart loop left
|
|
471
|
+
// the user with a string of timeouts and no stack to act on.
|
|
472
|
+
const text = String(chunk)
|
|
473
|
+
process.stderr.write(`[dsh-browser host] ${text}`)
|
|
474
|
+
appendHostLog(text)
|
|
457
475
|
})
|
|
458
476
|
// Send the spawn token over stdin (first line). The child reads it from
|
|
459
477
|
// stdin instead of argv so the token is never visible in the process
|
|
@@ -464,11 +482,17 @@ class ElectronChildClient {
|
|
|
464
482
|
// A failed spawn (bad/corrupt binary) emits 'error' — without a listener
|
|
465
483
|
// that would crash the whole DSH process.
|
|
466
484
|
this.child.on('error', error => {
|
|
467
|
-
|
|
485
|
+
const line = `spawn error: ${String(error)}`
|
|
486
|
+
process.stderr.write(`[dsh-browser host] ${line}\n`)
|
|
487
|
+
appendHostLog(`${line}\n`)
|
|
468
488
|
this.fail(new Error(`dsh-builtin-browser: browser host failed to start: ${String(error)}`))
|
|
469
489
|
})
|
|
470
490
|
this.child.on('exit', (code, signal) => {
|
|
471
|
-
|
|
491
|
+
// The exit code/signal is the other half of a crash report; without it a
|
|
492
|
+
// restart loop is indistinguishable from a clean stop.
|
|
493
|
+
const line = `browser host exited (code=${String(code)} signal=${String(signal)})`
|
|
494
|
+
appendHostLog(`${line}\n`)
|
|
495
|
+
this.fail(new Error(`dsh-builtin-browser: ${line}`))
|
|
472
496
|
})
|
|
473
497
|
}
|
|
474
498
|
|
|
@@ -860,6 +884,39 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
|
|
|
860
884
|
.catch(() => { /* host unavailable */ })
|
|
861
885
|
}
|
|
862
886
|
|
|
887
|
+
/**
|
|
888
|
+
* (Re)present a view and WAIT for the child to confirm it, so the provider
|
|
889
|
+
* can dispatch CDP `Input.*` knowing the view has a display surface.
|
|
890
|
+
*
|
|
891
|
+
* Two things this fixes, both silent before:
|
|
892
|
+
* - showing a view that does not exist yet is a no-op in the child, so the
|
|
893
|
+
* view must be materialized first (a fresh tab's createView races the
|
|
894
|
+
* provider's showActive);
|
|
895
|
+
* - a view whose renderer was replaced by a navigation is \"already\n * visible\" and therefore skipped by the child's flicker-avoidance fast
|
|
896
|
+
* path, leaving the new renderer without a surface.
|
|
897
|
+
*
|
|
898
|
+
* The barrier ping is meaningful because the child handles messages strictly
|
|
899
|
+
* in order: its reply is queued behind the showView and so only arrives once
|
|
900
|
+
* the visibility change has been applied.
|
|
901
|
+
*/
|
|
902
|
+
async presentView(handle: ElectronViewHandle, label?: string): Promise<void> {
|
|
903
|
+
await this.ready()
|
|
904
|
+
const client = this.client
|
|
905
|
+
if (client === undefined) throw new HostGoneError('browser host unavailable')
|
|
906
|
+
// A view created after the last createView does not exist in the child yet.
|
|
907
|
+
// ensureMaterialized also rebuilds it against a fresh child after a host
|
|
908
|
+
// death, which is the path that used to leave sessions timing out forever.
|
|
909
|
+
if (handle instanceof DeferredRemoteView) {
|
|
910
|
+
await withTimeout(handle.ensureMaterialized(), PRESENT_TIMEOUT_MS, 'browser host did not materialize the view')
|
|
911
|
+
}
|
|
912
|
+
await withTimeout(
|
|
913
|
+
client.call('showView', { viewId: handle.id, ...label !== undefined ? { label } : {} }),
|
|
914
|
+
PRESENT_TIMEOUT_MS,
|
|
915
|
+
'browser host did not confirm the view presentation',
|
|
916
|
+
)
|
|
917
|
+
await withTimeout(client.call('ping'), PRESENT_TIMEOUT_MS, 'browser host stopped answering')
|
|
918
|
+
}
|
|
919
|
+
|
|
863
920
|
/** Route a view into its session's own window (one window per session). */
|
|
864
921
|
groupView(handle: ElectronViewHandle, windowId: string, label?: string): void {
|
|
865
922
|
this.groups.set(handle.id, { windowId, ...label !== undefined ? { label } : {} })
|
|
@@ -977,6 +1034,33 @@ class DeferredRemoteView implements ElectronViewHandle {
|
|
|
977
1034
|
async restoreAuth(cookies: ExportedCookie[]): Promise<number> {
|
|
978
1035
|
return this.withRecovery(view => view.restoreAuth(cookies))
|
|
979
1036
|
}
|
|
1037
|
+
|
|
1038
|
+
/** Materialize this view in the child (no-op when already there). */
|
|
1039
|
+
async ensureMaterialized(): Promise<void> {
|
|
1040
|
+
await this.withRecovery(async () => undefined)
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
/**
|
|
1045
|
+
* Append one host-diagnostics chunk to `$DSH_HOME/logs/dsh-builtin-browser-host.log`.
|
|
1046
|
+
* Best-effort and self-limiting: diagnostics must never break the host or grow
|
|
1047
|
+
* without bound, so failures are swallowed and the file is truncated when it
|
|
1048
|
+
* gets large (a crash loop writes quickly).
|
|
1049
|
+
*/
|
|
1050
|
+
const HOST_LOG_MAX_BYTES = 2 * 1024 * 1024
|
|
1051
|
+
|
|
1052
|
+
function appendHostLog(text: string): void {
|
|
1053
|
+
const home = process.env.DSH_HOME
|
|
1054
|
+
if (home === undefined || home === '') return
|
|
1055
|
+
try {
|
|
1056
|
+
const dir = join(home, 'logs')
|
|
1057
|
+
const file = join(dir, 'dsh-builtin-browser-host.log')
|
|
1058
|
+
try {
|
|
1059
|
+
if (statSync(file).size > HOST_LOG_MAX_BYTES) writeFileSync(file, '')
|
|
1060
|
+
} catch { /* first write, or unreadable: just append */ }
|
|
1061
|
+
mkdirSync(dir, { recursive: true })
|
|
1062
|
+
appendFileSync(file, text)
|
|
1063
|
+
} catch { /* diagnostics are best-effort only */ }
|
|
980
1064
|
}
|
|
981
1065
|
|
|
982
1066
|
/** Reject a promise if it does not settle within the budget. */
|
|
@@ -1001,6 +1085,9 @@ export function defaultHostMainPath(): string {
|
|
|
1001
1085
|
* steps; `resolveElectronPath` is the full resolution order.
|
|
1002
1086
|
*/
|
|
1003
1087
|
export const internals = {
|
|
1088
|
+
electronDistCandidates,
|
|
1089
|
+
electronDistExe,
|
|
1090
|
+
electronExeBeside,
|
|
1004
1091
|
isBareElectron,
|
|
1005
1092
|
resolveElectronPath,
|
|
1006
1093
|
resolveElectronPathImpl,
|