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