dsh-builtin-browser 0.4.9 → 0.4.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.
@@ -9,6 +9,7 @@
9
9
  import { type VisitedPage } from './history-store.js';
10
10
  import type { BrowserSettings } from './settings-store.js';
11
11
  import type { BrowserA11yRequest, BrowserA11yResult, BrowserChallenge, BrowserCheckRequest, BrowserClearRequest, BrowserClickRequest, BrowserContentRequest, BrowserContentResult, BrowserElementTarget, BrowserExecuteRequest, BrowserExecuteResult, BrowserFillRequest, BrowserFillResult, BrowserGetValueRequest, BrowserGetValueResult, BrowserHistoryEntry, BrowserOpenRequest, BrowserProvider, BrowserScrapeRequest, BrowserScrapeResult, BrowserScreenshotRequest, BrowserSelectRequest, BrowserSelectResult, BrowserSessionId, BrowserSetValueRequest, BrowserSetValueResult, BrowserSnapshotResult, BrowserTab, BrowserTypeRequest, BrowserWaitRequest, BrowserWaitResult, BrowserScrollRequest, BrowserKeyRequest, ExportedCookie } from '../browser/types.js';
12
+ import type { CarrierPage } from '../browser/types.js';
12
13
  /** Stable provider id registered with `ctx.browser`. */
13
14
  export declare const ELECTRON_BROWSER_PROVIDER_ID = "electron";
14
15
  /**
@@ -58,6 +59,35 @@ export interface ElectronBrowserViewHost {
58
59
  * @param label - human-readable session/task label, as in {@link showView}.
59
60
  */
60
61
  presentView?(handle: ElectronViewHandle, label?: string): Promise<void>;
62
+ /**
63
+ * Optional: pages this carrier holds that the provider never created.
64
+ *
65
+ * A sidebar panel and an installed browser both own their own page creation. Clicking a link
66
+ * that opens a new page — or any `window.open` the page performs — produces a page the provider
67
+ * did not ask for and therefore never recorded. Those pages are real: they are what the human
68
+ * sees, they hold memory, and they persist until something closes them. Without this the tab
69
+ * model reports only what the provider itself made, so the extras are invisible to
70
+ * `dsh_browser_list_tabs` and unclosable through `dsh_browser_close_tab`, accumulating for the
71
+ * life of the browser.
72
+ *
73
+ * Views the host itself opened are NOT included. That exclusion belongs to the host, which is the
74
+ * only side that can make it: a provider tab is identified by the provider's own id and a carrier
75
+ * page by the carrier's, so a caller comparing the two matches nothing and reports every page
76
+ * twice. Measured on the sidebar carrier: three clicks produced three webview guests while
77
+ * `dsh_browser_list_tabs` reported one tab; on the installed-browser carrier the window held four
78
+ * pages and the tool reported one.
79
+ * @returns every page the carrier holds that this host did not open.
80
+ */
81
+ listPages?(): Promise<readonly CarrierPage[]>;
82
+ /**
83
+ * Optional: close one page reported by {@link listPages}.
84
+ *
85
+ * The pair exists because listing a page the caller cannot close is worse than not listing it:
86
+ * `dsh_browser_list_tabs` would report pages that `dsh_browser_close_tab` answers "not open in
87
+ * this session" about, which reads as a contradiction rather than a boundary.
88
+ * @param id - the carrier's own page id, as {@link listPages} reported it.
89
+ */
90
+ closePage?(id: string): Promise<void>;
61
91
  /**
62
92
  * Optional cheap usability probe (no network): whether the host can back
63
93
  * views at all right now. The self-hosted host checks for a usable Electron
@@ -303,6 +333,15 @@ export interface CdpNavigateParams {
303
333
  * CDP method/params for `Input.dispatchMouseEvent`: a pointer move, or one half
304
334
  * of a click's press+release pair. `button`/`clickCount` belong to the press and
305
335
  * release halves; a move carries only the position.
336
+ *
337
+ * `buttons` is a BITMASK of what is held down at the moment of the event, and it is not the same
338
+ * field as `button` — `button` says which one this event is about, `buttons` says which ones are
339
+ * down. Chromium needs both to recognise a synthesized press as a real button press: with
340
+ * `buttons` omitted (0), the event still reaches the page and a `click` listener still fires, but
341
+ * the element's DEFAULT ACTION does not run. Measured on the sidebar carrier: a click on an
342
+ * anchor sent `mousedown`/`mouseup`/`click` to the page with `isTrusted: true` and
343
+ * `defaultPrevented: false`, and the browser did not follow the link — including for an anchor
344
+ * injected by the test itself, so the page was not responsible.
306
345
  */
307
346
  export interface CdpMouseParams {
308
347
  readonly type: 'mouseMoved' | 'mousePressed' | 'mouseReleased';
@@ -310,6 +349,8 @@ export interface CdpMouseParams {
310
349
  readonly y: number;
311
350
  readonly button?: 'left';
312
351
  readonly clickCount?: number;
352
+ /** Buttons held during the event: 1 = primary down. A move carries 0. */
353
+ readonly buttons?: number;
313
354
  }
314
355
  /** CDP method/params for `Input.insertText`. */
315
356
  export interface CdpInsertTextParams {
@@ -384,6 +384,28 @@ export class ElectronBrowserProvider {
384
384
  active: i === s.activeIndex,
385
385
  });
386
386
  }
387
+ // Pages the CARRIER holds that this provider never created.
388
+ //
389
+ // A sidebar panel and an installed browser both open pages on their own: a click on a link
390
+ // that targets a new page, or any `window.open`, produces a page nothing tells us about.
391
+ // Reporting only our own tabs made those invisible to this tool and unclosable through
392
+ // `dsh_browser_close_tab`, so they accumulated for the life of the browser. Measured on the
393
+ // sidebar carrier: three clicks produced three webview guests while this listed one tab.
394
+ //
395
+ // `listPages` already excludes the pages it opened the view for — that filtering cannot happen
396
+ // here, because a tab is known by this provider's own id and a carrier page by the carrier's,
397
+ // and the two never match. A first version compared them anyway and listed every page twice.
398
+ //
399
+ // They are appended under a `page:` prefix so a caller can tell the two kinds apart — the
400
+ // prefix is also what `closeTab` uses to decide whether to close a view or a carrier page.
401
+ const pages = await this.host.listPages?.().catch(() => undefined) ?? [];
402
+ for (const page of pages) {
403
+ // A page with no URL is a blank one the carrier opened for itself. Listing it would invite a
404
+ // caller to act on nothing, and the blank normalisation already closes those.
405
+ if (page.url === '' || page.url === 'about:blank')
406
+ continue;
407
+ result.push({ id: `page:${page.id}`, url: page.url, active: false });
408
+ }
387
409
  return result;
388
410
  }
389
411
  /** Switch to a tab by id, making its view visible. */
@@ -395,6 +417,16 @@ export class ElectronBrowserProvider {
395
417
  }
396
418
  /** Close one tab; closing the active tab activates the next. */
397
419
  closeTab(session, tabId) {
420
+ // A page the carrier owns rather than a view we created. It was reported by `listTabs` under
421
+ // this prefix, so closing it here is what keeps the two tools consistent — a page that can be
422
+ // listed must be closable, or the listing reads as a contradiction.
423
+ if (tabId.startsWith('page:')) {
424
+ const id = tabId.slice('page:'.length);
425
+ if (this.host.closePage === undefined) {
426
+ return Promise.reject(new BrowserError('browser: this carrier does not expose the pages it opens on its own, so that page cannot be closed', 'BROWSER_TAB_NOT_FOUND'));
427
+ }
428
+ return this.host.closePage(id);
429
+ }
398
430
  const found = this.locateTab(session, tabId);
399
431
  const s = found.s;
400
432
  const index = found.index;
@@ -1290,7 +1322,7 @@ export class ElectronBrowserProvider {
1290
1322
  // landed because the first had quietly established the target. Move the
1291
1323
  // pointer first so a click is self-contained.
1292
1324
  try {
1293
- await send({ type: 'mouseMoved', x, y }, 'move');
1325
+ await send({ type: 'mouseMoved', x, y, buttons: 0 }, 'move');
1294
1326
  }
1295
1327
  catch (error) {
1296
1328
  throw new BrowserError(`browser: click failed: ${String(error)}`, 'BROWSER_CLICK_FAILED', { cause: error });
@@ -1311,12 +1343,13 @@ export class ElectronBrowserProvider {
1311
1343
  x,
1312
1344
  y,
1313
1345
  button: 'left',
1346
+ buttons: 0,
1314
1347
  clickCount: 1,
1315
1348
  })
1316
1349
  .catch(() => { });
1317
1350
  };
1318
1351
  try {
1319
- await send({ type: 'mousePressed', x, y, button: 'left', clickCount: 1 }, 'press');
1352
+ await send({ type: 'mousePressed', x, y, button: 'left', buttons: 1, clickCount: 1 }, 'press');
1320
1353
  }
1321
1354
  catch (error) {
1322
1355
  // The press may still land late; release best-effort so the button is
@@ -1325,7 +1358,7 @@ export class ElectronBrowserProvider {
1325
1358
  throw new BrowserError(`browser: click failed: ${String(error)}`, 'BROWSER_CLICK_FAILED', { cause: error });
1326
1359
  }
1327
1360
  try {
1328
- await send({ type: 'mouseReleased', x, y, button: 'left', clickCount: 1 }, 'release');
1361
+ await send({ type: 'mouseReleased', x, y, button: 'left', buttons: 0, clickCount: 1 }, 'release');
1329
1362
  }
1330
1363
  catch (error) {
1331
1364
  // The press already landed; retry the release so the button is not
@@ -36,6 +36,7 @@
36
36
  export type BrowserLauncher = (path: string, args: readonly string[]) => ReturnType<typeof spawn>;
37
37
  import { spawn } from 'node:child_process';
38
38
  import type { ElectronBrowserViewHost, ElectronViewHandle } from './provider.js';
39
+ import type { CarrierPage } from '../browser/types.js';
39
40
  /** Which browser the user asked for. */
40
41
  export type BrowserChannel = 'bundled' | 'chrome' | 'edge' | 'auto' | 'sidebar';
41
42
  /** A resolved browser installation. */
@@ -213,6 +214,45 @@ export declare class SystemBrowserViewHost implements ElectronBrowserViewHost {
213
214
  * make the plugin fall back for no reason.
214
215
  */
215
216
  available(): boolean;
217
+ /**
218
+ * Every page the browser currently holds, including ones this plugin did not create.
219
+ *
220
+ * Chrome and Edge own their own page creation. A click on a link that opens a new page — or any
221
+ * `window.open` — produces a page the provider never asked for, and nothing reports it back.
222
+ * Those pages are real: they hold memory and stay until something closes them. Measured on a real
223
+ * window: four pages present while `dsh_browser_list_tabs` reported one.
224
+ *
225
+ * Discovery is already on at connection time (see the blank-page normalisation), so
226
+ * `Target.getTargets` also lists pages that existed before this client connected.
227
+ * @returns the browser's pages, or an empty list when it cannot answer.
228
+ */
229
+ /**
230
+ * Every page the browser holds that THIS HOST did not open.
231
+ *
232
+ * Chrome and Edge own their own page creation. A click on a link that opens a new page — or any
233
+ * `window.open` — produces a page the provider never asked for, and nothing reports it back.
234
+ * Those pages are real: they hold memory and stay until something closes them. Measured on a real
235
+ * window: four pages present while `dsh_browser_list_tabs` reported one.
236
+ *
237
+ * Pages this host opened are EXCLUDED here rather than deduplicated by the caller, because the
238
+ * caller cannot do it: the provider knows a tab by its own id, the carrier knows a page by its
239
+ * CDP target id, and the two never match. The first version returned everything and relied on the
240
+ * caller to filter, which reported the same page twice.
241
+ *
242
+ * Discovery is already on at connection time (see the blank-page normalisation), so
243
+ * `Target.getTargets` also lists pages that existed before this client connected.
244
+ * @returns the browser's pages that are not ours, or an empty list when it cannot answer.
245
+ */
246
+ listPages(): Promise<readonly CarrierPage[]>;
247
+ /**
248
+ * Close one page by the target id {@link listPages} reported.
249
+ *
250
+ * `Target.closeTarget` is the browser's own close, so it works for pages this plugin never
251
+ * created. The page may already be gone if the human closed it in the meantime — the caller
252
+ * asked for it to be closed and it is, so that is not an error.
253
+ * @param id - the CDP target id.
254
+ */
255
+ closePage(id: string): Promise<void>;
216
256
  createView(): ElectronViewHandle;
217
257
  destroyView(handle: ElectronViewHandle): void;
218
258
  /** Nothing to show: the browser owns its own windows. */
@@ -156,6 +156,18 @@ const INSTALL_LOCATIONS = {
156
156
  * @param env - the environment to resolve against.
157
157
  * @returns the path with %NAME% references replaced, or the entry unchanged when unknown.
158
158
  */
159
+ /**
160
+ * Whether a browser's self-reported product name is one this carrier could have launched.
161
+ *
162
+ * `edg`, not `edge`: Edge reports itself as `Edg/155.0.4283.45`, both over HTTP and over the
163
+ * protocol. Testing for the full word rejected a live Edge that had just been started, so the
164
+ * call failed with the hand-off error this check was added to prevent — while the browser was
165
+ * running the whole time, answering on its own port.
166
+ * @param product - the product string a browser reports about itself.
167
+ */
168
+ function isBrowserProduct(product) {
169
+ return typeof product === 'string' && /chrome|chromium|edg/i.test(product);
170
+ }
159
171
  /**
160
172
  * Whether a target URL is one of the blanks a browser opens by itself.
161
173
  *
@@ -588,14 +600,14 @@ export class SystemBrowserViewHost {
588
600
  return undefined;
589
601
  // The port number alone proves nothing — it can be reused once the browser that owned it
590
602
  // exits. The endpoint's own answer is the evidence, and it has to name a browser.
591
- if (typeof version.Browser !== 'string' || !/chrome|chromium|edge/i.test(version.Browser))
603
+ if (!isBrowserProduct(version.Browser))
592
604
  return undefined;
593
605
  const client = new CdpClient(version.webSocketDebuggerUrl);
594
606
  await client.whenReady();
595
607
  // Asked through the protocol too, so a websocket that merely accepts connections is not
596
608
  // mistaken for a browser that answers.
597
609
  const info = await client.send('Browser.getVersion', {});
598
- if (typeof info?.product !== 'string' || !/chrome|chromium|edge/i.test(info.product)) {
610
+ if (!isBrowserProduct(info?.product)) {
599
611
  client.close();
600
612
  return undefined;
601
613
  }
@@ -875,6 +887,66 @@ export class SystemBrowserViewHost {
875
887
  available() {
876
888
  return !this.disposed;
877
889
  }
890
+ /**
891
+ * Every page the browser currently holds, including ones this plugin did not create.
892
+ *
893
+ * Chrome and Edge own their own page creation. A click on a link that opens a new page — or any
894
+ * `window.open` — produces a page the provider never asked for, and nothing reports it back.
895
+ * Those pages are real: they hold memory and stay until something closes them. Measured on a real
896
+ * window: four pages present while `dsh_browser_list_tabs` reported one.
897
+ *
898
+ * Discovery is already on at connection time (see the blank-page normalisation), so
899
+ * `Target.getTargets` also lists pages that existed before this client connected.
900
+ * @returns the browser's pages, or an empty list when it cannot answer.
901
+ */
902
+ /**
903
+ * Every page the browser holds that THIS HOST did not open.
904
+ *
905
+ * Chrome and Edge own their own page creation. A click on a link that opens a new page — or any
906
+ * `window.open` — produces a page the provider never asked for, and nothing reports it back.
907
+ * Those pages are real: they hold memory and stay until something closes them. Measured on a real
908
+ * window: four pages present while `dsh_browser_list_tabs` reported one.
909
+ *
910
+ * Pages this host opened are EXCLUDED here rather than deduplicated by the caller, because the
911
+ * caller cannot do it: the provider knows a tab by its own id, the carrier knows a page by its
912
+ * CDP target id, and the two never match. The first version returned everything and relied on the
913
+ * caller to filter, which reported the same page twice.
914
+ *
915
+ * Discovery is already on at connection time (see the blank-page normalisation), so
916
+ * `Target.getTargets` also lists pages that existed before this client connected.
917
+ * @returns the browser's pages that are not ours, or an empty list when it cannot answer.
918
+ */
919
+ async listPages() {
920
+ try {
921
+ const client = await this.ensureClient();
922
+ const listed = await client.send('Target.getTargets', {});
923
+ const ours = new Set(this.sessions.values());
924
+ return (listed?.targetInfos ?? [])
925
+ .filter(entry => entry.type === 'page' && typeof entry.targetId === 'string')
926
+ .filter(entry => !ours.has(entry.targetId))
927
+ .map(entry => ({
928
+ id: entry.targetId,
929
+ url: typeof entry.url === 'string' ? entry.url : '',
930
+ ...typeof entry.title === 'string' && entry.title !== '' ? { title: entry.title } : {},
931
+ }));
932
+ }
933
+ catch {
934
+ // A listing must not fail harder than driving does; the next command reports a real problem.
935
+ return [];
936
+ }
937
+ }
938
+ /**
939
+ * Close one page by the target id {@link listPages} reported.
940
+ *
941
+ * `Target.closeTarget` is the browser's own close, so it works for pages this plugin never
942
+ * created. The page may already be gone if the human closed it in the meantime — the caller
943
+ * asked for it to be closed and it is, so that is not an error.
944
+ * @param id - the CDP target id.
945
+ */
946
+ async closePage(id) {
947
+ const client = await this.ensureClient();
948
+ await client.send('Target.closeTarget', { targetId: id }).catch(() => undefined);
949
+ }
878
950
  createView() {
879
951
  const viewId = randomUUID();
880
952
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-builtin-browser",
3
- "version": "0.4.9",
3
+ "version": "0.4.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 (dsh_browser_auth), CAPTCHA detection, batch form fill (dsh_browser_fill), authenticated download, history/replay, action restriction",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -503,8 +503,15 @@ export interface BrowserTab {
503
503
  readonly active: boolean
504
504
  }
505
505
 
506
- /** Request to open a URL: into the active tab or a new tab. */
507
- export interface BrowserOpenRequest {
506
+ /** One page a carrier holds, whether or not this plugin created it. */
507
+ export interface CarrierPage {
508
+ /** Stable within one carrier session; used to address the page. */
509
+ readonly id: string
510
+ readonly url: string
511
+ readonly title?: string
512
+ }
513
+
514
+ /** Request to open a URL: into the active tab or a new tab. */export interface BrowserOpenRequest {
508
515
  /** The URL to open. */
509
516
  readonly url: string
510
517
  /** Open in a new tab (parallel). Default false (reuse the active tab). */
@@ -30,6 +30,7 @@ import { join } from 'node:path'
30
30
  import { randomUUID } from 'node:crypto'
31
31
  import type { ElectronBrowserViewHost, ElectronViewHandle } from './provider.js'
32
32
  import type { BrowserUserAction } from './provider.js'
33
+ import type { CarrierPage } from '../browser/types.js'
33
34
 
34
35
 
35
36
  /** Absolute path of the endpoint file the shell writes. */
@@ -171,7 +172,26 @@ export class DesktopBridgeViewHost implements ElectronBrowserViewHost {
171
172
  const probe = new BridgeConnection(endpoint, bridgeEndpointPath())
172
173
  try {
173
174
  const answer = await probe.call({ op: 'list' }, 5_000)
174
- return answer.ok === true ? new DesktopBridgeViewHost(endpoint, sessionId) : undefined
175
+ if (answer.ok !== true) return undefined
176
+ // Refuse a panel this plugin cannot drive, before adopting it.
177
+ //
178
+ // The sidebar browser renders as a <webview> only where the shell exposes
179
+ // `dshDesktop.browser` and enables `webviewTag`. Where it renders as an <iframe> there is
180
+ // no webContents, no `getWebContentsId()` and no CDP endpoint — and every tool here is
181
+ // built on CDP, so adopting it means failing every call, with no path back to a carrier
182
+ // that works. Reported on DSH Desktop 2.0.17 with both conditions measured.
183
+ //
184
+ // Read here WITHOUT an owner on purpose, because the question is not about a conversation:
185
+ // whether the panel is a webview is decided by the shell's preload and window options, so
186
+ // it is the same answer for every conversation and needs no session context.
187
+ //
188
+ // `none` and `unknown` are NOT refusals: a panel that does not exist yet is created by the
189
+ // first open, and a probe that could not read the attribute has learned nothing.
190
+ if (String(answer.frame ?? 'unknown') === 'iframe') {
191
+ DesktopBridgeViewHost.refuseIframe = true
192
+ return undefined
193
+ }
194
+ return new DesktopBridgeViewHost(endpoint, sessionId)
175
195
  } finally {
176
196
  probe.close()
177
197
  }
@@ -181,11 +201,96 @@ export class DesktopBridgeViewHost implements ElectronBrowserViewHost {
181
201
  }
182
202
  }
183
203
 
204
+ /**
205
+ * Whether the last discovery refused a bridge because its sidebar panel is an iframe.
206
+ *
207
+ * Recorded so the entry point can say WHY the plugin is self-hosting on a shell that does have a
208
+ * bridge. Otherwise the user sees a second browser window and no explanation, on a machine where
209
+ * they just followed instructions to install the bridge.
210
+ */
211
+ private static refuseIframe = false
212
+
213
+ /** Whether discovery refused a bridge for an undrivable sidebar panel (see {@link discover}). */
214
+ static refusedIframePanel(): boolean {
215
+ return this.refuseIframe
216
+ }
217
+
184
218
  /** Whether this host can back views: the bridge already answered `list`. */
185
219
  available(): boolean {
186
220
  return true
187
221
  }
188
222
 
223
+ /**
224
+ * Every page this conversation's sidebar panel currently holds.
225
+ *
226
+ * The shell owns page creation inside the panel: a link the user or the agent clicks may open a
227
+ * new page, and nothing tells the provider. Those pages are real — they are what the human sees
228
+ * — but without this the tab model reports only the ones the provider created, so the rest are
229
+ * invisible to `dsh_browser_list_tabs` and unclosable through `dsh_browser_close_tab`.
230
+ *
231
+ * The bridge's `list` already answers with the sidebar guests, which is the same information, so
232
+ * this asks for it rather than reaching into the shell again.
233
+ * @returns the panel's pages, or an empty list when the bridge cannot answer.
234
+ */
235
+ /**
236
+ * Every sidebar page this conversation holds that THIS HOST did not open.
237
+ *
238
+ * The shell owns page creation inside the panel: a link the user or the agent clicks may open a
239
+ * new page, and nothing tells the provider. Those pages are real — they are what the human sees
240
+ * — but without this the tab model reports only the ones the provider created, so the rest are
241
+ * invisible to `dsh_browser_list_tabs` and unclosable through `dsh_browser_close_tab`.
242
+ *
243
+ * Pages this host opened are EXCLUDED here rather than deduplicated by the caller, because the
244
+ * caller cannot do it: the provider knows a tab by its own id and the carrier knows a page by the
245
+ * guest id, and the two never match. The first version returned everything and relied on the
246
+ * caller to filter, which reported the same page twice — the mapping that makes the match
247
+ * possible exists only here.
248
+ * @returns the panel's pages that are not ours, or an empty list when the bridge cannot answer.
249
+ */
250
+ async listPages(): Promise<readonly CarrierPage[]> {
251
+ try {
252
+ const answer = await this.connection.call({ op: 'list', owner: this.owner }, 5_000)
253
+ const guests = Array.isArray(answer.sidebar) ? answer.sidebar : []
254
+ const ours = new Set<number>()
255
+ for (const view of this.views.values()) {
256
+ if (typeof view.guest === 'number') ours.add(view.guest)
257
+ }
258
+ return guests
259
+ .filter((guest): guest is { id: number; url?: unknown } => typeof guest?.id === 'number')
260
+ .filter(guest => !ours.has(guest.id))
261
+ .map(guest => ({
262
+ id: String(guest.id),
263
+ url: typeof guest.url === 'string' ? guest.url : '',
264
+ }))
265
+ } catch {
266
+ // A bridge that cannot answer has a real problem, and the next command reports it. Failing
267
+ // the tab LIST here would make a listing worse than the driving it exists to describe.
268
+ return []
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Close one sidebar page by the id {@link listPages} reported.
274
+ *
275
+ * The bridge closes sidebar pages by TITLE, and only the ones the caller owns — that is what
276
+ * stopped one conversation's teardown from destroying another's page. So the title is looked up
277
+ * through the same listing, and a page that no longer appears is treated as already gone rather
278
+ * than as an error: the caller asked for it to be closed, and it is.
279
+ * @param id - the sidebar guest id, as a string.
280
+ */
281
+ async closePage(id: string): Promise<void> {
282
+ const numeric = Number(id)
283
+ if (!Number.isFinite(numeric)) throw new Error(`not a sidebar page id: ${id}`)
284
+ const answer = await this.connection.call({ op: 'list', owner: this.owner }, 5_000)
285
+ const guests = Array.isArray(answer.sidebar) ? answer.sidebar : []
286
+ const title = guests
287
+ .filter((guest): guest is { id: number; title?: unknown } => Number(guest?.id) === numeric)
288
+ .map(guest => String(guest.title ?? ''))
289
+ .find(candidate => candidate !== '')
290
+ if (title === undefined) return
291
+ await this.connection.call({ op: 'closeSidebarBrowser', titles: [title], owner: this.owner }, 10_000)
292
+ }
293
+
189
294
  /**
190
295
  * The guest backing a view, materialized on first use.
191
296
  *
@@ -223,7 +328,16 @@ export class DesktopBridgeViewHost implements ElectronBrowserViewHost {
223
328
  owner,
224
329
  ...url !== undefined && url !== '' ? { url } : {},
225
330
  }, 30_000)
226
- if (answer.ok !== true) throw new Error(`dsh-builtin-browser: sidebar unavailable (${String(answer.error)})`)
331
+ if (answer.ok !== true) {
332
+ // A bridge that answers but cannot produce a page is as unusable as one that has exited, and
333
+ // the difference matters here: `onLost` is what returns the surface to the self-hosted
334
+ // browser. Without this the same failure repeats on every call until DSH is restarted — on a
335
+ // shell whose sidebar panel is an iframe, that is every call forever. Reported as "installing
336
+ // the bridge made it worse than not installing it": the carrier was switched to one that
337
+ // cannot work, with no path back to the one that does.
338
+ if (String(answer.error).includes('produced no page')) this.reportLost()
339
+ throw new Error(`dsh-builtin-browser: sidebar unavailable (${String(answer.error)})`)
340
+ }
227
341
  const id = Number(answer.id)
228
342
  if (!Number.isFinite(id) || id <= 0) {
229
343
  throw new Error('dsh-builtin-browser: the sidebar reported no browser page for this conversation')
@@ -335,6 +335,21 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
335
335
  adopt(sidebar, 'driving the desktop sidebar browser')
336
336
  return
337
337
  }
338
+ // A bridge exists but its sidebar panel is an iframe. Saying nothing here would leave the user
339
+ // with a second browser window on a machine where they just followed the instructions to
340
+ // install the bridge, and no way to know the two facts are related. The carrier is not
341
+ // unavailable by accident: an iframe has no CDP endpoint, so adopting it would fail every call
342
+ // instead of falling back — see DesktopBridgeViewHost.discover.
343
+ if (DesktopBridgeViewHost.refusedIframePanel()) {
344
+ ctx.logger?.warn?.(
345
+ 'dsh-builtin-browser: a desktop bridge is installed, but this shell renders the sidebar '
346
+ + 'browser panel as an iframe rather than a webview, which exposes no CDP endpoint and '
347
+ + 'cannot be driven. Using the browser this plugin hosts itself instead — that is the '
348
+ + 'independent window, not the sidebar. A shell that exposes `dshDesktop.browser` and '
349
+ + 'enables `webviewTag` would provide a drivable panel.',
350
+ )
351
+ return
352
+ }
338
353
  // The user explicitly asked for the sidebar, so silence would leave them with a setting
339
354
  // that did nothing and no way to tell why. This is reachable on `dsh web`, and on a desktop
340
355
  // whose shell never published a bridge endpoint. An automatic attempt stays quiet (the
@@ -51,6 +51,7 @@ import type {
51
51
  ExportedCookie,
52
52
  } from '../browser/types.js'
53
53
  import { BrowserError } from '../browser/types.js'
54
+ import type { CarrierPage } from '../browser/types.js'
54
55
 
55
56
  /**
56
57
  * Page-context human-verification (CAPTCHA / bot-detection) detection. Runs
@@ -156,6 +157,35 @@ export interface ElectronBrowserViewHost {
156
157
  * @param label - human-readable session/task label, as in {@link showView}.
157
158
  */
158
159
  presentView?(handle: ElectronViewHandle, label?: string): Promise<void>
160
+ /**
161
+ * Optional: pages this carrier holds that the provider never created.
162
+ *
163
+ * A sidebar panel and an installed browser both own their own page creation. Clicking a link
164
+ * that opens a new page — or any `window.open` the page performs — produces a page the provider
165
+ * did not ask for and therefore never recorded. Those pages are real: they are what the human
166
+ * sees, they hold memory, and they persist until something closes them. Without this the tab
167
+ * model reports only what the provider itself made, so the extras are invisible to
168
+ * `dsh_browser_list_tabs` and unclosable through `dsh_browser_close_tab`, accumulating for the
169
+ * life of the browser.
170
+ *
171
+ * Views the host itself opened are NOT included. That exclusion belongs to the host, which is the
172
+ * only side that can make it: a provider tab is identified by the provider's own id and a carrier
173
+ * page by the carrier's, so a caller comparing the two matches nothing and reports every page
174
+ * twice. Measured on the sidebar carrier: three clicks produced three webview guests while
175
+ * `dsh_browser_list_tabs` reported one tab; on the installed-browser carrier the window held four
176
+ * pages and the tool reported one.
177
+ * @returns every page the carrier holds that this host did not open.
178
+ */
179
+ listPages?(): Promise<readonly CarrierPage[]>
180
+ /**
181
+ * Optional: close one page reported by {@link listPages}.
182
+ *
183
+ * The pair exists because listing a page the caller cannot close is worse than not listing it:
184
+ * `dsh_browser_list_tabs` would report pages that `dsh_browser_close_tab` answers "not open in
185
+ * this session" about, which reads as a contradiction rather than a boundary.
186
+ * @param id - the carrier's own page id, as {@link listPages} reported it.
187
+ */
188
+ closePage?(id: string): Promise<void>
159
189
  /**
160
190
  * Optional cheap usability probe (no network): whether the host can back
161
191
  * views at all right now. The self-hosted host checks for a usable Electron
@@ -413,6 +443,15 @@ export interface CdpNavigateParams {
413
443
  * CDP method/params for `Input.dispatchMouseEvent`: a pointer move, or one half
414
444
  * of a click's press+release pair. `button`/`clickCount` belong to the press and
415
445
  * release halves; a move carries only the position.
446
+ *
447
+ * `buttons` is a BITMASK of what is held down at the moment of the event, and it is not the same
448
+ * field as `button` — `button` says which one this event is about, `buttons` says which ones are
449
+ * down. Chromium needs both to recognise a synthesized press as a real button press: with
450
+ * `buttons` omitted (0), the event still reaches the page and a `click` listener still fires, but
451
+ * the element's DEFAULT ACTION does not run. Measured on the sidebar carrier: a click on an
452
+ * anchor sent `mousedown`/`mouseup`/`click` to the page with `isTrusted: true` and
453
+ * `defaultPrevented: false`, and the browser did not follow the link — including for an anchor
454
+ * injected by the test itself, so the page was not responsible.
416
455
  */
417
456
  export interface CdpMouseParams {
418
457
  readonly type: 'mouseMoved' | 'mousePressed' | 'mouseReleased'
@@ -420,6 +459,8 @@ export interface CdpMouseParams {
420
459
  readonly y: number
421
460
  readonly button?: 'left'
422
461
  readonly clickCount?: number
462
+ /** Buttons held during the event: 1 = primary down. A move carries 0. */
463
+ readonly buttons?: number
423
464
  }
424
465
 
425
466
  /** CDP method/params for `Input.insertText`. */
@@ -771,6 +812,27 @@ export class ElectronBrowserProvider implements BrowserProvider {
771
812
  active: i === s.activeIndex,
772
813
  })
773
814
  }
815
+ // Pages the CARRIER holds that this provider never created.
816
+ //
817
+ // A sidebar panel and an installed browser both open pages on their own: a click on a link
818
+ // that targets a new page, or any `window.open`, produces a page nothing tells us about.
819
+ // Reporting only our own tabs made those invisible to this tool and unclosable through
820
+ // `dsh_browser_close_tab`, so they accumulated for the life of the browser. Measured on the
821
+ // sidebar carrier: three clicks produced three webview guests while this listed one tab.
822
+ //
823
+ // `listPages` already excludes the pages it opened the view for — that filtering cannot happen
824
+ // here, because a tab is known by this provider's own id and a carrier page by the carrier's,
825
+ // and the two never match. A first version compared them anyway and listed every page twice.
826
+ //
827
+ // They are appended under a `page:` prefix so a caller can tell the two kinds apart — the
828
+ // prefix is also what `closeTab` uses to decide whether to close a view or a carrier page.
829
+ const pages = await this.host.listPages?.().catch(() => undefined) ?? []
830
+ for (const page of pages) {
831
+ // A page with no URL is a blank one the carrier opened for itself. Listing it would invite a
832
+ // caller to act on nothing, and the blank normalisation already closes those.
833
+ if (page.url === '' || page.url === 'about:blank') continue
834
+ result.push({ id: `page:${page.id}`, url: page.url, active: false })
835
+ }
774
836
  return result
775
837
  }
776
838
 
@@ -784,6 +846,19 @@ export class ElectronBrowserProvider implements BrowserProvider {
784
846
 
785
847
  /** Close one tab; closing the active tab activates the next. */
786
848
  closeTab(session: BrowserSessionId, tabId: string): Promise<void> {
849
+ // A page the carrier owns rather than a view we created. It was reported by `listTabs` under
850
+ // this prefix, so closing it here is what keeps the two tools consistent — a page that can be
851
+ // listed must be closable, or the listing reads as a contradiction.
852
+ if (tabId.startsWith('page:')) {
853
+ const id = tabId.slice('page:'.length)
854
+ if (this.host.closePage === undefined) {
855
+ return Promise.reject(new BrowserError(
856
+ 'browser: this carrier does not expose the pages it opens on its own, so that page cannot be closed',
857
+ 'BROWSER_TAB_NOT_FOUND',
858
+ ))
859
+ }
860
+ return this.host.closePage(id)
861
+ }
787
862
  const found = this.locateTab(session, tabId)
788
863
  const s = found.s
789
864
  const index = found.index
@@ -1747,7 +1822,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
1747
1822
  // landed because the first had quietly established the target. Move the
1748
1823
  // pointer first so a click is self-contained.
1749
1824
  try {
1750
- await send({ type: 'mouseMoved', x, y } satisfies CdpMouseParams, 'move')
1825
+ await send({ type: 'mouseMoved', x, y, buttons: 0 } satisfies CdpMouseParams, 'move')
1751
1826
  } catch (error) {
1752
1827
  throw new BrowserError(`browser: click failed: ${String(error)}`, 'BROWSER_CLICK_FAILED', { cause: error })
1753
1828
  }
@@ -1767,12 +1842,13 @@ export class ElectronBrowserProvider implements BrowserProvider {
1767
1842
  x,
1768
1843
  y,
1769
1844
  button: 'left',
1845
+ buttons: 0,
1770
1846
  clickCount: 1,
1771
1847
  } satisfies CdpMouseParams)
1772
1848
  .catch(() => {})
1773
1849
  }
1774
1850
  try {
1775
- await send({ type: 'mousePressed', x, y, button: 'left', clickCount: 1 }, 'press')
1851
+ await send({ type: 'mousePressed', x, y, button: 'left', buttons: 1, clickCount: 1 }, 'press')
1776
1852
  } catch (error) {
1777
1853
  // The press may still land late; release best-effort so the button is
1778
1854
  // never left in a stuck pressed state.
@@ -1780,7 +1856,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
1780
1856
  throw new BrowserError(`browser: click failed: ${String(error)}`, 'BROWSER_CLICK_FAILED', { cause: error })
1781
1857
  }
1782
1858
  try {
1783
- await send({ type: 'mouseReleased', x, y, button: 'left', clickCount: 1 }, 'release')
1859
+ await send({ type: 'mouseReleased', x, y, button: 'left', buttons: 0, clickCount: 1 }, 'release')
1784
1860
  } catch (error) {
1785
1861
  // The press already landed; retry the release so the button is not
1786
1862
  // left pressed before surfacing the failure.