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.
@@ -25,6 +25,7 @@
25
25
  * @module dsh-browser/browser-electron/remote-host
26
26
  */
27
27
  import { execFileSync, spawn } from 'node:child_process';
28
+ import { appendFileSync, mkdirSync, statSync, writeFileSync } from 'node:fs';
28
29
  import { randomBytes } from 'node:crypto';
29
30
  import { createRequire } from 'node:module';
30
31
  import { existsSync, readFileSync, readdirSync, readlinkSync } from 'node:fs';
@@ -33,6 +34,8 @@ import { createServer } from 'node:net';
33
34
  import { fileURLToPath } from 'node:url';
34
35
  /** How long to wait for the child to signal readiness before failing. */
35
36
  const READY_TIMEOUT_MS = 20_000;
37
+ /** Budget for the present/barrier round-trips (see presentView). */
38
+ const PRESENT_TIMEOUT_MS = 10_000;
36
39
  /** Safety cap on a single RPC reply line (base64 downloads are the big ones). */
37
40
  const MAX_RPC_BUFFER_BYTES = 512 * 1024 * 1024;
38
41
  function resolveElectronPath() {
@@ -316,23 +319,31 @@ function compareVersions(a, b) {
316
319
  }
317
320
  return 0;
318
321
  }
319
- /** From an electron package entry file, find the dist executable beside it. */
320
- function electronExeBeside(entry) {
321
- const candidates = [
322
- join(dirname(entry), 'dist', 'electron.exe'),
323
- join(dirname(entry), 'dist', 'electron'),
324
- join(dirname(entry), '..', 'dist', 'electron.exe'),
325
- join(dirname(entry), '..', 'dist', 'electron'),
322
+ /** Candidate executable layouts used by the Electron npm package on one platform. */
323
+ function electronDistCandidates(pkgRoot, platform = process.platform) {
324
+ // macOS keeps the runnable binary inside the Electron.app bundle; unlike
325
+ // Windows/Linux there is no dist/electron(.exe) sibling to probe.
326
+ if (platform === 'darwin') {
327
+ return [join(pkgRoot, 'dist', 'Electron.app', 'Contents', 'MacOS', 'Electron')];
328
+ }
329
+ return [
330
+ join(pkgRoot, 'dist', 'electron.exe'),
331
+ join(pkgRoot, 'dist', 'electron'),
326
332
  ];
327
- for (const candidate of candidates) {
328
- if (existsSync(candidate))
333
+ }
334
+ /** From an electron package entry file, find the dist executable beside it. */
335
+ function electronExeBeside(entry, platform = process.platform) {
336
+ const packageRoots = [dirname(entry), join(dirname(entry), '..')];
337
+ for (const packageRoot of packageRoots) {
338
+ const candidate = electronDistExe(packageRoot, platform);
339
+ if (candidate !== undefined)
329
340
  return candidate;
330
341
  }
331
342
  return undefined;
332
343
  }
333
344
  /** From an electron package root, find its dist executable. */
334
- function electronDistExe(pkgRoot) {
335
- for (const candidate of [join(pkgRoot, 'dist', 'electron.exe'), join(pkgRoot, 'dist', 'electron')]) {
345
+ function electronDistExe(pkgRoot, platform = process.platform) {
346
+ for (const candidate of electronDistCandidates(pkgRoot, platform)) {
336
347
  if (existsSync(candidate))
337
348
  return candidate;
338
349
  }
@@ -411,8 +422,13 @@ class ElectronChildClient {
411
422
  });
412
423
  this.child.stderr.setEncoding('utf8');
413
424
  this.child.stderr.on('data', chunk => {
414
- // Diagnostics only; never parse stderr as protocol.
415
- process.stderr.write(`[dsh-browser host] ${String(chunk)}`);
425
+ // Diagnostics only; never parse stderr as protocol. Mirror to a log file
426
+ // as well: under a plain `dsh web` self-host these lines only ever reach
427
+ // the DSH process's stderr, so a host that crashes in a restart loop left
428
+ // the user with a string of timeouts and no stack to act on.
429
+ const text = String(chunk);
430
+ process.stderr.write(`[dsh-browser host] ${text}`);
431
+ appendHostLog(text);
416
432
  });
417
433
  // Send the spawn token over stdin (first line). The child reads it from
418
434
  // stdin instead of argv so the token is never visible in the process
@@ -423,11 +439,17 @@ class ElectronChildClient {
423
439
  // A failed spawn (bad/corrupt binary) emits 'error' — without a listener
424
440
  // that would crash the whole DSH process.
425
441
  this.child.on('error', error => {
426
- process.stderr.write(`[dsh-browser host] spawn error: ${String(error)}\n`);
442
+ const line = `spawn error: ${String(error)}`;
443
+ process.stderr.write(`[dsh-browser host] ${line}\n`);
444
+ appendHostLog(`${line}\n`);
427
445
  this.fail(new Error(`dsh-builtin-browser: browser host failed to start: ${String(error)}`));
428
446
  });
429
447
  this.child.on('exit', (code, signal) => {
430
- this.fail(new Error(`dsh-builtin-browser: browser host exited (code=${String(code)} signal=${String(signal)})`));
448
+ // The exit code/signal is the other half of a crash report; without it a
449
+ // restart loop is indistinguishable from a clean stop.
450
+ const line = `browser host exited (code=${String(code)} signal=${String(signal)})`;
451
+ appendHostLog(`${line}\n`);
452
+ this.fail(new Error(`dsh-builtin-browser: ${line}`));
431
453
  });
432
454
  }
433
455
  /** Reject everything in flight, mark the client dead, and notify the host. */
@@ -823,6 +845,35 @@ export class RemoteElectronViewHost {
823
845
  .then(() => this.client?.call('showView', { viewId: handle.id, ...label !== undefined ? { label } : {} }))
824
846
  .catch(() => { });
825
847
  }
848
+ /**
849
+ * (Re)present a view and WAIT for the child to confirm it, so the provider
850
+ * can dispatch CDP `Input.*` knowing the view has a display surface.
851
+ *
852
+ * Two things this fixes, both silent before:
853
+ * - showing a view that does not exist yet is a no-op in the child, so the
854
+ * view must be materialized first (a fresh tab's createView races the
855
+ * provider's showActive);
856
+ * - a view whose renderer was replaced by a navigation is \"already\n * visible\" and therefore skipped by the child's flicker-avoidance fast
857
+ * path, leaving the new renderer without a surface.
858
+ *
859
+ * The barrier ping is meaningful because the child handles messages strictly
860
+ * in order: its reply is queued behind the showView and so only arrives once
861
+ * the visibility change has been applied.
862
+ */
863
+ async presentView(handle, label) {
864
+ await this.ready();
865
+ const client = this.client;
866
+ if (client === undefined)
867
+ throw new HostGoneError('browser host unavailable');
868
+ // A view created after the last createView does not exist in the child yet.
869
+ // ensureMaterialized also rebuilds it against a fresh child after a host
870
+ // death, which is the path that used to leave sessions timing out forever.
871
+ if (handle instanceof DeferredRemoteView) {
872
+ await withTimeout(handle.ensureMaterialized(), PRESENT_TIMEOUT_MS, 'browser host did not materialize the view');
873
+ }
874
+ await withTimeout(client.call('showView', { viewId: handle.id, ...label !== undefined ? { label } : {} }), PRESENT_TIMEOUT_MS, 'browser host did not confirm the view presentation');
875
+ await withTimeout(client.call('ping'), PRESENT_TIMEOUT_MS, 'browser host stopped answering');
876
+ }
826
877
  /** Route a view into its session's own window (one window per session). */
827
878
  groupView(handle, windowId, label) {
828
879
  this.groups.set(handle.id, { windowId, ...label !== undefined ? { label } : {} });
@@ -933,6 +984,34 @@ class DeferredRemoteView {
933
984
  async restoreAuth(cookies) {
934
985
  return this.withRecovery(view => view.restoreAuth(cookies));
935
986
  }
987
+ /** Materialize this view in the child (no-op when already there). */
988
+ async ensureMaterialized() {
989
+ await this.withRecovery(async () => undefined);
990
+ }
991
+ }
992
+ /**
993
+ * Append one host-diagnostics chunk to `$DSH_HOME/logs/dsh-builtin-browser-host.log`.
994
+ * Best-effort and self-limiting: diagnostics must never break the host or grow
995
+ * without bound, so failures are swallowed and the file is truncated when it
996
+ * gets large (a crash loop writes quickly).
997
+ */
998
+ const HOST_LOG_MAX_BYTES = 2 * 1024 * 1024;
999
+ function appendHostLog(text) {
1000
+ const home = process.env.DSH_HOME;
1001
+ if (home === undefined || home === '')
1002
+ return;
1003
+ try {
1004
+ const dir = join(home, 'logs');
1005
+ const file = join(dir, 'dsh-builtin-browser-host.log');
1006
+ try {
1007
+ if (statSync(file).size > HOST_LOG_MAX_BYTES)
1008
+ writeFileSync(file, '');
1009
+ }
1010
+ catch { /* first write, or unreadable: just append */ }
1011
+ mkdirSync(dir, { recursive: true });
1012
+ appendFileSync(file, text);
1013
+ }
1014
+ catch { /* diagnostics are best-effort only */ }
936
1015
  }
937
1016
  /** Reject a promise if it does not settle within the budget. */
938
1017
  function withTimeout(promise, ms, message) {
@@ -951,6 +1030,9 @@ export function defaultHostMainPath() {
951
1030
  * steps; `resolveElectronPath` is the full resolution order.
952
1031
  */
953
1032
  export const internals = {
1033
+ electronDistCandidates,
1034
+ electronDistExe,
1035
+ electronExeBeside,
954
1036
  isBareElectron,
955
1037
  resolveElectronPath,
956
1038
  resolveElectronPathImpl,
@@ -1042,7 +1042,7 @@ export function apply(ctx, config = {}) {
1042
1042
  description: 'Capture the current shared-browser page as a screenshot (PNG default, JPEG optional). Use for visual confirmation of layout, charts, designs, or CAPTCHAs, or to feed a vision tool (read_image) that locates elements visually. Supports full-page capture, save-to-file, JPEG encoding, and downscaling (maxWidth/maxHeight) to cut vision-tool token cost. JPEG is only available on the self-hosted native path; the desktop-shell path returns PNG.',
1043
1043
  parameters: {
1044
1044
  fullPage: { type: 'boolean', description: 'Capture the full scrollable page instead of the viewport (default false).' },
1045
- savePath: { type: 'string', description: 'Absolute file path to also save the image to (e.g. for read_image vision location).' },
1045
+ savePath: { type: 'string', description: 'Absolute file path to also save the image to (e.g. for read_image vision location). Must resolve inside the configured downloadDir (default: the system Downloads folder, localized names such as ~/下载 included); an existing file is never overwritten.' },
1046
1046
  format: { type: 'string', enum: ['png', 'jpeg'], description: 'Image format (default png; jpeg is self-hosted native path only).' },
1047
1047
  quality: { type: 'number', description: 'JPEG quality 1-100 (default 80); ignored for PNG.' },
1048
1048
  maxWidth: { type: 'number', description: 'Downscale to fit within this width (aspect preserved).' },
@@ -1312,7 +1312,7 @@ export function apply(ctx, config = {}) {
1312
1312
  description: 'Download a URL to a local file, keeping the browser session\'s cookies and login state. Use for fetching files behind authentication or from the current page context. Available on the self-hosted browser; the desktop shell delegates downloads to the real browser UI.',
1313
1313
  parameters: {
1314
1314
  url: { type: 'string', required: true, description: 'The URL to download.' },
1315
- savePath: { type: 'string', required: true, description: 'Absolute path of the file to write.' },
1315
+ savePath: { type: 'string', required: true, description: 'Absolute path of the file to write. Must resolve inside the configured downloadDir (default: the system Downloads folder, localized names such as ~/下载 included); an existing file is never overwritten.' },
1316
1316
  },
1317
1317
  output: {
1318
1318
  schema: { type: 'object', additionalProperties: false, properties: { path: { type: 'string', required: true } } },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-builtin-browser",
3
- "version": "0.1.21",
3
+ "version": "0.1.22",
4
4
  "description": "Shared real browser plugin for DeepSeek Harness: install-and-use — a visible native browser the human can take over, driven by the agent over CDP. DOM-level interaction (React/Vue safe), per-task session isolation, cookie persistence (browser_auth), CAPTCHA detection, batch form fill (browser_fill), authenticated download, history/replay, action restriction",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -34,6 +34,7 @@ import { dirname, join } from 'node:path'
34
34
  * The spawn token the parent sent via stdin (first line). Must be echoed in
35
35
  * our hello. Read from stdin so the token never appears in argv (WMI /
36
36
  * Process Explorer / /proc/*).
37
+ */
37
38
 
38
39
  // Isolate this host's profile from the DSH app's default Electron userData:
39
40
  // several Electron instances sharing Roaming\Electron fight over the GPU
@@ -85,6 +86,14 @@ const TOOLBAR_HEIGHT = 64
85
86
  /** One view: the Electron object plus its CDP-backed surface. */
86
87
  interface HostView {
87
88
  readonly webContentsView: WebContentsView
89
+ /**
90
+ * Set when the view's renderer process was replaced by a navigation. The
91
+ * fresh renderer has no display surface, and Chromium silently drops CDP
92
+ * synthesized input for a view without one — so the next show/input must
93
+ * force the visible-again dance even if this view is already the visible
94
+ * one. Cleared once the surface has been re-established.
95
+ */
96
+ needsRepresent: boolean
88
97
  }
89
98
 
90
99
  /** One window: a session's views + its toolbar. */
@@ -176,7 +185,17 @@ button.tool:hover { background: rgba(255,255,255,.12); }
176
185
  <div class="strip" id="strip"></div>
177
186
  <div id="err"></div>
178
187
  <script>
179
- const bridge = window.bridge
188
+ // The body runs inside an IIFE on purpose. The preload above exposes
189
+ // window.bridge via contextBridge.exposeInMainWorld(), which installs a
190
+ // NON-CONFIGURABLE global property. A top-level const/let named bridge here
191
+ // would hit HasRestrictedGlobalProperty and throw a SyntaxError at PARSE time
192
+ // — an early error, so the whole script is discarded: nothing gets bound, the
193
+ // address bar and nav buttons go silently dead, and the user sees no error.
194
+ // Function scope sidesteps that check (the tb name below stops the collision
195
+ // from returning by habit) and keeps these bindings off the page global.
196
+ // Keep this block free of backticks — it lives inside a TS template literal.
197
+ (() => {
198
+ const tb = window.bridge
180
199
  const addr = document.getElementById('addr')
181
200
  const strip = document.getElementById('strip')
182
201
  const errBox = document.getElementById('err')
@@ -187,7 +206,7 @@ function showErr(text) {
187
206
  clearTimeout(showErrT)
188
207
  showErrT = setTimeout(() => errBox.classList.remove('show'), 5000)
189
208
  }
190
- function post(action, payload) { bridge.post(action, payload || {}) }
209
+ function post(action, payload) { tb.post(action, payload || {}) }
191
210
  document.getElementById('back').onclick = () => post('back')
192
211
  document.getElementById('fwd').onclick = () => post('forward')
193
212
  document.getElementById('reload').onclick = () => post('reload')
@@ -202,7 +221,7 @@ addr.addEventListener('keydown', e => {
202
221
  if (v !== '') { post('navigate', { url: v }); addr.blur() }
203
222
  }
204
223
  })
205
- bridge.onTabs(payload => {
224
+ tb.onTabs(payload => {
206
225
  const tabs = (payload && payload.tabs) || []
207
226
  strip.textContent = ''
208
227
  for (const t of tabs) {
@@ -224,7 +243,8 @@ bridge.onTabs(payload => {
224
243
  const act = tabs.find(t => t.active)
225
244
  if (act && document.activeElement !== addr) addr.value = act.url || ''
226
245
  })
227
- bridge.onError(text => showErr(text))
246
+ tb.onError(text => showErr(text))
247
+ })()
228
248
  </script>
229
249
  </body>
230
250
  </html>
@@ -243,6 +263,48 @@ function ensureToolbarFiles(): void {
243
263
  writeFileSync(join(TOOLBAR_DIR, 'toolbar.html'), TOOLBAR_HTML)
244
264
  }
245
265
 
266
+ /**
267
+ * Bound an await with a timeout. Used so a wedged renderer surfaces as an
268
+ * explicit error instead of a command that never settles.
269
+ */
270
+ function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
271
+ return new Promise<T>((resolve, reject) => {
272
+ const timer = setTimeout(() => {
273
+ reject(new Error(`${label} timed out after ${ms}ms`))
274
+ }, ms)
275
+ promise.then(
276
+ (value) => { clearTimeout(timer); resolve(value) },
277
+ (error) => { clearTimeout(timer); reject(error) },
278
+ )
279
+ })
280
+ }
281
+
282
+ /**
283
+ * CDP commands are bounded here as well as in the parent: a view whose
284
+ * renderer stopped responding must produce a clear error, not a hang that
285
+ * only the tool layer's 10–30s timeout eventually cuts off.
286
+ */
287
+ const COMMAND_TIMEOUT_MS = 20000
288
+
289
+ /**
290
+ * A brand-new WebContentsView has no renderer process until something loads,
291
+ * and CDP commands (Runtime.evaluate …) sent to a renderer-less view never
292
+ * settle. That is what made a host restart unrecoverable: the provider
293
+ * rebuilds the session with a fresh blank view whose very first action is a
294
+ * location.href read, which hung until the tool timeout.
295
+ *
296
+ * Loading about:blank spawns the renderer, so the view answers the moment it
297
+ * exists; a real navigation replaces it right after. Bounded + best-effort —
298
+ * the caller still gets its reply if this fails.
299
+ */
300
+ const RENDERER_PRIME_TIMEOUT_MS = 3000
301
+
302
+ async function primeRenderer(view: WebContentsView): Promise<void> {
303
+ try {
304
+ await withTimeout(view.webContents.loadURL('about:blank'), RENDERER_PRIME_TIMEOUT_MS, 'renderer prime')
305
+ } catch { /* best effort: the first real navigation creates the renderer */ }
306
+ }
307
+
246
308
  /** Reply to the parent over the RPC socket. */
247
309
  function reply(id: number, payload: Record<string, unknown>): void {
248
310
  if (rpcSocket === undefined) {
@@ -449,7 +511,16 @@ function updateWindowTitle(win: HostWindow, viewId: string | undefined, label: s
449
511
  function showViewInWindow(win: HostWindow, viewId: string, label?: string): void {
450
512
  const entry = win.views.get(viewId)
451
513
  if (entry === undefined) return
452
- if (win.visibleViewId !== viewId) {
514
+ // A navigation replaced this view's renderer, and the new one has no
515
+ // display surface yet. Chromium drops CDP synthesized input (Input.*) for a
516
+ // view without a surface, which silently swallowed the FIRST click/keystroke
517
+ // after every navigation — and because the view was already the visible one,
518
+ // the dance below used to be skipped entirely. Force it once.
519
+ const mustRepresent = entry.needsRepresent
520
+ if (mustRepresent) {
521
+ try { entry.webContentsView.setVisible(false) } catch { /* destroyed */ }
522
+ }
523
+ if (win.visibleViewId !== viewId || mustRepresent) {
453
524
  // Hide every other view in THIS window, then show and RAISE the target
454
525
  // (topmost child wins). When the target is already visible, skip the
455
526
  // remove/re-add dance — doing it on every operation flickered.
@@ -463,6 +534,7 @@ function showViewInWindow(win: HostWindow, viewId: string, label?: string): void
463
534
  win.window.contentView.addChildView(entry.webContentsView)
464
535
  } catch { /* window closing */ }
465
536
  win.visibleViewId = viewId
537
+ entry.needsRepresent = false
466
538
  syncToolbar(win)
467
539
  }
468
540
  // Always refresh the title: it reflects the CURRENT page of the visible
@@ -549,11 +621,18 @@ async function handle(op: string, msg: { id: number; viewId?: string; windowId?:
549
621
  // New views start hidden: only the shown one may be visible.
550
622
  view.setVisible(false)
551
623
  win.window.contentView.addChildView(view)
552
- win.views.set(viewId, { webContentsView: view })
624
+ win.views.set(viewId, { webContentsView: view, needsRepresent: false })
553
625
  // Keep the window title and the toolbar tab strip live as the page
554
626
  // changes (navigations, title updates).
555
627
  view.webContents.on('page-title-updated', () => { updateWindowTitle(win, viewId, undefined); syncToolbar(win) })
556
- view.webContents.on('did-navigate', () => { updateWindowTitle(win, viewId, undefined); syncToolbar(win) })
628
+ view.webContents.on('did-navigate', () => {
629
+ // The renderer process was just replaced: mark the view so the next
630
+ // show forces a fresh display surface (see showViewInWindow).
631
+ const current = win.views.get(viewId)
632
+ if (current !== undefined) current.needsRepresent = true
633
+ updateWindowTitle(win, viewId, undefined)
634
+ syncToolbar(win)
635
+ })
557
636
  view.webContents.on('did-navigate-in-page', () => syncToolbar(win))
558
637
  wireFocusRouting(win, view, viewId)
559
638
  const first = win.views.size === 1
@@ -564,6 +643,10 @@ async function handle(op: string, msg: { id: number; viewId?: string; windowId?:
564
643
  layoutWindow(win)
565
644
  if (first) updateWindowTitle(win, viewId, undefined)
566
645
  syncToolbar(win)
646
+ // Prime the renderer BEFORE replying: the parent's next command
647
+ // (a location.href read) would otherwise hang on a view that has no
648
+ // renderer yet — the root of "host restarted and never recovers".
649
+ await primeRenderer(view)
567
650
  reply(msg.id, { ok: true })
568
651
  return
569
652
  }
@@ -623,7 +706,11 @@ async function handle(op: string, msg: { id: number; viewId?: string; windowId?:
623
706
  if (win === undefined || entry === undefined) throw new Error(`command: unknown view ${viewId}`)
624
707
  const method = msg.method
625
708
  if (typeof method !== 'string') throw new Error('command missing method')
626
- const result = await entry.webContentsView.webContents.debugger.sendCommand(method, msg.params ?? {})
709
+ const result = await withTimeout(
710
+ entry.webContentsView.webContents.debugger.sendCommand(method, msg.params ?? {}),
711
+ COMMAND_TIMEOUT_MS,
712
+ `CDP ${method}`,
713
+ )
627
714
  reply(msg.id, { ok: true, result })
628
715
  return
629
716
  }
@@ -894,6 +981,13 @@ void app.whenReady().then(() => {
894
981
  }
895
982
  }
896
983
  const rl = createInterface({ input: socket })
984
+ // Handle messages STRICTLY in order. Ops depend on each other completing:
985
+ // a `command` for a view must not overtake the `createView` that makes it,
986
+ // and the parent's present-barrier (`showView` then `ping`) is only
987
+ // meaningful if the child cannot answer the ping mid-show. The previous
988
+ // fire-and-forget loop let all of them race, which is how a fresh tab's
989
+ // first click could be dispatched before its view was ever on screen.
990
+ let queue: Promise<void> = Promise.resolve()
897
991
  rl.on('line', line => {
898
992
  const text = line.trim()
899
993
  if (text === '') return
@@ -904,7 +998,10 @@ void app.whenReady().then(() => {
904
998
  return // non-protocol noise
905
999
  }
906
1000
  if (typeof msg.id !== 'number' || typeof msg.op !== 'string') return
907
- void handle(msg.op, msg).catch(() => { /* reply already sent inside handle */ })
1001
+ // Bind the narrowed op now: the check above does not survive into the
1002
+ // deferred callback (msg is a mutable binding).
1003
+ const op = msg.op
1004
+ queue = queue.then(() => handle(op, msg)).catch(() => { /* reply already sent inside handle */ })
908
1005
  })
909
1006
  socket.on('error', error => {
910
1007
  process.stderr.write(`[dsh-browser host] socket error: ${String(error)}\n`)