dsh-builtin-browser 0.4.9 → 0.4.11

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
  }
@@ -687,11 +699,38 @@ export class SystemBrowserViewHost {
687
699
  // The port file is deliberately NOT deleted here, and that is a reversal.
688
700
  //
689
701
  // It used to be removed so the launch loop below could not read a previous browser's port. But
690
- // the loop already refuses any file older than the spawn (`freshPort` compares mtimes), so the
691
- // deletion never protected it — while it DID destroy the only record of a browser that may
692
- // still be running for this profile. That record is what `adoptRunningBrowser` needs when the
693
- // launch comes back as a hand-off: the browser was alive the whole time, and without the file
694
- // there is no way to find it again.
702
+ // the loop judges freshness against a snapshot taken below, so the deletion never protected it
703
+ // — while it DID destroy the only record of a browser that may still be running for this
704
+ // profile. That record is what `adoptRunningBrowser` needs when the launch comes back as a
705
+ // hand-off: the browser was alive the whole time, and without the file there is no way to find
706
+ // it again.
707
+ const portFile = join(this.profileDir, 'DevToolsActivePort');
708
+ /**
709
+ * What the port file was like BEFORE this launch, so "fresh" is judged against a snapshot
710
+ * rather than against a clock.
711
+ *
712
+ * The test used to be `mtimeMs < spawnedAt` — a filesystem timestamp compared to `Date.now()`.
713
+ * Those are different clocks with different resolutions, and CI is where it showed: a file the
714
+ * fake browser had just written was rejected as stale, so the loop waited out its full 30s
715
+ * against a browser that was already listening, because the file's mtime had rounded down
716
+ * below the spawn time. That is not a test-only problem: on any filesystem whose mtime
717
+ * resolution is coarser than a millisecond, the plugin would fail to see the browser it had
718
+ * just launched, and report a CDP timeout for a browser that was up the whole time.
719
+ *
720
+ * `undefined` means there was no file, so any file that appears afterwards is fresh. When one
721
+ * existed, a later mtime OR different contents makes the new one fresh. Byte-identical contents
722
+ * with an unchanged mtime is the PREVIOUS browser's port, and adopting it is what this whole
723
+ * check exists to prevent: it may name a port that now belongs to something else entirely.
724
+ */
725
+ const portFileBefore = (() => {
726
+ try {
727
+ return { mtimeMs: statSync(portFile).mtimeMs, contents: readFileSync(portFile, 'utf8') };
728
+ }
729
+ catch {
730
+ return undefined;
731
+ }
732
+ })();
733
+ // Kept for the failure message and for the exit listener's diagnostics; no longer a judgement.
695
734
  const spawnedAt = Date.now();
696
735
  let child;
697
736
  try {
@@ -727,22 +766,28 @@ export class SystemBrowserViewHost {
727
766
  // Clearing these is what makes the restart usable rather than merely alive.
728
767
  this.forgetBrowserState();
729
768
  });
730
- const portFile = join(this.profileDir, 'DevToolsActivePort');
731
769
  /**
732
770
  * The port this launch wrote, or undefined while it has not written one yet.
733
771
  *
734
- * A file left by the PREVIOUS browser is not evidence about this one. Its contents
735
- * name a port that may now belong to something else entirely — another Chromium, a
736
- * node --inspect — and connecting there publishes a client this host does not own and
737
- * dispose() can never kill. Only a file written after the spawn counts.
772
+ * A file left by the PREVIOUS browser is not evidence about this one. Its contents name a port
773
+ * that may now belong to something else entirely — another Chromium, a `node --inspect` — and
774
+ * connecting there publishes a client this host does not own and dispose() can never kill.
775
+ * Only a file that CHANGED across this launch counts, which is what `portFileBefore` records.
738
776
  */
739
777
  const freshPort = () => {
740
778
  try {
741
779
  if (!existsSync(portFile))
742
780
  return undefined;
743
- if (statSync(portFile).mtimeMs < spawnedAt)
781
+ const after = statSync(portFile);
782
+ const contents = readFileSync(portFile, 'utf8');
783
+ // Unchanged since before the launch: this is the previous browser's file, whatever its
784
+ // mtime says. Comparing against the snapshot rather than the spawn time is what keeps this
785
+ // correct on a filesystem whose timestamps are coarser than the clock behind spawnedAt.
786
+ if (portFileBefore !== undefined
787
+ && after.mtimeMs <= portFileBefore.mtimeMs
788
+ && contents === portFileBefore.contents)
744
789
  return undefined;
745
- const value = Number(readFileSync(portFile, 'utf8').split('\n')[0]?.trim() ?? '');
790
+ const value = Number(contents.split('\n')[0]?.trim() ?? '');
746
791
  return Number.isInteger(value) && value > 0 ? value : undefined;
747
792
  }
748
793
  catch {
@@ -875,6 +920,66 @@ export class SystemBrowserViewHost {
875
920
  available() {
876
921
  return !this.disposed;
877
922
  }
923
+ /**
924
+ * Every page the browser currently holds, including ones this plugin did not create.
925
+ *
926
+ * Chrome and Edge own their own page creation. A click on a link that opens a new page — or any
927
+ * `window.open` — produces a page the provider never asked for, and nothing reports it back.
928
+ * Those pages are real: they hold memory and stay until something closes them. Measured on a real
929
+ * window: four pages present while `dsh_browser_list_tabs` reported one.
930
+ *
931
+ * Discovery is already on at connection time (see the blank-page normalisation), so
932
+ * `Target.getTargets` also lists pages that existed before this client connected.
933
+ * @returns the browser's pages, or an empty list when it cannot answer.
934
+ */
935
+ /**
936
+ * Every page the browser holds that THIS HOST did not open.
937
+ *
938
+ * Chrome and Edge own their own page creation. A click on a link that opens a new page — or any
939
+ * `window.open` — produces a page the provider never asked for, and nothing reports it back.
940
+ * Those pages are real: they hold memory and stay until something closes them. Measured on a real
941
+ * window: four pages present while `dsh_browser_list_tabs` reported one.
942
+ *
943
+ * Pages this host opened are EXCLUDED here rather than deduplicated by the caller, because the
944
+ * caller cannot do it: the provider knows a tab by its own id, the carrier knows a page by its
945
+ * CDP target id, and the two never match. The first version returned everything and relied on the
946
+ * caller to filter, which reported the same page twice.
947
+ *
948
+ * Discovery is already on at connection time (see the blank-page normalisation), so
949
+ * `Target.getTargets` also lists pages that existed before this client connected.
950
+ * @returns the browser's pages that are not ours, or an empty list when it cannot answer.
951
+ */
952
+ async listPages() {
953
+ try {
954
+ const client = await this.ensureClient();
955
+ const listed = await client.send('Target.getTargets', {});
956
+ const ours = new Set(this.sessions.values());
957
+ return (listed?.targetInfos ?? [])
958
+ .filter(entry => entry.type === 'page' && typeof entry.targetId === 'string')
959
+ .filter(entry => !ours.has(entry.targetId))
960
+ .map(entry => ({
961
+ id: entry.targetId,
962
+ url: typeof entry.url === 'string' ? entry.url : '',
963
+ ...typeof entry.title === 'string' && entry.title !== '' ? { title: entry.title } : {},
964
+ }));
965
+ }
966
+ catch {
967
+ // A listing must not fail harder than driving does; the next command reports a real problem.
968
+ return [];
969
+ }
970
+ }
971
+ /**
972
+ * Close one page by the target id {@link listPages} reported.
973
+ *
974
+ * `Target.closeTarget` is the browser's own close, so it works for pages this plugin never
975
+ * created. The page may already be gone if the human closed it in the meantime — the caller
976
+ * asked for it to be closed and it is, so that is not an error.
977
+ * @param id - the CDP target id.
978
+ */
979
+ async closePage(id) {
980
+ const client = await this.ensureClient();
981
+ await client.send('Target.closeTarget', { targetId: id }).catch(() => undefined);
982
+ }
878
983
  createView() {
879
984
  const viewId = randomUUID();
880
985
  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.11",
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