dsh-builtin-browser 0.1.4 → 0.1.6

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.
@@ -64,6 +64,8 @@ export interface BrowserExecuteRequest {
64
64
  readonly script: string;
65
65
  /** Optional arguments injected into the script scope as `arguments[0..n]`. */
66
66
  readonly args?: readonly unknown[];
67
+ /** Evaluation budget in ms; a wedged page rejects with a timeout. Default 30000. */
68
+ readonly timeoutMs?: number;
67
69
  }
68
70
  /**
69
71
  * Result of an execute. Mirrors CDP `Runtime.evaluate` semantics: either a
@@ -149,7 +149,13 @@ export class ElectronBrowserProvider {
149
149
  }
150
150
  }
151
151
  signal?.throwIfAborted();
152
- await handle.sendCommand(CDP_PAGE_NAVIGATE, { url });
152
+ const result = await handle.sendCommand(CDP_PAGE_NAVIGATE, { url });
153
+ // Page.navigate resolves even when the navigation fails; surface the
154
+ // failure instead of leaving a silent white screen.
155
+ const errorText = result.errorText;
156
+ if (typeof errorText === 'string' && errorText !== '') {
157
+ throw new BrowserError(`browser: navigation to "${url}" failed: ${errorText}`, 'BROWSER_NAVIGATION_FAILED');
158
+ }
153
159
  }
154
160
  /** Execute JS in the active tab's page context. */
155
161
  async execute(session, request, signal) {
@@ -171,11 +177,16 @@ export class ElectronBrowserProvider {
171
177
  const expression = hasArgs
172
178
  ? `(function(){ const __dshArgs = ${JSON.stringify(request.args)}; return Function(${JSON.stringify(body)}).apply(null, __dshArgs) })()`
173
179
  : `(function(){ return Function(${JSON.stringify(body)})() })()`;
174
- const result = await handle.sendCommand(CDP_RUNTIME_EVALUATE, {
180
+ // CDP Runtime.evaluate can hang indefinitely on a not-yet-loaded page
181
+ // (navigate returned but the renderer has not committed). Bound it so a
182
+ // stuck call surfaces as BROWSER_EXECUTE_TIMEOUT instead of wedging the
183
+ // whole tool call. The caller's signal wins when it fires first.
184
+ const timeoutMs = request.timeoutMs ?? 30_000;
185
+ const result = await withTimeout(handle.sendCommand(CDP_RUNTIME_EVALUATE, {
175
186
  expression,
176
187
  returnByValue: true,
177
188
  awaitPromise: true,
178
- });
189
+ }), timeoutMs, signal, `browser: execute timed out after ${timeoutMs}ms`);
179
190
  if (result.exceptionDetails !== undefined) {
180
191
  const detail = result.exceptionDetails;
181
192
  return { ok: false, exception: detail.exception?.description ?? detail.text ?? 'unknown exception' };
@@ -220,7 +231,10 @@ export class ElectronBrowserProvider {
220
231
  }
221
232
  return { url, title, elements: out, truncated: out.length >= cap }
222
233
  })()`;
223
- const result = await handleSendEvaluate(tab.handle, script);
234
+ // Same hang guard as execute: a renderer that has not committed after
235
+ // navigate would otherwise block snapshot forever.
236
+ const timeoutMs = 30_000;
237
+ const result = await withTimeout(handleSendEvaluate(tab.handle, script), timeoutMs, signal, `browser: snapshot timed out after ${timeoutMs}ms`);
224
238
  if (!result.ok)
225
239
  throw new BrowserError(`browser: snapshot evaluation failed: ${result.exception}`, 'BROWSER_SNAPSHOT_FAILED');
226
240
  const value = result.value;
@@ -351,6 +365,46 @@ export class ElectronBrowserProvider {
351
365
  return result.ok && typeof result.value === 'string' ? result.value : '';
352
366
  }
353
367
  }
368
+ /**
369
+ * Bound a promise so a wedged CDP call surfaces as an error instead of
370
+ * hanging the tool call forever. The caller's signal, when provided, wins
371
+ * over the timeout if it fires first.
372
+ * @param promise - the operation to bound.
373
+ * @param ms - the timeout budget.
374
+ * @param signal - optional caller signal.
375
+ * @param message - the timeout error message.
376
+ * @returns the promise's value, or a rejected promise on timeout/abort.
377
+ */
378
+ function withTimeout(promise, ms, signal, message) {
379
+ return new Promise((resolve, reject) => {
380
+ let done = false;
381
+ const timer = setTimeout(() => {
382
+ if (done)
383
+ return;
384
+ done = true;
385
+ reject(new Error(message));
386
+ }, ms);
387
+ const finish = (fn) => {
388
+ if (done)
389
+ return;
390
+ done = true;
391
+ clearTimeout(timer);
392
+ if (signal !== undefined)
393
+ signal.removeEventListener('abort', onAbort);
394
+ fn();
395
+ };
396
+ const onAbort = () => {
397
+ if (done)
398
+ return;
399
+ done = true;
400
+ clearTimeout(timer);
401
+ reject(signal?.reason instanceof Error ? signal.reason : new Error('aborted'));
402
+ };
403
+ if (signal !== undefined)
404
+ signal.addEventListener('abort', onAbort, { once: true });
405
+ promise.then(value => finish(() => resolve(value)), error => finish(() => reject(error)));
406
+ });
407
+ }
354
408
  /**
355
409
  * Run a `Runtime.evaluate` through a view handle and normalize the result.
356
410
  * Shared by execute, snapshot, content, and internal URL reads.
@@ -358,12 +358,17 @@ export class RemoteElectronViewHost {
358
358
  class DeferredRemoteView {
359
359
  id;
360
360
  materialize;
361
+ materialized;
361
362
  constructor(id, materialize) {
362
363
  this.id = id;
363
364
  this.materialize = materialize;
364
365
  }
365
366
  async sendCommand(method, params) {
366
- const view = await this.materialize();
367
+ // Materialize once and cache: every sendCommand on the same handle must
368
+ // target the SAME child view. Re-materializing would re-run createView
369
+ // (duplicate window) and drop the established view.
370
+ this.materialized ??= this.materialize();
371
+ const view = await this.materialized;
367
372
  return view.sendCommand(method, params);
368
373
  }
369
374
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-builtin-browser",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Shared real browser capability for DeepSeek Harness: a visible browser the human watches and can take over, driven by the agent over CDP (snapshot/execute/content/tab management); host-side seam, provider, and browser_* tools",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",