dsh-builtin-browser 0.4.1 → 0.4.5

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.
Files changed (57) hide show
  1. package/CHANGELOG.md +1699 -0
  2. package/README.en.md +114 -33
  3. package/README.md +108 -32
  4. package/client.js +478 -29
  5. package/cordis.patch.yml +21 -0
  6. package/desktop-bridge/install.mjs +66 -6
  7. package/desktop-bridge/plugin-browser-bridge.js +426 -167
  8. package/docs/architecture.md +14 -4
  9. package/docs/tool-reference.md +13 -13
  10. package/docs/user-guide.md +58 -3
  11. package/docs/why-browser.md +1 -1
  12. package/lib/browser/types.d.ts +34 -3
  13. package/lib/browser-electron/bridge-connection.d.ts +49 -26
  14. package/lib/browser-electron/bridge-connection.js +185 -22
  15. package/lib/browser-electron/desktop-bridge-host.d.ts +69 -36
  16. package/lib/browser-electron/desktop-bridge-host.js +151 -86
  17. package/lib/browser-electron/entry.d.ts +1 -1
  18. package/lib/browser-electron/entry.js +133 -40
  19. package/lib/browser-electron/ephemeral-profile.d.ts +37 -0
  20. package/lib/browser-electron/ephemeral-profile.js +146 -0
  21. package/lib/browser-electron/history-store.d.ts +36 -2
  22. package/lib/browser-electron/history-store.js +76 -7
  23. package/lib/browser-electron/host-main.js +86 -28
  24. package/lib/browser-electron/provider.d.ts +106 -3
  25. package/lib/browser-electron/provider.js +407 -60
  26. package/lib/browser-electron/remote-host.d.ts +52 -3
  27. package/lib/browser-electron/remote-host.js +259 -25
  28. package/lib/browser-electron/rpc-ops.d.ts +18 -0
  29. package/lib/browser-electron/rpc-ops.js +36 -0
  30. package/lib/browser-electron/settings-route.d.ts +28 -0
  31. package/lib/browser-electron/settings-route.js +68 -0
  32. package/lib/browser-electron/settings-store.d.ts +40 -0
  33. package/lib/browser-electron/settings-store.js +160 -23
  34. package/lib/browser-electron/system-browser.d.ts +102 -12
  35. package/lib/browser-electron/system-browser.js +411 -51
  36. package/lib/index.js +0 -1
  37. package/lib/tool-browser/element-fields.d.ts +145 -0
  38. package/lib/tool-browser/element-fields.js +152 -0
  39. package/lib/tool-browser/index.js +149 -74
  40. package/package.json +10 -4
  41. package/scripts/verify-host-compat.mjs +195 -0
  42. package/src/browser/types.ts +34 -3
  43. package/src/browser-electron/bridge-connection.ts +179 -22
  44. package/src/browser-electron/desktop-bridge-host.ts +146 -84
  45. package/src/browser-electron/entry.ts +132 -39
  46. package/src/browser-electron/ephemeral-profile.ts +152 -0
  47. package/src/browser-electron/history-store.ts +81 -9
  48. package/src/browser-electron/host-main.ts +115 -36
  49. package/src/browser-electron/provider.ts +492 -69
  50. package/src/browser-electron/remote-host.ts +276 -38
  51. package/src/browser-electron/rpc-ops.ts +41 -0
  52. package/src/browser-electron/settings-route.ts +87 -0
  53. package/src/browser-electron/settings-store.ts +173 -24
  54. package/src/browser-electron/system-browser.ts +408 -39
  55. package/src/index.ts +3 -2
  56. package/src/tool-browser/element-fields.ts +167 -0
  57. package/src/tool-browser/index.ts +155 -76
@@ -45,7 +45,9 @@ export function bridgeEndpointPath(): string {
45
45
  */
46
46
  function isGuestGone(error: unknown): boolean {
47
47
  const message = error instanceof Error ? error.message : String(error)
48
- return /guest \d+ is not available|sidebar unavailable|did not create a browser guest/i.test(message)
48
+ // The panel path reports its own failure when the renderer never produced a page, and that is the
49
+ // same class as a vanished guest — worth one retry on a fresh panel — so it is listed here too.
50
+ return /guest \d+ is not available|sidebar unavailable|produced no page/i.test(message)
49
51
  }
50
52
 
51
53
  /**
@@ -58,16 +60,53 @@ function isGuestGone(error: unknown): boolean {
58
60
  */
59
61
  export class DesktopBridgeViewHost implements ElectronBrowserViewHost {
60
62
  private readonly connection: BridgeConnection
61
- private readonly views = new Map<string, number>()
62
63
  /**
63
- * Guests whose view is gone but whose page may still be open in the sidebar.
64
+ * One entry per view this host handed out: the conversation it serves, and the guest backing it.
64
65
  *
65
- * The provider destroys its view handles before it asks for a release, so the
66
- * mapping is dropped by then — keeping the guest ids here is what lets us close
67
- * only our own tabs when several sessions are running (requirements §4: each
68
- * session gets its own page).
66
+ * The conversation belongs to the ENTRY, not to this host. A single plugin process serves every
67
+ * conversation — DSH runs them all through one host instance — so an owner held on the host
68
+ * would be one conversation's id applied to all of them, and a page could still land in
69
+ * another's sidebar however carefully the bridge checked it.
69
70
  */
70
- private readonly orphaned = new Set<number>()
71
+ private readonly views = new Map<string, { guest?: number; owner: string }>()
72
+ /**
73
+ * This process's identity with the bridge, for tab ownership.
74
+ *
75
+ * Every DSH session is its own plugin process, and the sidebar is one surface they all share.
76
+ * A session can only see its own `views` map, so before the bridge kept a ledger it adopted
77
+ * whichever tab it found — including one another session had opened, which is how a URL from
78
+ * one session appeared in another's sidebar. The id is per process, so two sessions never
79
+ * collide and one session's tabs are never handed to another; it is deliberately not the DSH
80
+ * session id, which this layer has no access to and does not need.
81
+ *
82
+ * The whole ledger rests on that premise, so it was measured rather than assumed: with two
83
+ * sessions running, the desktop had TWO host processes (plus one main, two renderers and a
84
+ * gpu/utility pair). If sessions ever shared a process, every owner check in the bridge would
85
+ * compare equal and the bleed would return in a new shape — so a change to how DSH spawns
86
+ * sessions invalidates this file, not just this comment.
87
+ *
88
+ * It is now the CONVERSATION id rather than an invented uuid, read from the plugin's ctx by the
89
+ * same path DSH's own desktop host uses. That matters because the shell keeps one sidebar per
90
+ * conversation and writes its session id onto each sidebar container (on the React fiber,
91
+ * measured on the running app) — so the bridge can only answer "is this sidebar mine" if it has
92
+ * a comparable id. With an invented uuid it never could, which is how a command came to be typed
93
+ * into another conversation's address bar.
94
+ *
95
+ * Falls back to a random uuid when the host cannot supply one — an older DSH, or a non-desktop
96
+ * composition. That is no worse than before: the bridge then knows only "this process", and it
97
+ * refuses to operate on a sidebar it cannot prove is its own.
98
+ */
99
+ private readonly owner: string
100
+ /**
101
+ * Guests whose view is gone but whose page may still be open in the sidebar, with the
102
+ * conversation each belongs to.
103
+ *
104
+ * The provider destroys its view handles before it asks for a release, so the mapping is dropped
105
+ * by then — keeping the guest ids here is what lets us close only our own tabs when several
106
+ * conversations are running (requirements §4: each session gets its own page). The owner rides
107
+ * along for the same reason it does in {@link views}: a release names one conversation.
108
+ */
109
+ private readonly orphaned = new Map<number, string>()
71
110
  /**
72
111
  * Registered by the provider, and deliberately never invoked on this carrier.
73
112
  *
@@ -83,8 +122,9 @@ export class DesktopBridgeViewHost implements ElectronBrowserViewHost {
83
122
  /**
84
123
  * @param endpoint - the shell's published bridge endpoint.
85
124
  */
86
- private constructor(endpoint: BridgeEndpoint) {
87
- this.connection = new BridgeConnection(endpoint)
125
+ private constructor(endpoint: BridgeEndpoint, sessionId?: string) {
126
+ this.owner = sessionId !== undefined && sessionId !== String.fromCharCode(39,39) ? sessionId : randomUUID()
127
+ this.connection = new BridgeConnection(endpoint, bridgeEndpointPath())
88
128
  }
89
129
 
90
130
  /**
@@ -95,7 +135,7 @@ export class DesktopBridgeViewHost implements ElectronBrowserViewHost {
95
135
  * caller falls back to self-hosting, which always works.
96
136
  * @returns the host, or undefined when no bridge is available.
97
137
  */
98
- static async discover(): Promise<DesktopBridgeViewHost | undefined> {
138
+ static async discover(sessionId?: string): Promise<DesktopBridgeViewHost | undefined> {
99
139
  let endpoint: BridgeEndpoint
100
140
  try {
101
141
  const raw = JSON.parse(readFileSync(bridgeEndpointPath(), 'utf8')) as Partial<BridgeEndpoint>
@@ -107,10 +147,10 @@ export class DesktopBridgeViewHost implements ElectronBrowserViewHost {
107
147
  try {
108
148
  // A throwaway connection for discovery: the adopted host opens its own, and a
109
149
  // rejected endpoint must not leave a socket behind.
110
- const probe = new BridgeConnection(endpoint)
150
+ const probe = new BridgeConnection(endpoint, bridgeEndpointPath())
111
151
  try {
112
152
  const answer = await probe.call({ op: 'list' }, 5_000)
113
- return answer.ok === true ? new DesktopBridgeViewHost(endpoint) : undefined
153
+ return answer.ok === true ? new DesktopBridgeViewHost(endpoint, sessionId) : undefined
114
154
  } finally {
115
155
  probe.close()
116
156
  }
@@ -126,61 +166,58 @@ export class DesktopBridgeViewHost implements ElectronBrowserViewHost {
126
166
  }
127
167
 
128
168
  /**
129
- * The guest id backing a view, materialized on first use.
169
+ * The guest backing a view, materialized on first use.
130
170
  *
131
- * The sidebar browser is itself a multi-tab surface, so each view gets its own
132
- * tab's guest: the provider's tab bookkeeping then maps onto real tabs the human
133
- * can see and switch between. Two rules keep that honest:
134
- * - a cached guest is used as-is: probing it first cost a round-trip on every
135
- * command, so liveness is established by the command failing instead;
136
- * - a fresh view takes an unclaimed guest, growing the tab strip only when
137
- * every existing guest is already spoken for.
171
+ * The conversation that called owns exactly one sidebar page on this carrier — a human and the
172
+ * agent look at the same page — so every view resolves to that conversation's own guest, and the
173
+ * page does not multiply behind the provider's own tab bookkeeping.
174
+ *
175
+ * A cached guest is used as-is: probing it first cost a round-trip on every command, so liveness
176
+ * is established by the command failing instead.
138
177
  * @param viewId - the view whose guest is wanted.
139
- * @param url - address to use when a sidebar browser has to be opened first.
178
+ * @param url - address to use when this conversation has no page yet.
140
179
  */
180
+
141
181
  private async guestFor(viewId: string, url?: string): Promise<number> {
142
- const existing = this.views.get(viewId)
182
+ const entry = this.views.get(viewId)
143
183
  // No liveness check here: verifying the cached guest cost a round-trip on every
144
184
  // command. A guest that has gone away is detected by the command itself failing,
145
185
  // and `createView`'s sendCommand then discards the cache and calls back in here.
146
- if (existing !== undefined) return existing
186
+ if (entry?.guest !== undefined) return entry.guest
147
187
 
148
- // The browser tab must exist before its strip can be extended: `ensureSidebar`
149
- // opens the sidebar (and, on a fresh shell, materializes the first guest).
150
- const sidebar = await this.connection.call({
188
+ // The conversation THIS VIEW serves — read from the view, never from the process.
189
+ //
190
+ // The difference is the whole bug. This host is one instance shared by every conversation, so
191
+ // `this.owner` alone can only be "whichever conversation this process happens to name", and
192
+ // naming the wrong one is exactly how a page requested in one conversation appeared in
193
+ // another. The view was created for a conversation (`createView(owner)`), so that is what it
194
+ // asks for its page.
195
+ //
196
+ // Nothing here asks which conversation is on screen: `ensureSidebar` reaches this
197
+ // conversation's own panel by id through the renderer, rather than by pressing the shell's
198
+ // shortcut or clicking its card, both of which the shell delivers to whatever it displays.
199
+ const owner = entry?.owner ?? this.owner
200
+ const answer = await this.connection.call({
151
201
  op: 'ensureSidebar',
152
- ...url !== undefined ? { url } : {},
153
- })
154
- if (sidebar.ok !== true) throw new Error(`dsh-builtin-browser: sidebar unavailable (${String(sidebar.error)})`)
155
-
156
- const wanted = this.views.size + 1
157
- let ids = await this.guestIds(Math.max(1, wanted))
158
- const taken = new Set(this.views.values())
159
- let free = ids.find(id => !taken.has(id))
160
- if (free === undefined) {
161
- // Every tab is already driving something: the strip has to grow.
162
- ids = await this.guestIds(ids.length + 1)
163
- free = ids.find(id => !taken.has(id))
202
+ owner,
203
+ ...url !== undefined && url !== '' ? { url } : {},
204
+ }, 30_000)
205
+ if (answer.ok !== true) throw new Error(`dsh-builtin-browser: sidebar unavailable (${String(answer.error)})`)
206
+ const id = Number(answer.id)
207
+ if (!Number.isFinite(id) || id <= 0) {
208
+ throw new Error('dsh-builtin-browser: the sidebar reported no browser page for this conversation')
164
209
  }
165
- if (free === undefined) throw new Error('dsh-builtin-browser: the sidebar reported no free browser tab')
166
- this.views.set(viewId, free)
167
- return free
210
+ this.views.set(viewId, { guest: id, owner })
211
+ return id
168
212
  }
169
213
 
170
214
  /**
171
- * Ask for at least `count` sidebar tabs and return their guest ids.
172
- * @param count - minimum number of tabs.
215
+ * @param owner - the conversation this view serves. Recorded WITH the view: one host instance
216
+ * serves every conversation, so the owner cannot live on the host.
173
217
  */
174
- private async guestIds(count: number): Promise<number[]> {
175
- const answer = await this.connection.call({ op: 'ensureTabs', count })
176
- if (answer.ok !== true) throw new Error(`dsh-builtin-browser: could not open a sidebar tab (${String(answer.error)})`)
177
- const ids = Array.isArray(answer.ids) ? answer.ids.map(Number).filter(Number.isFinite) : []
178
- if (ids.length === 0) throw new Error('dsh-builtin-browser: the sidebar reported no browser tabs')
179
- return ids
180
- }
181
-
182
- createView(): ElectronViewHandle {
218
+ createView(owner?: string): ElectronViewHandle {
183
219
  const viewId = randomUUID()
220
+ this.views.set(viewId, { owner: owner !== undefined && owner !== '' ? owner : this.owner })
184
221
  const navigateUrl = (method: string, params?: Record<string, unknown>): string | undefined =>
185
222
  method === 'Page.navigate' ? String((params as { url?: unknown })?.url ?? '') : undefined
186
223
  const run = async (guest: number, method: string, params?: Record<string, unknown>): Promise<Record<string, unknown>> => {
@@ -205,7 +242,10 @@ export class DesktopBridgeViewHost implements ElectronBrowserViewHost {
205
242
  // The human closed the tab: the page is gone, so take a fresh one and
206
243
  // replay the command once. This is the "closing the interface ends the
207
244
  // session" rule, now paid for only when it actually happens.
208
- this.views.delete(viewId)
245
+ // Drop the guest but keep the entry: the conversation it serves is what the retry needs,
246
+ // and removing the entry would lose it back to the process-wide fallback owner.
247
+ const lost = this.views.get(viewId)
248
+ if (lost !== undefined) delete lost.guest
209
249
  guest = await this.guestFor(viewId, url)
210
250
  return await run(guest, method, params)
211
251
  }
@@ -214,41 +254,44 @@ export class DesktopBridgeViewHost implements ElectronBrowserViewHost {
214
254
  }
215
255
 
216
256
  destroyView(handle: ElectronViewHandle): void {
217
- const guest = this.views.get(handle.id)
218
- if (guest !== undefined) {
219
- // Remember it: the page outlives our handle, and a later release must be able
220
- // to name exactly the tabs this plugin opened.
221
- this.orphaned.add(guest)
257
+ const entry = this.views.get(handle.id)
258
+ if (entry !== undefined) {
259
+ // Remember the page: it outlives our handle, and a later release must be able to name
260
+ // exactly the tabs this conversation's views opened. The owner comes along so a release
261
+ // naming one conversation does not sweep up another's.
262
+ if (entry.guest !== undefined) this.orphaned.set(entry.guest, entry.owner)
222
263
  this.views.delete(handle.id)
223
264
  }
224
265
  }
225
266
 
226
267
  /**
227
- * Nothing to show: the sidebar is already on screen, and showing it again is
228
- * the shell's business (it owns the tab strip and the visibility toggle).
268
+ * Bring this view's tab to the front in the sidebar.
269
+ *
270
+ * This used to be empty, on the reasoning that the sidebar is already on screen so there is
271
+ * nothing to show. That confused "the sidebar is visible" with "this page is the one being
272
+ * looked at": opening a page left it behind whatever the human had in front, which is issue
273
+ * #23. The other two carriers both do this — the self-hosted window re-adds the view and the
274
+ * system browser calls `Page.bringToFront` — so this carrier was the odd one out.
275
+ *
276
+ * Best-effort by design: the tab strip belongs to the shell's renderer, and failing to raise
277
+ * it must not fail the navigation that has already happened.
278
+ * @param handle - the view to bring forward.
229
279
  */
230
- showView(): void {}
280
+ showView(handle: ElectronViewHandle): void {
281
+ // `owner` is required: the bridge refuses to bring forward a guest held by another session.
282
+ // It comes from the view, not the process — with several conversations live, a process-wide
283
+ // owner would raise the wrong conversation's tab.
284
+ const owner = this.views.get(handle.id)?.owner ?? this.owner
285
+ void this.connection.call({ op: 'showTab', viewId: Number(handle.id), owner }, 5_000).catch(() => undefined)
286
+ }
231
287
 
232
- /** Same as {@link showView}: the page is already presented by the shell. */
233
- async presentView(): Promise<void> {}
234
288
 
235
- /**
236
- * The shell owns window grouping, and one sidebar serves every view, so there
237
- * is nothing to record here. (It must not write a placeholder guest id either:
238
- * a view whose guest is not yet materialized has to stay absent from the map,
239
- * or the first real command would be sent to the placeholder.)
240
- */
241
- groupView(): void {}
242
289
 
243
- /** Focus is the shell's to manage, and there is no separate window to raise. */
244
- async focus(): Promise<void> {}
245
290
 
246
291
  onUserAction(handler: (action: BrowserUserAction) => void): void {
247
292
  this.userActionHandler = handler
248
293
  }
249
294
 
250
- /** The shell reports its own window lifecycle; nothing to subscribe to here. */
251
- onViewClosed(): void {}
252
295
 
253
296
  /**
254
297
  * Release the sidebar's browser pages (requirements §3, `ui.closeWithSession`).
@@ -257,21 +300,38 @@ export class DesktopBridgeViewHost implements ElectronBrowserViewHost {
257
300
  * belongs to the shell and would happily keep the page (and its renderer) alive.
258
301
  * Closing the tabs is what actually ends the page — and only the page: cookies
259
302
  * live in the partition, history on disk, so both survive.
303
+ * @param owner - the conversation whose pages to close. Omit to close every conversation's,
304
+ * which is only correct from a teardown that is itself global.
260
305
  * @returns a promise that settles once the shell has been asked.
261
306
  */
262
- async releasePage(): Promise<void> {
263
- // Only our own tabs: the ids come from the views we handed out (and their
264
- // orphans), never from "every webview currently visible". With two sessions
265
- // live, releasing one must leave the other's page alone.
266
- const mine = [...this.views.values(), ...this.orphaned]
267
- this.views.clear()
268
- this.orphaned.clear()
307
+ async releasePage(owner?: string): Promise<void> {
308
+ // Only this conversation's tabs. The host is ONE instance shared by every conversation, so a
309
+ // release that named none would close pages other conversations are still driving — "every
310
+ // webview currently visible" is precisely what must never be used here.
311
+ const mine: number[] = []
312
+ for (const [viewId, entry] of [...this.views]) {
313
+ if (owner !== undefined && entry.owner !== owner) continue
314
+ if (entry.guest !== undefined) mine.push(entry.guest)
315
+ this.views.delete(viewId)
316
+ }
317
+ // A view destroyed before the release still owns its page; its guest is collected here.
318
+ for (const [guest, holder] of [...this.orphaned]) {
319
+ if (owner !== undefined && holder !== owner) continue
320
+ mine.push(guest)
321
+ this.orphaned.delete(guest)
322
+ }
269
323
  // Nothing of ours is open: release nothing. Falling through to an unfiltered
270
324
  // release here would close tabs a human opened, or another session's page.
271
325
  if (mine.length === 0) return
272
326
  let titles: string[]
273
327
  try {
274
- const answer = await this.connection.call({ op: 'list' }, 5_000)
328
+ // `owner` is REQUIRED here now that the bridge filters `list` by it. Without it the bridge
329
+ // falls back to a different owner, so the guests this session holds are filtered out of the
330
+ // answer, `mine.includes(...)` matches nothing, the title list comes back empty and this
331
+ // returns without closing anything — the page stayed open and the human had to close it by
332
+ // hand. Adding the ledger to the bridge without updating this call site is exactly the kind
333
+ // of half-change that made the ownership work take four rounds.
334
+ const answer = await this.connection.call({ op: 'list', owner: owner ?? this.owner }, 5_000)
275
335
  const sidebar = Array.isArray(answer.sidebar) ? answer.sidebar as Array<{ id?: unknown; title?: unknown }> : []
276
336
  titles = sidebar
277
337
  .filter(guest => mine.includes(Number(guest.id)))
@@ -284,7 +344,9 @@ export class DesktopBridgeViewHost implements ElectronBrowserViewHost {
284
344
  }
285
345
  if (titles.length === 0) return
286
346
  try {
287
- await this.connection.call({ op: 'closeSidebarBrowser', titles }, 10_000)
347
+ // `owner` is required for the same reason as above: the bridge closes only the guests the
348
+ // caller holds, so an owner-less call closes nothing and the page stays open.
349
+ await this.connection.call({ op: 'closeSidebarBrowser', titles, owner: owner ?? this.owner }, 10_000)
288
350
  } catch {
289
351
  // Best effort: the setting expresses a preference, and a shell that cannot
290
352
  // be reached is no reason to fail the session teardown that called us.
@@ -22,8 +22,10 @@ import { join } from 'node:path'
22
22
  import { defaultHostMainPath, RemoteElectronViewHost } from './remote-host.js'
23
23
  import { DesktopBridgeViewHost } from './desktop-bridge-host.js'
24
24
  import { detectBrowser, searchSummary, SystemBrowserViewHost } from './system-browser.js'
25
+ import { claimEphemeralProfile, ephemeralProfileName, sweepAbandonedEphemeralProfiles } from './ephemeral-profile.js'
25
26
  import { MissingSystemBrowserHost } from './missing-system-browser.js'
26
27
  import { SettingsStore } from './settings-store.js'
28
+ import { sameOrigin } from './settings-route.js'
27
29
 
28
30
  export {
29
31
  ELECTRON_BROWSER_PROVIDER_ID,
@@ -52,7 +54,7 @@ export interface Config {
52
54
  readonly downloadDir?: string
53
55
  /** Maximum snapshot elements before truncation. Default 60. */
54
56
  readonly snapshotMaxElements?: number
55
- /** Maximum content characters before truncation when no maxChars is given. Default 100_000. */
57
+ /** Maximum content characters before truncation when no maxChars is given. No longer read: the cap is per format (html and json 50 000, otherwise 20 000). */
56
58
  readonly contentMaxChars?: number
57
59
  }
58
60
 
@@ -65,8 +67,49 @@ export const Config: z<Config> = z.object({
65
67
  contentMaxChars: z.number(),
66
68
  })
67
69
 
70
+ /**
71
+ * This process's session id, read the way DSH's own desktop host reads it.
72
+ *
73
+ * `dsh-desktop-host/lib/index.js` does `ctx.get('agents')` then `agents.list()`, and uses
74
+ * `agent.id` directly as a sessionId. One conversation per process, so the list holds this one.
75
+ *
76
+ * Returns undefined rather than throwing when the service is absent — an older host, or a
77
+ * non-desktop composition. The caller falls back to a random id, which is what the bridge used
78
+ * before and is safe: it only means "this process", not "this conversation".
79
+ *
80
+ * @param ctx - the plugin context.
81
+ * @returns the session id, or undefined when it cannot be read.
82
+ */
83
+ function readSessionId(ctx: unknown): string | undefined {
84
+ try {
85
+ const surface = ctx as { get?: (name: string) => unknown }
86
+ if (typeof surface.get !== 'function') return undefined
87
+ const agents = surface.get('agents') as { list?: () => unknown } | undefined
88
+ const list = typeof agents?.list === 'function' ? agents.list() : undefined
89
+ if (!Array.isArray(list) || list.length === 0) return undefined
90
+ const first = list[0] as { id?: unknown } | undefined
91
+ return typeof first?.id === 'string' && first.id !== '' ? first.id : undefined
92
+ } catch {
93
+ return undefined
94
+ }
95
+ }
96
+
68
97
  /** Register the Electron browser provider with `ctx.browser`. */
69
98
  export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config): void {
99
+ // Which session is this process serving?
100
+ //
101
+ // The desktop shell keeps ONE sidebar per conversation — measured: two `[class*=_tabStrip]`
102
+ // containers sat in the DOM at once, each carrying its own session id on the React fiber, and
103
+ // the invisible one's webview was unloaded. So the bridge CAN tell which sidebar belongs to
104
+ // whom; what it lacked was the other half, this process's own identity. Without it, the bridge
105
+ // drove "whatever sidebar is on screen" and — measured on the running shell — typed into another
106
+ // conversation's address bar and navigated that page.
107
+ //
108
+ // The identity is available here, and DSH's own desktop host shows how
109
+ // (`dsh-desktop-host/lib/index.js`: `ctx.get('agents').list()`, with `agent.id` used directly as
110
+ // a sessionId). One conversation per process, so the list holds this one.
111
+ const sessionId = readSessionId(ctx)
112
+
70
113
  // One settings document per plugin instance: the settings panel writes it, the
71
114
  // provider reads it live, and both ends agree on the same file.
72
115
  const settings = new SettingsStore()
@@ -116,10 +159,27 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
116
159
  selfHosted.dispose()
117
160
  ctx.logger?.info?.(`dsh-builtin-browser: ${note}`)
118
161
  } catch (error) {
119
- // Never leave the surface without a provider.
120
- unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(
121
- new RemoteElectronViewHost(defaultHostMainPath()), providerConfig))
122
- ctx.logger?.warn?.(`dsh-builtin-browser: could not adopt ${note} (${String(error)})`)
162
+ // Never leave the surface without a provider. The half-built host is disposed FIRST: an
163
+ // adopt that got as far as constructing it may already hold a socket, a listener or a child
164
+ // process, and dropping it on the floor is how a failed upgrade becomes a leak that only
165
+ // shows up as a stray Electron in the task manager.
166
+ try { host.dispose?.() } catch { /* the fallback below matters more than this */ }
167
+ // The fallback registration gets its own guard, because THIS is the path taken when the
168
+ // context is already gone — and then `ctx.browser` throws `cannot get required service
169
+ // "browser" in inactive context`. Unguarded, that second throw escaped the async retry
170
+ // that calls `adopt`, became an unhandled rejection, and killed the host process with
171
+ // exit code 1: a failed upgrade, which should only ever cost a warning, took the whole
172
+ // session down instead. Observed 2026-10-05 (crash-…-host.log, phase: running).
173
+ try {
174
+ unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(
175
+ new RemoteElectronViewHost(defaultHostMainPath()), providerConfig))
176
+ ctx.logger?.warn?.(`dsh-builtin-browser: could not adopt ${note} (${String(error)})`)
177
+ } catch (fallbackError) {
178
+ ctx.logger?.warn?.(
179
+ `dsh-builtin-browser: could not adopt ${note} (${String(error)}), and the fallback `
180
+ + `registration also failed (${String(fallbackError)}) — no browser provider is `
181
+ + 'registered for this context')
182
+ }
123
183
  }
124
184
  }
125
185
 
@@ -137,8 +197,8 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
137
197
  // that choice real — otherwise a user who picked Chrome would silently get the sidebar,
138
198
  // and a user who picked a browser that is not installed would never see the explanation
139
199
  // written for exactly that case, because the sidebar would quietly replace it.
140
- const explicitChoice = channel !== 'bundled' && channel !== 'auto'
141
- if (channel !== 'bundled') {
200
+ const explicitChoice: boolean = channel !== 'bundled' && channel !== 'auto' && channel !== 'sidebar'
201
+ if (channel !== 'bundled' && channel !== 'sidebar') {
142
202
  const detected = detectBrowser(channel)
143
203
  if (detected === undefined) {
144
204
  // `auto` promises to take whatever is available, so falling back is what it
@@ -152,15 +212,29 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
152
212
  `the selected ${channel} is not installed — commands will explain how to fix it`)
153
213
  }
154
214
  } else {
155
- const home = process.env.DSH_HOME ?? homedir()
215
+ // Same profile root as settings-store, history-store and desktop-bridge-host: the
216
+ // `?.dsh` component used to be missing HERE only, so a desktop launched from a
217
+ // shortcut (no DSH_HOME in the environment) put its browser profile in
218
+ // `C:\Users\<user>\dsh-builtin-browser-host\` while its settings and history lived in
219
+ // `~/.dsh/dsh-builtin-browser-host/` — two half-profiles that never met, and a
220
+ // settings panel whose switches appeared not to apply.
221
+ const home = process.env.DSH_HOME ?? join(homedir(), '.dsh')
156
222
  // Login state lives in the browser profile, so the cookies setting decides where
157
223
  // that profile goes: a stable directory keeps the user signed in across restarts
158
224
  // (the point of using their own browser), while turning persistence off gets a
159
225
  // throwaway directory that is removed when the browser is released.
160
226
  const persist = settings.get().cookies.persist
227
+ const profileRoot = join(home, BROWSER_PROFILE_DIR)
161
228
  const profileDir = persist
162
- ? join(home, 'dsh-builtin-browser-host', `${detected.kind}-profile`)
163
- : join(home, 'dsh-builtin-browser-host', `${detected.kind}-profile-ephemeral-${randomUUID()}`)
229
+ ? join(profileRoot, `${detected.kind}-profile`)
230
+ : join(profileRoot, ephemeralProfileName(detected.kind, randomUUID()))
231
+ if (!persist) {
232
+ // A process killed outright never runs its own cleanup (see ephemeral-profile.ts),
233
+ // so the sweep happens HERE — at the only moment that can still act on a profile
234
+ // whose owner is gone — and before this run claims a directory of its own.
235
+ sweepAbandonedEphemeralProfiles(profileRoot, detected.kind)
236
+ claimEphemeralProfile(profileDir)
237
+ }
164
238
  adopt(new SystemBrowserViewHost(detected, profileDir, [], persist ? undefined : profileDir),
165
239
  `will drive the installed ${detected.kind} (${detected.path}${persist ? '' : ', ephemeral profile'})`)
166
240
  }
@@ -168,19 +242,57 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
168
242
 
169
243
  // An explicit choice was already adopted above; discovering the sidebar here would
170
244
  // replace it, which is exactly what this guard prevents.
171
- if (explicitChoice) return
245
+ //
246
+ // `sidebar` is explicit too, but it names the sidebar, so it is the one explicit choice that
247
+ // must FALL THROUGH to the discovery below instead of returning here.
248
+ if (explicitChoice && channel !== 'sidebar') return
172
249
 
173
- void DesktopBridgeViewHost.discover().then(sidebar => {
174
- // No bridge (plain `dsh web`, an older desktop build, or a shell that already
175
- // exited): keep self-hosting, which is what every surface has always done.
176
- if (sidebar === undefined) return
177
- adopt(sidebar, 'driving the desktop sidebar browser')
178
- }).catch(() => { /* discovery never throws; keep self-hosting */ })
250
+ // The shell may not be up yet. `dsh web` starts before — or entirely without — the desktop app
251
+ // that hosts the bridge, and a one-shot discovery meant a bridge that appeared a second later
252
+ // was never adopted for the rest of the process's life. Retry a handful of times over roughly
253
+ // half a minute, then stop: this is a convenience, not a promise, and the session the user is
254
+ // already driving in the self-hosted window is not worth replacing minutes into their work.
255
+ // `adopt` is itself one-shot (its `upgraded` guard), so the only possible effect of a retry
256
+ // that lands late is adopting a bridge that showed up just after startup.
257
+ void (async () => {
258
+ for (const delay of [0, 2_000, 5_000, 15_000]) {
259
+ if (delay > 0) await new Promise(resolve => setTimeout(resolve, delay))
260
+ let sidebar: Awaited<ReturnType<typeof DesktopBridgeViewHost.discover>>
261
+ try {
262
+ sidebar = await DesktopBridgeViewHost.discover(sessionId)
263
+ } catch {
264
+ sidebar = undefined // discovery is documented not to throw; never let it stop the retries
265
+ }
266
+ if (sidebar === undefined) continue
267
+ adopt(sidebar, 'driving the desktop sidebar browser')
268
+ return
269
+ }
270
+ // The user explicitly asked for the sidebar, so silence would leave them with a setting
271
+ // that did nothing and no way to tell why. This is reachable on `dsh web`, and on a desktop
272
+ // whose shell never published a bridge endpoint. An automatic attempt stays quiet (the
273
+ // self-hosted window is a fine outcome); an explicit request does not.
274
+ if (channel === 'sidebar') {
275
+ ctx.logger?.warn?.(
276
+ 'dsh-builtin-browser: the sidebar browser was requested but no desktop bridge was found — '
277
+ + 'this DSH has no desktop shell, or its bridge never started. Falling back to the bundled '
278
+ + 'browser; set browser.channel to another value to silence this.',
279
+ )
280
+ }
281
+ })()
179
282
  }
180
283
 
181
284
  /** Route the settings panel reads and writes. */
182
285
  const SETTINGS_ROUTE = '/dsh-builtin-browser/settings'
183
286
 
287
+ /**
288
+ * The directory under the DSH home that holds this plugin's browser profile.
289
+ *
290
+ * Named once, because every consumer of it must agree: `settingsPath()` and the history
291
+ * store resolve their own copy from `$DSH_HOME`, and a profile written under a different
292
+ * root than the settings document is a pair that silently never meets.
293
+ */
294
+ const BROWSER_PROFILE_DIR = 'dsh-builtin-browser-host'
295
+
184
296
  /** The host web-server surface this plugin uses, described structurally. */
185
297
  interface WebServerHost {
186
298
  readonly webServer: {
@@ -222,28 +334,9 @@ function installSettingsRoute(ctx: Context, settings: SettingsStore): void {
222
334
  }
223
335
  }
224
336
 
225
- /**
226
- * Same-origin guard. Settings are machine-local configuration, so a page loaded
227
- * elsewhere must not be able to read or rewrite them.
228
- * @param request - the incoming request.
229
- * @returns whether the request may touch the settings document.
230
- */
231
- function sameOrigin(request: IncomingMessage): boolean {
232
- const site = String(request.headers['sec-fetch-site'] ?? '').toLowerCase()
233
- if (site === 'cross-site') return false
234
- const origin = String(request.headers.origin ?? '').trim()
235
- if (origin === 'null') return false
236
- if (origin === '') return true
237
- try {
238
- return new URL(origin).host.toLowerCase() === String(request.headers.host ?? '').trim().toLowerCase()
239
- } catch {
240
- return false
241
- }
242
- }
243
-
244
337
  /**
245
338
  * One settings round-trip: `GET` reads the document, `PUT`/`POST` merges a
246
- * partial patch into it.
339
+ * partial patch into it. Admission lives in `settings-route.ts` so it can be tested.
247
340
  * @param request - the incoming request.
248
341
  * @param response - the response to write.
249
342
  * @param settings - the settings document.
@@ -260,11 +353,11 @@ async function handleSettingsRequest(
260
353
  })
261
354
  response.end(JSON.stringify(body))
262
355
  }
263
- if (!sameOrigin(request)) {
356
+ const method = request.method ?? 'GET'
357
+ if (!sameOrigin(request, method !== 'GET')) {
264
358
  json(403, { ok: false, error: 'cross-origin request refused' })
265
359
  return
266
360
  }
267
- const method = request.method ?? 'GET'
268
361
  if (method === 'GET') {
269
362
  json(200, { ok: true, settings: settings.get(), path: settings.path() })
270
363
  return