dsh-builtin-browser 0.1.8 → 0.1.10

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.
@@ -2,9 +2,9 @@
2
2
  * Self-hosted Electron browser host (parent side): an
3
3
  * {@link ElectronBrowserViewHost} implementation that spawns the plugin's own
4
4
  * Electron child process (host-main.js) and drives it over line-delimited
5
- * JSON-RPC on stdio. This is what makes the plugin work on surfaces without a
6
- * desktop shell's electronViewHost (plain dsh web): installing the plugin is
7
- * enough — the browser window appears on first use.
5
+ * JSON-RPC on a loopback TCP socket. This is what makes the plugin work on
6
+ * surfaces without a desktop shell's electronViewHost (plain dsh web):
7
+ * installing the plugin is enough — the browser window appears on first use.
8
8
  *
9
9
  * Protocol (one JSON object per line, both directions):
10
10
  * -> { id, op: 'createView' } | { id, op: 'destroyView', viewId } |
@@ -23,6 +23,8 @@ import { createServer } from 'node:net';
23
23
  import { fileURLToPath } from 'node:url';
24
24
  /** How long to wait for the child to signal readiness before failing. */
25
25
  const READY_TIMEOUT_MS = 20_000;
26
+ /** Safety cap on a single RPC reply line (base64 downloads are the big ones). */
27
+ const MAX_RPC_BUFFER_BYTES = 512 * 1024 * 1024;
26
28
  /**
27
29
  * Locate the Electron binary, trying, in order:
28
30
  * 1. require('electron') from this module (optional peer dependency),
@@ -147,6 +149,7 @@ function findElectronExe(store) {
147
149
  class ElectronChildClient {
148
150
  hostMainPath;
149
151
  port;
152
+ onExit;
150
153
  child;
151
154
  pending = new Map();
152
155
  nextId = 1;
@@ -154,9 +157,12 @@ class ElectronChildClient {
154
157
  socket;
155
158
  connected = false;
156
159
  outbox = [];
157
- constructor(hostMainPath, port) {
160
+ /** Set once the child has exited; further calls fail fast instead of queueing. */
161
+ dead = false;
162
+ constructor(hostMainPath, port, onExit) {
158
163
  this.hostMainPath = hostMainPath;
159
164
  this.port = port;
165
+ this.onExit = onExit;
160
166
  const electron = resolveElectronPath();
161
167
  // ELECTRON_RUN_AS_NODE (even an empty string) makes Electron run as plain
162
168
  // Node, breaking require('electron'); NODE_OPTIONS can inject flags that
@@ -174,25 +180,45 @@ class ElectronChildClient {
174
180
  // Diagnostics only; never parse stderr as protocol.
175
181
  process.stderr.write(`[dsh-browser host] ${String(chunk)}`);
176
182
  });
183
+ // A failed spawn (bad/corrupt binary) emits 'error' — without a listener
184
+ // that would crash the whole DSH process.
185
+ this.child.on('error', error => {
186
+ process.stderr.write(`[dsh-browser host] spawn error: ${String(error)}\n`);
187
+ this.fail(new Error(`dsh-builtin-browser: browser host failed to start: ${String(error)}`));
188
+ });
177
189
  this.child.on('exit', (code, signal) => {
178
- const err = new Error(`dsh-builtin-browser: browser host exited (code=${String(code)} signal=${String(signal)})`);
179
- for (const pending of this.pending.values())
180
- pending.reject(err);
181
- this.pending.clear();
190
+ this.fail(new Error(`dsh-builtin-browser: browser host exited (code=${String(code)} signal=${String(signal)})`));
182
191
  });
183
192
  }
193
+ /** Reject everything in flight, mark the client dead, and notify the host. */
194
+ fail(err) {
195
+ if (this.dead)
196
+ return;
197
+ this.dead = true;
198
+ this.connected = false;
199
+ for (const pending of this.pending.values())
200
+ pending.reject(err);
201
+ this.pending.clear();
202
+ this.outbox = [];
203
+ this.onExit?.();
204
+ }
184
205
  /** Accept the child's connection (called by the server). */
185
206
  attach(socket) {
186
207
  this.socket = socket;
187
208
  this.connected = true;
188
209
  socket.setEncoding('utf8');
210
+ // Without an 'error' listener a remote reset (ECONNRESET/EPIPE) throws an
211
+ // uncaught 'error' event and crashes the whole DSH process; 'close' below
212
+ // does the cleanup.
213
+ socket.on('error', error => {
214
+ process.stderr.write(`[dsh-browser host] socket error: ${String(error)}\n`);
215
+ });
189
216
  socket.on('data', chunk => this.onData(chunk));
190
217
  socket.on('close', () => {
191
218
  this.connected = false;
192
- const err = new Error('dsh-builtin-browser: browser host connection closed');
193
- for (const pending of this.pending.values())
194
- pending.reject(err);
195
- this.pending.clear();
219
+ if (!this.dead) {
220
+ this.fail(new Error('dsh-builtin-browser: browser host connection closed'));
221
+ }
196
222
  });
197
223
  // Flush anything queued while disconnected.
198
224
  if (this.outbox.length > 0) {
@@ -203,6 +229,14 @@ class ElectronChildClient {
203
229
  }
204
230
  onData(chunk) {
205
231
  this.buffer += typeof chunk === 'string' ? chunk : chunk.toString('utf8');
232
+ // Safety net: a pathological child (or a reply larger than expected)
233
+ // must not grow the parent's memory without bound. The child caps
234
+ // downloads at 256 MiB, so a healthy stream never approaches this.
235
+ if (this.buffer.length > MAX_RPC_BUFFER_BYTES) {
236
+ this.buffer = '';
237
+ this.fail(new Error(`dsh-builtin-browser: RPC reply exceeded ${MAX_RPC_BUFFER_BYTES} bytes`));
238
+ return;
239
+ }
206
240
  let nl;
207
241
  while ((nl = this.buffer.indexOf('\n')) >= 0) {
208
242
  const line = this.buffer.slice(0, nl).trim();
@@ -231,6 +265,9 @@ class ElectronChildClient {
231
265
  }
232
266
  /** Send one command and await the reply. */
233
267
  call(op, payload = {}) {
268
+ if (this.dead) {
269
+ return Promise.reject(new Error('dsh-builtin-browser: browser host is not running'));
270
+ }
234
271
  const id = this.nextId++;
235
272
  const line = JSON.stringify({ id, op, ...payload });
236
273
  return new Promise((resolve, reject) => {
@@ -294,15 +331,30 @@ export class RemoteElectronViewHost {
294
331
  pendingSocket;
295
332
  views = new Map();
296
333
  readyPromise;
334
+ disposed = false;
297
335
  constructor(hostMainPath) {
298
336
  this.hostMainPath = hostMainPath;
299
337
  }
300
- /** Ensure the child is up and ready (lazy on first use). */
338
+ /** Ensure the child is up and ready (lazy on first use; restarts after a crash). */
301
339
  ready() {
302
340
  if (this.readyPromise !== undefined)
303
341
  return this.readyPromise;
304
- this.readyPromise = this.start();
305
- return this.readyPromise;
342
+ const started = this.start();
343
+ const wrapped = started.catch(error => {
344
+ // A failed startup must not poison the host forever: tear down whatever
345
+ // was half-created and let the next call retry from scratch.
346
+ if (this.readyPromise === wrapped) {
347
+ this.readyPromise = undefined;
348
+ this.client?.kill();
349
+ this.client = undefined;
350
+ this.server?.close();
351
+ this.server = undefined;
352
+ this.pendingSocket = undefined;
353
+ }
354
+ throw error;
355
+ });
356
+ this.readyPromise = wrapped;
357
+ return wrapped;
306
358
  }
307
359
  async start() {
308
360
  // Listen on an ephemeral loopback port; the child connects back.
@@ -316,10 +368,15 @@ export class RemoteElectronViewHost {
316
368
  server.once('error', reject);
317
369
  server.listen(0, '127.0.0.1', () => resolve());
318
370
  });
371
+ // A later server error (rare on a loopback ephemeral port) must not crash
372
+ // the process; the client's fail path handles the actual recovery.
373
+ server.on('error', error => {
374
+ process.stderr.write(`[dsh-browser host] rpc server error: ${String(error)}\n`);
375
+ });
319
376
  const address = server.address();
320
377
  const port = typeof address === 'object' && address !== null ? address.port : 0;
321
378
  this.server = server;
322
- this.client = new ElectronChildClient(this.hostMainPath, port);
379
+ this.client = new ElectronChildClient(this.hostMainPath, port, () => this.onChildExit());
323
380
  if (this.pendingSocket !== undefined) {
324
381
  this.client.attach(this.pendingSocket);
325
382
  this.pendingSocket = undefined;
@@ -327,6 +384,18 @@ export class RemoteElectronViewHost {
327
384
  // Wait for the child's connection + readiness ping.
328
385
  await withTimeout(this.client.call('ping'), READY_TIMEOUT_MS, 'browser host did not become ready');
329
386
  }
387
+ /** The child died: tear down so the next use starts a fresh child. */
388
+ onChildExit() {
389
+ if (this.disposed)
390
+ return;
391
+ this.client = undefined;
392
+ this.server?.close();
393
+ this.server = undefined;
394
+ this.pendingSocket = undefined;
395
+ this.readyPromise = undefined;
396
+ // Keep the views map: handles still resolve to ids; a fresh child simply
397
+ // has no such views yet, and reset_session reopens clean sessions.
398
+ }
330
399
  createView() {
331
400
  // The seam is synchronous; the provider uses the handle immediately, so
332
401
  // commands are deferred until the child is up and the view materialized.
@@ -341,12 +410,21 @@ export class RemoteElectronViewHost {
341
410
  if (client === undefined)
342
411
  throw new Error('browser host unavailable');
343
412
  await client.call('createView', { viewId: id });
413
+ // If the view was destroyed while the createView RPC was in flight, do
414
+ // not re-insert a stale entry that would resurrect a dead child view.
415
+ if (this.views.get(id) === undefined) {
416
+ throw new Error('browser: view destroyed while starting');
417
+ }
344
418
  const view = new RemoteView(id, client);
345
419
  this.views.set(id, view);
346
420
  return view;
347
421
  }
348
422
  showView(handle) {
349
- void this.ready().then(() => this.client?.call('showView', { viewId: handle.id }));
423
+ // Fire-and-forget by design (visibility is best-effort), but a rejected
424
+ // promise must not become an unhandled rejection (crash on Node >= 15).
425
+ void this.ready()
426
+ .then(() => this.client?.call('showView', { viewId: handle.id }))
427
+ .catch(() => { });
350
428
  }
351
429
  destroyView(handle) {
352
430
  const view = this.views.get(handle.id);
@@ -359,6 +437,7 @@ export class RemoteElectronViewHost {
359
437
  }
360
438
  /** Shut the child and the RPC server down. */
361
439
  dispose() {
440
+ this.disposed = true;
362
441
  this.client?.kill();
363
442
  this.client = undefined;
364
443
  this.server?.close();
@@ -376,27 +455,37 @@ class DeferredRemoteView {
376
455
  this.id = id;
377
456
  this.materialize = materialize;
378
457
  }
458
+ /**
459
+ * Materialize once and cache: every sendCommand on the same handle must
460
+ * target the SAME child view (re-materializing would re-run createView and
461
+ * duplicate the window). A FAILED materialization is reset so a later call
462
+ * (e.g. after the host restarted) can retry instead of being poisoned.
463
+ */
464
+ materializeOnce() {
465
+ if (this.materialized === undefined) {
466
+ const pending = this.materialize();
467
+ this.materialized = pending.catch(error => {
468
+ if (this.materialized === pending)
469
+ this.materialized = undefined;
470
+ throw error;
471
+ });
472
+ }
473
+ return this.materialized;
474
+ }
379
475
  async sendCommand(method, params) {
380
- // Materialize once and cache: every sendCommand on the same handle must
381
- // target the SAME child view. Re-materializing would re-run createView
382
- // (duplicate window) and drop the established view.
383
- this.materialized ??= this.materialize();
384
- const view = await this.materialized;
476
+ const view = await this.materializeOnce();
385
477
  return view.sendCommand(method, params);
386
478
  }
387
479
  async download(url, savePath) {
388
- this.materialized ??= this.materialize();
389
- const view = await this.materialized;
480
+ const view = await this.materializeOnce();
390
481
  return view.download(url, savePath);
391
482
  }
392
483
  async flushAuth() {
393
- this.materialized ??= this.materialize();
394
- const view = await this.materialized;
484
+ const view = await this.materializeOnce();
395
485
  return view.flushAuth();
396
486
  }
397
487
  async restoreAuth(cookies) {
398
- this.materialized ??= this.materialize();
399
- const view = await this.materialized;
488
+ const view = await this.materializeOnce();
400
489
  return view.restoreAuth(cookies);
401
490
  }
402
491
  }
package/lib/index.d.ts CHANGED
@@ -1,15 +1,16 @@
1
1
  /**
2
- * dsh-browser plugin entry: aggregates the shared-browser capability pieces.
3
- * The cordis.patch.yml rows reference subpath exports:
4
- * - `dsh-browser/browser` -> the ctx.browser seam (Service Definition)
5
- * - `dsh-browser/browser-electron` -> the Electron CDP provider
6
- * - `dsh-browser/tool-browser` -> the model-facing browser_* tools
2
+ * dsh-builtin-browser plugin entry: aggregates the shared-browser capability
3
+ * pieces. The cordis.patch.yml rows reference subpath exports:
4
+ * - `dsh-builtin-browser/browser` -> the ctx.browser seam (Service)
5
+ * - `dsh-builtin-browser/browser-electron` -> the Electron CDP provider
6
+ * - `dsh-builtin-browser/tool-browser` -> the model-facing browser_* tools
7
7
  * This root entry only re-exports for programmatic use; the loader rows are
8
8
  * the composition surface.
9
- * @module dsh-browser
9
+ * @module dsh-builtin-browser
10
10
  */
11
11
  export { BrowserError } from './browser/types.ts';
12
- export type { BrowserClickRequest, BrowserContentFormat, BrowserContentRequest, BrowserContentResult, BrowserExecuteRequest, BrowserExecuteResult, BrowserNavigateRequest, BrowserOpenRequest, BrowserProvider, BrowserScreenshotRequest, BrowserScreenshotResult, BrowserSessionId, BrowserSnapshotElement, BrowserSnapshotResult, BrowserTab, BrowserTypeRequest, } from './browser/types.ts';
12
+ export type { BrowserChallenge, BrowserClickRequest, BrowserContentFormat, BrowserContentRequest, BrowserContentResult, BrowserExecuteRequest, BrowserExecuteResult, BrowserFillField, BrowserFillRequest, BrowserFillResult, BrowserNavigateRequest, BrowserOpenRequest, BrowserProvider, BrowserScreenshotRequest, BrowserScreenshotResult, BrowserSessionId, BrowserSnapshotElement, BrowserSnapshotResult, BrowserTab, BrowserTypeRequest, ExportedCookie, } from './browser/types.ts';
13
13
  export { BrowserRuntime } from './browser/runtime.ts';
14
14
  export { ElectronBrowserProvider } from './browser-electron/provider.ts';
15
15
  export type { ElectronBrowserViewHost, ElectronViewHandle } from './browser-electron/provider.ts';
16
+ export { RemoteElectronViewHost, defaultHostMainPath } from './browser-electron/remote-host.ts';
package/lib/index.js CHANGED
@@ -1,13 +1,14 @@
1
1
  /**
2
- * dsh-browser plugin entry: aggregates the shared-browser capability pieces.
3
- * The cordis.patch.yml rows reference subpath exports:
4
- * - `dsh-browser/browser` -> the ctx.browser seam (Service Definition)
5
- * - `dsh-browser/browser-electron` -> the Electron CDP provider
6
- * - `dsh-browser/tool-browser` -> the model-facing browser_* tools
2
+ * dsh-builtin-browser plugin entry: aggregates the shared-browser capability
3
+ * pieces. The cordis.patch.yml rows reference subpath exports:
4
+ * - `dsh-builtin-browser/browser` -> the ctx.browser seam (Service)
5
+ * - `dsh-builtin-browser/browser-electron` -> the Electron CDP provider
6
+ * - `dsh-builtin-browser/tool-browser` -> the model-facing browser_* tools
7
7
  * This root entry only re-exports for programmatic use; the loader rows are
8
8
  * the composition surface.
9
- * @module dsh-browser
9
+ * @module dsh-builtin-browser
10
10
  */
11
11
  export { BrowserError } from "./browser/types.js";
12
12
  export { BrowserRuntime } from "./browser/runtime.js";
13
13
  export { ElectronBrowserProvider } from "./browser-electron/provider.js";
14
+ export { RemoteElectronViewHost, defaultHostMainPath } from "./browser-electron/remote-host.js";
@@ -32,7 +32,7 @@ export interface Config {
32
32
  export declare function apply(ctx: Context, config?: Config): void;
33
33
  /** Test hook: inspect and reset the plugin-level session map (used by tests). */
34
34
  export declare const internals: {
35
- /** The per-task session map (task key -> provider session id). */
35
+ /** A copy of the per-task session map (task key -> provider session id). */
36
36
  readonly sessions: ReadonlyMap<string, BrowserSessionId>;
37
37
  /** Drop one task's mapping without closing the provider session. */
38
38
  clearSession(key?: string): void;
@@ -20,6 +20,8 @@ export const name = 'tool-browser';
20
20
  export const inject = ['tools', 'browser', 'systemPrompt'];
21
21
  /** Per-task browser sessions, keyed by the calling DSH session id. */
22
22
  const sessionsByTask = new Map();
23
+ /** In-flight first-open per task key, so concurrent first calls share one session. */
24
+ const pendingOpens = new Map();
23
25
  /**
24
26
  * Action restriction state: an allow-list of browser tool names, or undefined
25
27
  * for unrestricted. When set, any browser_* tool not in the list is refused
@@ -48,25 +50,29 @@ function taskKey(exec) {
48
50
  }
49
51
  /**
50
52
  * Resolve the calling task's browser session, opening one on first use.
53
+ * Concurrent first calls for the same key share a single open.
51
54
  * @param browser - the seam service.
52
55
  * @param key - the task key (see {@link taskKey}).
53
56
  * @returns the task's session id.
54
57
  */
55
58
  async function ensureSession(browser, key) {
56
- let session = sessionsByTask.get(key);
57
- if (session === undefined) {
58
- session = await browser.open();
59
- sessionsByTask.set(key, session);
60
- }
61
- return session;
59
+ const existing = sessionsByTask.get(key);
60
+ if (existing !== undefined)
61
+ return existing;
62
+ const pending = pendingOpens.get(key);
63
+ if (pending !== undefined)
64
+ return pending;
65
+ const opening = browser.open().then(session => { sessionsByTask.set(key, session); pendingOpens.delete(key); return session; }, error => { pendingOpens.delete(key); throw error; });
66
+ pendingOpens.set(key, opening);
67
+ return opening;
62
68
  }
63
- /** Coerce a tool-provided string value back to boolean/number when it looks like one. */
69
+ /** Coerce a tool-provided string value back to boolean/number only when lossless. */
64
70
  function parseFillValue(v) {
65
71
  if (v === 'true')
66
72
  return true;
67
73
  if (v === 'false')
68
74
  return false;
69
- if (v !== undefined && /^-?\d+(\.\d+)?$/.test(v))
75
+ if (v !== undefined && /^-?\d+(\.\d+)?$/.test(v) && String(Number(v)) === v)
70
76
  return Number(v);
71
77
  return v ?? '';
72
78
  }
@@ -84,8 +90,8 @@ function formatSnapshot(snapshot) {
84
90
  /** Register all browser tools with `ctx.tools`. */
85
91
  export function apply(ctx, config = {}) {
86
92
  const timeoutMs = config.timeoutMs ?? 60_000;
87
- if (config.allowedActions !== undefined)
88
- restrictedTo = [...config.allowedActions];
93
+ // Re-apply resets the restriction: an omitted allowedActions lifts it.
94
+ restrictedTo = config.allowedActions !== undefined ? [...config.allowedActions] : undefined;
89
95
  ctx.systemPrompt.section({
90
96
  name: 'tool:browser',
91
97
  // Tool guidance band is 100-199; 150 keeps clear of the common 110/120
@@ -107,6 +113,7 @@ export function apply(ctx, config = {}) {
107
113
  properties: {
108
114
  url: { type: 'string', required: true },
109
115
  title: { type: 'string' },
116
+ truncated: { type: 'boolean' },
110
117
  elements: {
111
118
  type: 'array',
112
119
  required: true,
@@ -168,6 +175,7 @@ export function apply(ctx, config = {}) {
168
175
  properties: {
169
176
  url: { type: 'string', required: true },
170
177
  title: { type: 'string' },
178
+ truncated: { type: 'boolean' },
171
179
  elements: {
172
180
  type: 'array',
173
181
  required: true,
@@ -546,6 +554,7 @@ export function apply(ctx, config = {}) {
546
554
  timeoutMs,
547
555
  isConcurrencySafe: () => true,
548
556
  async execute(args, exec) {
557
+ assertAllowed('browser_switch_tab');
549
558
  const browser = ctx.get('browser');
550
559
  if (browser === undefined)
551
560
  throw new Error('tool-browser: browser service unavailable');
@@ -629,7 +638,11 @@ export function apply(ctx, config = {}) {
629
638
  return [{ type: 'text', text: '(no recorded operations yet)' }];
630
639
  return [{
631
640
  type: 'text',
632
- text: entries.map(e => `#${e.seq} ${e.action} ${e.ok ? 'ok' : 'FAIL'} ${JSON.stringify(e.params)}${e.result !== undefined ? ` -> ${e.result}` : ''}${e.error !== undefined ? ` !! ${e.error}` : ''}`).join('\n'),
641
+ text: entries.map(e => {
642
+ const rawParams = JSON.stringify(e.params);
643
+ const shownParams = rawParams.length > 300 ? rawParams.slice(0, 300) + '…' : rawParams;
644
+ return `#${e.seq} ${e.action} ${e.ok ? 'ok' : 'FAIL'} ${shownParams}${e.result !== undefined ? ` -> ${e.result}` : ''}${e.error !== undefined ? ` !! ${e.error}` : ''}`;
645
+ }).join('\n'),
633
646
  }];
634
647
  },
635
648
  },
@@ -738,9 +751,7 @@ export function apply(ctx, config = {}) {
738
751
  const browser = ctx.get('browser');
739
752
  if (browser === undefined)
740
753
  throw new Error('tool-browser: browser service unavailable');
741
- const key = taskKey(exec);
742
- const session = sessionsByTask.get(key) ?? await browser.open();
743
- sessionsByTask.set(key, session);
754
+ const session = await ensureSession(browser, taskKey(exec));
744
755
  const tabs = await browser.listTabs(session);
745
756
  return { session, tabs: tabs.map(t => ({ id: t.id, url: t.url, active: t.active })) };
746
757
  },
@@ -763,8 +774,14 @@ export function apply(ctx, config = {}) {
763
774
  const key = taskKey(exec);
764
775
  const session = sessionsByTask.get(key);
765
776
  if (session !== undefined) {
766
- await browser.close(session);
767
- sessionsByTask.delete(key);
777
+ try {
778
+ await browser.close(session);
779
+ }
780
+ finally {
781
+ // Always forget the mapping so the next call opens a fresh session,
782
+ // even if the provider close threw (the session is half-closed).
783
+ sessionsByTask.delete(key);
784
+ }
768
785
  }
769
786
  return { reset: true };
770
787
  },
@@ -787,8 +804,15 @@ export function apply(ctx, config = {}) {
787
804
  isConcurrencySafe: () => false,
788
805
  async execute(args, exec) {
789
806
  // Always allowed so the guard can be lifted.
790
- restrictedTo = (args.allowed ?? []).filter((t) => t.startsWith('browser_'));
791
- return { restrictedTo: [...restrictedTo] };
807
+ const allowed = args.allowed ?? [];
808
+ const unknown = allowed.filter((t) => !t.startsWith('browser_'));
809
+ if (unknown.length > 0) {
810
+ throw new Error(`browser_restrict: unknown tool name(s) ${unknown.map(t => `"${t}"`).join(', ')} (must start with "browser_")`);
811
+ }
812
+ // Empty list (or omitted) lifts the restriction; a non-empty list is the
813
+ // new allow-list.
814
+ restrictedTo = allowed.length === 0 ? undefined : [...allowed];
815
+ return { restrictedTo: restrictedTo === undefined ? [] : [...restrictedTo] };
792
816
  },
793
817
  }));
794
818
  ctx.tools.register(defineTool({
@@ -829,8 +853,8 @@ export function apply(ctx, config = {}) {
829
853
  }
830
854
  /** Test hook: inspect and reset the plugin-level session map (used by tests). */
831
855
  export const internals = {
832
- /** The per-task session map (task key -> provider session id). */
833
- get sessions() { return sessionsByTask; },
856
+ /** A copy of the per-task session map (task key -> provider session id). */
857
+ get sessions() { return new Map(sessionsByTask); },
834
858
  /** Drop one task's mapping without closing the provider session. */
835
859
  clearSession(key = 'default') { sessionsByTask.delete(key); },
836
860
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-builtin-browser",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
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",
@@ -250,9 +250,21 @@ export class BrowserRuntime extends Service {
250
250
  return this.resolveProvider().restoreAuth(session, cookies)
251
251
  }
252
252
 
253
- /** Close the session through the selected provider. Idempotent. */
253
+ /** Close the session through the selected provider. Idempotent; a missing
254
+ * provider is treated as already-closed so teardown paths stay no-ops. */
254
255
  async close(session: BrowserSessionId): Promise<void> {
255
- return this.resolveProvider().close(session)
256
+ try {
257
+ await this.resolveProvider().close(session)
258
+ } catch (error) {
259
+ const code = error instanceof BrowserError ? (error as { code?: string }).code : undefined
260
+ if (code === 'BROWSER_PROVIDER_UNAVAILABLE'
261
+ || code === 'BROWSER_PROVIDER_CONFIGURED_MISSING'
262
+ || code === 'BROWSER_PROVIDER_CONFIGURED_UNAVAILABLE'
263
+ || code === 'BROWSER_PROVIDER_AMBIGUOUS') {
264
+ return // provider gone; nothing to close
265
+ }
266
+ throw error
267
+ }
256
268
  }
257
269
  }
258
270
 
@@ -313,7 +313,8 @@ export interface BrowserProvider {
313
313
  export interface BrowserHistoryEntry {
314
314
  /** Monotonic sequence number within the session. */
315
315
  readonly seq: number
316
- /** The operation name: navigate | execute | click | type | replay. */
316
+ /** The operation name. Replayable: navigate | execute | click | type.
317
+ * Also recorded (non-replayable): fill | download | flushAuth | restoreAuth. */
317
318
  readonly action: string
318
319
  /** The operation's arguments. */
319
320
  readonly params: Record<string, unknown>
@@ -49,10 +49,15 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
49
49
  // External host (desktop shell) wins; otherwise self-host. The self-hosted
50
50
  // child is disposed with the fiber, mirroring the shell's lifetime.
51
51
  const host: ElectronBrowserViewHost = config.viewHost ?? new RemoteElectronViewHost(defaultHostMainPath())
52
+ // Own the disposer on THIS plugin's fiber: registerBrowserProvider's effect
53
+ // is bound to the seam's own fiber (the browser row), so a reload of this
54
+ // row would otherwise collide with the still-registered provider
55
+ // (BROWSER_DUPLICATE_PROVIDER) or leave a stale provider behind.
56
+ const unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(host, { httpOnly: config.httpOnly }))
52
57
  ctx.effect(() => () => {
58
+ unregister()
53
59
  if (config.viewHost === undefined && host instanceof RemoteElectronViewHost) {
54
60
  host.dispose()
55
61
  }
56
62
  })
57
- ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(host, { httpOnly: config.httpOnly }))
58
63
  }