dsh-builtin-browser 0.4.2 → 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 (48) hide show
  1. package/CHANGELOG.md +1699 -0
  2. package/README.en.md +63 -20
  3. package/README.md +60 -20
  4. package/client.js +418 -24
  5. package/desktop-bridge/install.mjs +66 -6
  6. package/desktop-bridge/plugin-browser-bridge.js +414 -163
  7. package/docs/architecture.md +4 -4
  8. package/docs/tool-reference.md +3 -3
  9. package/docs/user-guide.md +50 -2
  10. package/lib/browser/types.d.ts +9 -2
  11. package/lib/browser-electron/bridge-connection.d.ts +18 -21
  12. package/lib/browser-electron/bridge-connection.js +42 -1
  13. package/lib/browser-electron/desktop-bridge-host.d.ts +69 -36
  14. package/lib/browser-electron/desktop-bridge-host.js +151 -86
  15. package/lib/browser-electron/entry.js +73 -6
  16. package/lib/browser-electron/history-store.d.ts +11 -2
  17. package/lib/browser-electron/history-store.js +27 -5
  18. package/lib/browser-electron/host-main.js +11 -1
  19. package/lib/browser-electron/provider.d.ts +62 -2
  20. package/lib/browser-electron/provider.js +28 -12
  21. package/lib/browser-electron/remote-host.js +19 -6
  22. package/lib/browser-electron/rpc-ops.d.ts +18 -0
  23. package/lib/browser-electron/rpc-ops.js +36 -0
  24. package/lib/browser-electron/settings-route.d.ts +6 -6
  25. package/lib/browser-electron/settings-route.js +48 -8
  26. package/lib/browser-electron/settings-store.js +57 -16
  27. package/lib/browser-electron/system-browser.d.ts +31 -9
  28. package/lib/browser-electron/system-browser.js +116 -27
  29. package/lib/index.js +0 -1
  30. package/lib/tool-browser/element-fields.d.ts +145 -0
  31. package/lib/tool-browser/element-fields.js +152 -0
  32. package/lib/tool-browser/index.js +26 -60
  33. package/package.json +6 -2
  34. package/src/browser/types.ts +9 -2
  35. package/src/browser-electron/bridge-connection.ts +37 -1
  36. package/src/browser-electron/desktop-bridge-host.ts +146 -84
  37. package/src/browser-electron/entry.ts +75 -7
  38. package/src/browser-electron/history-store.ts +32 -7
  39. package/src/browser-electron/host-main.ts +10 -1
  40. package/src/browser-electron/provider.ts +91 -18
  41. package/src/browser-electron/remote-host.ts +19 -6
  42. package/src/browser-electron/rpc-ops.ts +41 -0
  43. package/src/browser-electron/settings-route.ts +54 -13
  44. package/src/browser-electron/settings-store.ts +56 -17
  45. package/src/browser-electron/system-browser.ts +121 -28
  46. package/src/index.ts +3 -2
  47. package/src/tool-browser/element-fields.ts +167 -0
  48. package/src/tool-browser/index.ts +31 -60
@@ -67,8 +67,49 @@ export const Config: z<Config> = z.object({
67
67
  contentMaxChars: z.number(),
68
68
  })
69
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
+
70
97
  /** Register the Electron browser provider with `ctx.browser`. */
71
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
+
72
113
  // One settings document per plugin instance: the settings panel writes it, the
73
114
  // provider reads it live, and both ends agree on the same file.
74
115
  const settings = new SettingsStore()
@@ -123,9 +164,22 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
123
164
  // process, and dropping it on the floor is how a failed upgrade becomes a leak that only
124
165
  // shows up as a stray Electron in the task manager.
125
166
  try { host.dispose?.() } catch { /* the fallback below matters more than this */ }
126
- unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(
127
- new RemoteElectronViewHost(defaultHostMainPath()), providerConfig))
128
- ctx.logger?.warn?.(`dsh-builtin-browser: could not adopt ${note} (${String(error)})`)
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
+ }
129
183
  }
130
184
  }
131
185
 
@@ -143,8 +197,8 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
143
197
  // that choice real — otherwise a user who picked Chrome would silently get the sidebar,
144
198
  // and a user who picked a browser that is not installed would never see the explanation
145
199
  // written for exactly that case, because the sidebar would quietly replace it.
146
- const explicitChoice = channel !== 'bundled' && channel !== 'auto'
147
- if (channel !== 'bundled') {
200
+ const explicitChoice: boolean = channel !== 'bundled' && channel !== 'auto' && channel !== 'sidebar'
201
+ if (channel !== 'bundled' && channel !== 'sidebar') {
148
202
  const detected = detectBrowser(channel)
149
203
  if (detected === undefined) {
150
204
  // `auto` promises to take whatever is available, so falling back is what it
@@ -188,7 +242,10 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
188
242
 
189
243
  // An explicit choice was already adopted above; discovering the sidebar here would
190
244
  // replace it, which is exactly what this guard prevents.
191
- 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
192
249
 
193
250
  // The shell may not be up yet. `dsh web` starts before — or entirely without — the desktop app
194
251
  // that hosts the bridge, and a one-shot discovery meant a bridge that appeared a second later
@@ -202,7 +259,7 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
202
259
  if (delay > 0) await new Promise(resolve => setTimeout(resolve, delay))
203
260
  let sidebar: Awaited<ReturnType<typeof DesktopBridgeViewHost.discover>>
204
261
  try {
205
- sidebar = await DesktopBridgeViewHost.discover()
262
+ sidebar = await DesktopBridgeViewHost.discover(sessionId)
206
263
  } catch {
207
264
  sidebar = undefined // discovery is documented not to throw; never let it stop the retries
208
265
  }
@@ -210,6 +267,17 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
210
267
  adopt(sidebar, 'driving the desktop sidebar browser')
211
268
  return
212
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
+ }
213
281
  })()
214
282
  }
215
283
 
@@ -68,8 +68,8 @@ export class HistoryStore {
68
68
  * the common case a comparison.
69
69
  */
70
70
  private appendedSincePrune = 0
71
- private readonly maxEntries: number
72
- private readonly maxAgeMs: number
71
+ private maxEntries: number
72
+ private maxAgeMs: number
73
73
 
74
74
  /**
75
75
  * @param file - absolute path of the JSONL history file.
@@ -80,9 +80,24 @@ export class HistoryStore {
80
80
  limits: HistoryLimits = {},
81
81
  ) {
82
82
  this.maxEntries = limits.maxEntries ?? DEFAULT_MAX_ENTRIES
83
- // The first rewrite is due once the file has grown PRUNE_SLACK past the cap.
83
+ this.maxAgeMs = (limits.maxAgeDays ?? DEFAULT_MAX_AGE_DAYS) * 24 * 60 * 60 * 1000
84
+ // The first rewrite is due once the file has grown the slack past the cap.
84
85
  this.pruneThreshold = this.maxEntries + this.slack()
86
+ }
87
+
88
+ /**
89
+ * Re-read the retention limits.
90
+ *
91
+ * They are settings, and settings change while the plugin runs — a panel that let the
92
+ * operator edit them while nothing read the new values made the control a lie. Entry count
93
+ * and age are the pair the interface presents, so they are updated together.
94
+ * @param limits - the current retention overrides.
95
+ */
96
+ updateLimits(limits: HistoryLimits): void {
97
+ this.maxEntries = limits.maxEntries ?? DEFAULT_MAX_ENTRIES
85
98
  this.maxAgeMs = (limits.maxAgeDays ?? DEFAULT_MAX_AGE_DAYS) * 24 * 60 * 60 * 1000
99
+ // Recomputed rather than left alone: a smaller cap means the next rewrite is sooner.
100
+ this.pruneThreshold = Math.max(this.pruneThreshold, this.maxEntries + this.slack())
86
101
  }
87
102
 
88
103
  /** Whether the history file exists yet (diagnostics and tests). */
@@ -163,13 +178,23 @@ export class HistoryStore {
163
178
  const cutoff = Date.now() - this.maxAgeMs
164
179
  const fresh = all.filter(page => page.at >= cutoff)
165
180
  const kept = fresh.length > this.maxEntries ? fresh.slice(fresh.length - this.maxEntries) : fresh
166
- // Only rewrite when something actually fell off, and only once the excess
167
- // is worth a full rewrite.
168
- if (kept.length === all.length) return
181
+ // Every early exit has to retire the count it just spent, or the gate above stops gating.
182
+ // It counted appends so that the common "nothing to do" case is a comparison; leaving the
183
+ // counter set on the two paths that DO read the file meant it was never zero again, so
184
+ // every navigation paid the full parse this was written to avoid.
185
+ if (kept.length === all.length) {
186
+ this.appendedSincePrune = 0
187
+ return
188
+ }
169
189
  // Only rewrite once the file has grown PRUNE_SLACK past what we last wrote. Asking
170
190
  // "is the file over the cap?" instead was true from the first over-cap append
171
191
  // onwards, so every single navigation after that rewrote the whole file.
172
- if (all.length < this.pruneThreshold) return
192
+ if (all.length < this.pruneThreshold) {
193
+ // Deliberately NOT reset here: the count is what tracks progress toward the threshold,
194
+ // so clearing it would postpone the rewrite indefinitely. The read is the price of
195
+ // staying under the threshold, and the threshold is what bounds how often that happens.
196
+ return
197
+ }
173
198
  try {
174
199
  const body = kept.map(page => `${JSON.stringify(page)}\n`).join('')
175
200
  // Atomic, like the settings document. A plain writeFileSync truncates first, and this is
@@ -808,6 +808,7 @@ async function handle(op: string, msg: { id: number; viewId?: string; windowId?:
808
808
  try { win.window.restore() } catch { /* not minimized */ }
809
809
  win.window.focus()
810
810
  let base64 = ''
811
+ let renderedSize: { width: number; height: number } | undefined
811
812
  try {
812
813
  let image
813
814
  try {
@@ -832,6 +833,9 @@ async function handle(op: string, msg: { id: number; viewId?: string; windowId?:
832
833
  if (maxW > 0 && w > maxW) { h = Math.round(h * maxW / w); w = maxW }
833
834
  if (maxH > 0 && h > maxH) { w = Math.round(w * maxH / h); h = maxH }
834
835
  if (w > 0 && h > 0 && (w !== size.width || h !== size.height)) image = image.resize({ width: w, height: h })
836
+ // What the caller actually receives: after the downscale, not the captured size.
837
+ // Recorded here because this is the last point that knows both.
838
+ if (w > 0 && h > 0) renderedSize = { width: w, height: h }
835
839
  const buf = wantFormat === 'jpeg' ? image.toJPEG(wantQuality) : image.toPNG()
836
840
  if (wantFormat === 'jpeg') mime = 'image/jpeg'
837
841
  if (buf.length > 0) base64 = buf.toString('base64')
@@ -876,7 +880,12 @@ async function handle(op: string, msg: { id: number; viewId?: string; windowId?:
876
880
  if (base64 === '') {
877
881
  throw new Error('capture produced no image (view not painted)')
878
882
  }
879
- reply(msg.id, { ok: true, result: { base64, mime } })
883
+ // The measured size travels with the image. Without it the native path — which is the
884
+ // default self-hosted one — could never answer `browser_screenshot`'s width/height,
885
+ // because the seam's only channel was {base64, mime} and the provider had nothing to
886
+ // forward. The caller's `maxWidth`/`maxHeight` are applied here, so this is the only
887
+ // place that knows what came back.
888
+ reply(msg.id, { ok: true, result: { base64, mime, width: renderedSize?.width, height: renderedSize?.height } })
880
889
  return
881
890
  }
882
891
  case 'download': {
@@ -118,8 +118,13 @@ export interface ElectronBrowserViewHost {
118
118
  * Create a new browser view and return a handle to its webContents-like
119
119
  * surface. The host owns windowing (adding the view to the window, sizing,
120
120
  * removal); the provider owns CDP-driven behavior.
121
+ * @param owner - the conversation this view serves. A host shared by several conversations
122
+ * needs it to keep one conversation's page out of another's; a host with one conversation per
123
+ * process may ignore it. It is per VIEW and not per host on purpose: the desktop's host is a
124
+ * single instance that every conversation runs through, so an owner held there would be one
125
+ * conversation's id applied to all of them.
121
126
  */
122
- createView(): ElectronViewHandle
127
+ createView(owner?: string): ElectronViewHandle
123
128
  /**
124
129
  * Destroy a view created by this host. Called on session close; idempotent
125
130
  * for an already-destroyed view.
@@ -159,6 +164,18 @@ export interface ElectronBrowserViewHost {
159
164
  * of failing only at first use.
160
165
  */
161
166
  available?(): boolean
167
+ /**
168
+ * Tell the human that a toolbar action the agent could not complete has failed.
169
+ *
170
+ * Optional. Declared rather than reached through a cast because this is what issue #16 was:
171
+ * the call used to be unbound, so `this` inside the host's implementation was undefined and
172
+ * its first statement threw — inside an async catch, which turned it into an unhandled
173
+ * rejection that took the whole DSH process down. A declared member is checkable; a cast is
174
+ * not.
175
+ * @param windowId - the window the action came from.
176
+ * @param message - what to show the human.
177
+ */
178
+ notifyUserActionError?(windowId: string, message: string): void
162
179
  /**
163
180
  * Optional: associate a view with a window group. Views grouped under the
164
181
  * same `windowId` share one window (one window per browser session); a host
@@ -194,7 +211,12 @@ export interface ElectronBrowserViewHost {
194
211
  * (settings: ui.closeWithSession) has to be asked for explicitly there.
195
212
  * @returns a promise that settles once the release was attempted.
196
213
  */
197
- releasePage?(): Promise<void>
214
+ /**
215
+ * Release this carrier's browser pages for one conversation.
216
+ * @param owner - the conversation whose pages to close. Omit to close every conversation's,
217
+ * which is only correct from a teardown that is itself global.
218
+ */
219
+ releasePage?(owner?: string): Promise<void>
198
220
  /**
199
221
  * Fold the carrier's presentation away while keeping the page alive.
200
222
  *
@@ -257,6 +279,37 @@ export interface ElectronViewHandle {
257
279
  * then behaves exactly as before.
258
280
  */
259
281
  focus?(): Promise<void>
282
+ /**
283
+ * Save a URL straight to disk, when the backing view can do that itself.
284
+ *
285
+ * Optional. Declared here rather than reached through a structural cast at the call site:
286
+ * a cast tells the compiler nothing, so a host that implements the interface without this
287
+ * method compiles and then silently does nothing.
288
+ * @param url - the URL to fetch.
289
+ * @param savePath - where to write it.
290
+ */
291
+ download?(url: string, savePath: string): Promise<void>
292
+ /**
293
+ * Export this view's cookies. Optional; see {@link download} for why it is declared.
294
+ * @returns the cookies, scoped to the view's own page.
295
+ */
296
+ flushAuth?(): Promise<readonly ExportedCookie[]>
297
+ /**
298
+ * Import cookies into this view. Optional.
299
+ * @param cookies - the cookies to write.
300
+ * @returns how many were accepted.
301
+ */
302
+ restoreAuth?(cookies: readonly ExportedCookie[]): Promise<number>
303
+ /**
304
+ * Capture this view natively, returning the image and its measured size.
305
+ *
306
+ * Optional. The size is part of the contract because the caller's own tool schema declares
307
+ * `width`/`height`; a host that can measure the image should say so here rather than leaving
308
+ * the provider to guess.
309
+ * @param opts - format, quality and downscale bounds.
310
+ * @returns the encoded image and the pixel size the caller will receive.
311
+ */
312
+ capture?(opts?: ScreenshotOptions): Promise<{ base64: string; mime: string; width?: number; height?: number }>
260
313
  }
261
314
 
262
315
  /** One tab inside a session: its view plus a stable id. */
@@ -655,7 +708,10 @@ export class ElectronBrowserProvider implements BrowserProvider {
655
708
  * in the window title so a human can tell which task's page is visible.
656
709
  */
657
710
  open(label?: string): Promise<BrowserSessionId> {
658
- const handle = this.host.createView()
711
+ // The label is the calling DSH task, and it is handed to the view as its owner: a host shared
712
+ // by several conversations (the desktop's) keeps one conversation's page out of another's on
713
+ // the strength of it, so it is not decoration.
714
+ const handle = this.host.createView(label)
659
715
  const id = `browser:${randomUUID()}`
660
716
  // Group the session's views under its own window (one window per
661
717
  // session), carrying the label for the window title. Hosts without
@@ -2595,7 +2651,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
2595
2651
  // savePath admission: the shared gate — absolute, inside `downloadDir`,
2596
2652
  // and never an existing file. Screenshots use the very same one.
2597
2653
  const savePath = this.admitSavePath(request.savePath, 'download')
2598
- const downloadable = handle as { download?(url: string, savePath: string): Promise<void> }
2654
+ const downloadable = handle
2599
2655
  if (typeof downloadable.download !== 'function') {
2600
2656
  throw new BrowserError('browser: download is only available on the self-hosted browser', 'BROWSER_DOWNLOAD_UNSUPPORTED')
2601
2657
  }
@@ -2632,7 +2688,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
2632
2688
  const timeoutMs = 30_000
2633
2689
  // The self-hosted carrier has a native path (the child reads its own session), which
2634
2690
  // is worth preferring: it reports exactly what the browser persisted.
2635
- const native = handle as { flushAuth?(): Promise<readonly ExportedCookie[]> }
2691
+ const native = handle
2636
2692
  if (typeof native.flushAuth === 'function') {
2637
2693
  const cookies = await withTimeout(native.flushAuth(), timeoutMs, undefined, `browser: auth export timed out after ${timeoutMs}ms`)
2638
2694
  this.record(s, 'flushAuth', {}, true, { result: `${cookies.length} cookies` })
@@ -2684,7 +2740,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
2684
2740
  const s = this.session(session)
2685
2741
  const { handle } = this.activeTab(s)
2686
2742
  const timeoutMs = 30_000
2687
- const native = handle as { restoreAuth?(cookies: readonly ExportedCookie[]): Promise<number> }
2743
+ const native = handle
2688
2744
  if (typeof native.restoreAuth === 'function') {
2689
2745
  const restored = await withTimeout(native.restoreAuth(cookies), timeoutMs, undefined, `browser: auth restore timed out after ${timeoutMs}ms`)
2690
2746
  this.record(s, 'restoreAuth', { count: cookies.length }, true, { result: `${restored} cookies` })
@@ -2723,7 +2779,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
2723
2779
  session: BrowserSessionId,
2724
2780
  request?: BrowserScreenshotRequest,
2725
2781
  signal?: AbortSignal,
2726
- ): Promise<{ readonly dataUrl: string; readonly path?: string }> {
2782
+ ): Promise<{ readonly dataUrl: string; readonly path?: string; readonly width?: number; readonly height?: number }> {
2727
2783
  const s = this.session(session)
2728
2784
  const { handle } = this.activeTab(s)
2729
2785
  signal?.throwIfAborted()
@@ -2734,7 +2790,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
2734
2790
  // immediately (empty) for hidden ones. JPEG and downscaling are encoded
2735
2791
  // in the child from the NativeImage, so this path is the only one that
2736
2792
  // can produce JPEG (CDP JPEG hangs on Electron 43).
2737
- const capturable = handle as { capture?(opts?: ScreenshotOptions): Promise<{ base64: string; mime: string }> }
2793
+ const capturable = handle
2738
2794
  if (request?.fullPage !== true && typeof capturable.capture === 'function') {
2739
2795
  // Ensure the target view is the visible one before capturing.
2740
2796
  this.showActive(s)
@@ -2752,7 +2808,17 @@ export class ElectronBrowserProvider implements BrowserProvider {
2752
2808
  if (shot.base64 === '') {
2753
2809
  throw new BrowserError('browser: capture returned an empty image (view not painted); retry shortly', 'BROWSER_SCREENSHOT_FAILED')
2754
2810
  }
2755
- return this.saveScreenshot(shot.base64, request?.savePath, shot.mime ?? 'image/png')
2811
+ return this.saveScreenshot(
2812
+ shot.base64,
2813
+ request?.savePath,
2814
+ shot.mime ?? 'image/png',
2815
+ // The child measured these after applying any downscale, and they describe the image
2816
+ // the caller receives. Without them this path — the default self-hosted one — could
2817
+ // never answer the width/height its own tool schema declares.
2818
+ shot.width !== undefined && shot.height !== undefined
2819
+ ? { width: shot.width, height: shot.height }
2820
+ : undefined,
2821
+ )
2756
2822
  }
2757
2823
  // Fallback: a handle without a native capture() (the sidebar, or an installed Chrome
2758
2824
  // or Edge) uses CDP; full-page needs `captureBeyondViewport`, which capturePage lacks.
@@ -2887,8 +2953,14 @@ export class ElectronBrowserProvider implements BrowserProvider {
2887
2953
  const store = this.historyStore
2888
2954
  if (store === undefined) return
2889
2955
  // The settings panel can switch recording off at runtime; the static config
2890
- // only supplies the startup default.
2891
- if (this.settingsSource !== undefined && !this.settingsSource().history.enabled) return
2956
+ // only supplies the startup default. The RETENTION limits are read here too: they were
2957
+ // parsed and stored but never handed to the store, so editing maxEntries or maxAgeDays in
2958
+ // the file changed nothing — the switch worked and the numbers beside it did not.
2959
+ if (this.settingsSource !== undefined) {
2960
+ const live = this.settingsSource().history
2961
+ if (!live.enabled) return
2962
+ store.updateLimits({ maxEntries: live.maxEntries, maxAgeDays: live.maxAgeDays })
2963
+ }
2892
2964
  void handleSendEvaluate(handle, 'location.href + "\\u0000" + (document.title || "")')
2893
2965
  .then(result => {
2894
2966
  if (!result.ok || typeof result.value !== 'string') return
@@ -3015,7 +3087,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
3015
3087
  // the views above; the desktop's sidebar does not, so it is asked explicitly
3016
3088
  // — and its cookies plus the browsing history outlive the page either way.
3017
3089
  if (this.settingsSource !== undefined && this.settingsSource().ui.closeWithSession) {
3018
- void this.host.releasePage?.().catch(() => undefined)
3090
+ // Named, because one host serves every conversation: an unnamed release would end the
3091
+ // pages of conversations that are still running.
3092
+ void this.host.releasePage?.(existing.label).catch(() => undefined)
3019
3093
  }
3020
3094
  }
3021
3095
  return Promise.resolve()
@@ -3039,7 +3113,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
3039
3113
 
3040
3114
  /** Append a fresh tab and make it active. */
3041
3115
  private newTab(s: Session): void {
3042
- const handle = this.host.createView()
3116
+ const handle = this.host.createView(s.label)
3043
3117
  // Same window as the session's other tabs.
3044
3118
  this.host.groupView?.(handle, s.id, s.label)
3045
3119
  s.tabs.push({ id: `tab:${randomUUID()}`, handle })
@@ -3144,11 +3218,10 @@ export class ElectronBrowserProvider implements BrowserProvider {
3144
3218
  const message = `action ${action.type} failed: ${String(error)}`
3145
3219
  let reported = false
3146
3220
  try {
3147
- const host = this.host as { notifyUserActionError?(windowId: string, message: string): void }
3148
- const notify = host.notifyUserActionError
3149
- if (typeof notify === 'function') {
3150
- // Called on its owner: an unbound call here kills the host process.
3151
- notify.call(host, action.windowId, message)
3221
+ const notify = this.host.notifyUserActionError?.bind(this.host)
3222
+ if (notify !== undefined) {
3223
+ // Bound at the call: an unbound call here kills the host process (issue #16).
3224
+ notify(action.windowId, message)
3152
3225
  reported = true
3153
3226
  }
3154
3227
  } catch (notifyError) {
@@ -531,9 +531,11 @@ class ElectronChildClient {
531
531
  /**
532
532
  * Drop the escalation kill once there is nothing left for it to target.
533
533
  *
534
- * Without this the timer would keep the parent's event loop alive for its full grace period
535
- * after exactly the ordinary exit it was armed to catch as the bad case — and the child does
536
- * not need killing once `fail()` has run for it.
534
+ * Called on the paths where the child is known to be gone — after an observed exit, and on
535
+ * release. It is deliberately NOT called from `fail()`: a dead socket says nothing about the
536
+ * process on the other end, and the timer exists for exactly the case where the child ignores
537
+ * the socket closing. Clearing it there disarmed the only escalation there was, which is how
538
+ * a child that ignored its socket could outlive dispose() and keep holding the profile.
537
539
  */
538
540
  private clearKillGrace(): void {
539
541
  if (this.killGraceTimer === undefined) return
@@ -558,7 +560,11 @@ class ElectronChildClient {
558
560
  // kept a live timer until it fired (harmless, but it is a dangling timer in a process
559
561
  // that may be shutting down).
560
562
  this.clearHelloTimer()
561
- this.clearKillGrace()
563
+ // NOT clearKillGrace(): this path runs when the SOCKET died, which says nothing about
564
+ // whether the process did. Clearing the escalation here disarmed it for exactly the case it
565
+ // exists for — a child that ignores its socket closing — so dispose() could return with the
566
+ // child still running and still holding the profile. The timer clears itself when the child
567
+ // exits, and `kill()` replaces it on an explicit release.
562
568
  // Tag every failure as "host gone" so the view layer can tell a dead host
563
569
  // apart from a page-level error and rebuild against a fresh child.
564
570
  const wrapped = err instanceof HostGoneError ? err : new HostGoneError(err.message)
@@ -1297,10 +1303,17 @@ class DeferredRemoteView implements ElectronViewHandle {
1297
1303
  private materializeOnce(): Promise<RemoteView> {
1298
1304
  if (this.materialized === undefined) {
1299
1305
  const pending = this.materialize()
1300
- this.materialized = pending.catch(error => {
1301
- if (this.materialized === pending) this.materialized = undefined
1306
+ // Compare against the promise actually stored — the derived one — not against `pending`.
1307
+ // Storing `pending.catch(...)` and comparing `this.materialized === pending` compared two
1308
+ // different objects, so the test was never true and the reset never ran: a materialization
1309
+ // that failed with anything other than a HostGoneError (the child alive but not answering
1310
+ // a ping, say) poisoned the handle for good, and the retry the comment below promises was
1311
+ // unreachable.
1312
+ const guarded = pending.catch(error => {
1313
+ if (this.materialized === guarded) this.materialized = undefined
1302
1314
  throw error
1303
1315
  })
1316
+ this.materialized = guarded
1304
1317
  }
1305
1318
  return this.materialized
1306
1319
  }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The RPC operation vocabulary, in one place.
3
+ *
4
+ * The child (`host-main.ts`) switches on these names and the parent (`remote-host.ts`) sends
5
+ * them. Both spelled them out independently and nothing connected the two, so a rename on one
6
+ * side compiled fine and failed at run time as "unknown op" — and this pair has already
7
+ * produced one silent failure of that shape (`noteStartFailure`'s dedupe comparison, which was
8
+ * never true and meant a log line that never appeared).
9
+ *
10
+ * The lists are the source of truth for a test that reads both switch statements and asserts
11
+ * they agree, which is what makes a one-sided rename fail loudly instead of at run time.
12
+ */
13
+
14
+ /** Operations the parent asks the child to perform. */
15
+ export const RPC_OPS = [
16
+ 'ping',
17
+ 'createView',
18
+ 'destroyView',
19
+ 'showView',
20
+ 'groupView',
21
+ 'focus',
22
+ 'command',
23
+ 'capture',
24
+ 'download',
25
+ 'flushAuth',
26
+ 'restoreAuth',
27
+ 'navigate',
28
+ 'new-tab',
29
+ 'activate',
30
+ 'close',
31
+ 'back',
32
+ 'forward',
33
+ 'reload',
34
+ 'userActionError',
35
+ ] as const
36
+
37
+ /** Notifications the child sends the parent, which carry no reply. */
38
+ export const RPC_NOTIFICATIONS = ['userAction', 'viewClosed'] as const
39
+
40
+ /** One operation name. */
41
+ export type RpcOp = (typeof RPC_OPS)[number]
@@ -9,17 +9,34 @@
9
9
  * It is NOT authentication, and cannot be. The plugin has no credential to check: DSH hands
10
10
  * plugin routes the raw request (`packages/host/webserver/src/index.ts` registers handlers with
11
11
  * no gate of its own), so the strongest same-origin evidence available is what a browser
12
- * reliably sends — `Sec-Fetch-Site`, plus `Origin` on every write, same-origin included.
12
+ * reliably sends.
13
13
  *
14
14
  * What that buys and what it does not:
15
- * - a plain HTTP client (curl, a script, another process) cannot WRITE any more, because it
16
- * sends no `Origin`;
17
- * - a page of THIS origin still can, because a browser sending a same-origin write looks
18
- * exactly like the panel. That is inherent to exposing an HTTP endpoint at all, and it is
19
- * why the settings panel's own text must not promise a lock that does not exist.
15
+ * - a plain HTTP client (curl, a script, another process) cannot WRITE, because it sends no
16
+ * `user-agent` and no `accept-language`;
17
+ * - a page of THIS origin can, because a browser sending a same-origin write looks exactly
18
+ * like the panel. That is inherent to exposing an HTTP endpoint at all, and it is why the
19
+ * settings panel's own text must not promise a lock that does not exist.
20
20
  */
21
21
  import type { IncomingMessage } from 'node:http'
22
22
 
23
+ /** The shell's own URL schemes. Only the shell can mint a document on one of these. */
24
+ const APP_SCHEMES = ['dsh-app:', 'dsh-desktop:']
25
+
26
+ /**
27
+ * The scheme of a header value, when it names a URL.
28
+ * @param value - a raw header value.
29
+ * @returns the scheme, or undefined when the value is absent, opaque or unparseable.
30
+ */
31
+ function schemeOf(value: string): string | undefined {
32
+ if (value === '' || value === 'null') return undefined
33
+ try {
34
+ return new URL(value).protocol
35
+ } catch {
36
+ return undefined
37
+ }
38
+ }
39
+
23
40
  /**
24
41
  * Whether a request may touch the settings document.
25
42
  * @param request - the incoming request.
@@ -27,19 +44,43 @@ import type { IncomingMessage } from 'node:http'
27
44
  * @returns whether the request is admitted.
28
45
  */
29
46
  export function sameOrigin(request: IncomingMessage, writing = false): boolean {
47
+ const origin = String(request.headers.origin ?? '').trim()
30
48
  const site = String(request.headers['sec-fetch-site'] ?? '').toLowerCase()
49
+
50
+ // A shell URL, in either the Origin or the referrer: the strongest evidence there is.
51
+ if (APP_SCHEMES.includes(schemeOf(origin) ?? '')) return true
52
+ if (APP_SCHEMES.includes(schemeOf(String(request.headers.referer ?? '')) ?? '')) return true
53
+
54
+ // MEASURED on the running desktop, and what three earlier attempts each guessed wrong: a
55
+ // request from the shell's own settings panel carries NO origin, NO sec-fetch-site and NO
56
+ // referer. It is not "dsh-app://app" and it is not "null" — the headers are simply absent,
57
+ // because Chromium attaches no cross-origin metadata to a request leaving a custom scheme.
58
+ //
59
+ // So the rule for that shape cannot be "a request without an Origin is a script", which is
60
+ // what refused every desktop save, and it cannot lean on `Sec-Fetch-Site` either. It has to
61
+ // test what a hand-rolled client does not send:
62
+ // - a renderer sends a full Mozilla-compatible `user-agent` AND an `accept-language`;
63
+ // - curl sends neither unless told to, and a local process that forges both can also read
64
+ // the settings document directly — it sits readable on disk, as the panel's own text
65
+ // says. This was never the boundary it looked like.
66
+ if (origin === '') {
67
+ const agent = String(request.headers['user-agent'] ?? '')
68
+ const language = String(request.headers['accept-language'] ?? '')
69
+ if (/^Mozilla\/5\.0/.test(agent) && language !== '') return true
70
+ // Reads stay open: they return the document the panel is already showing, and refusing them
71
+ // would only break hand-inspection of a file that sits readable on disk anyway.
72
+ return !writing
73
+ }
74
+
31
75
  // `cross-site` is another origin entirely; `same-site` is another origin of the same site
32
76
  // (a.example.com -> b.example.com), which is not this panel either. Both are refused.
33
77
  if (site === 'cross-site' || site === 'same-site') return false
34
- const origin = String(request.headers.origin ?? '').trim()
35
- // `Origin: null` is what a sandboxed or opaque-origin document sends. Never this panel.
78
+ // `Origin: null` is a sandboxed or opaque-origin document. Never this panel.
36
79
  if (origin === 'null') return false
37
- // Reads are let through without an Origin: they return the document the panel is already
38
- // showing, and refusing them would only break hand-inspection of a file that sits readable
39
- // on disk anyway. Writes are not: a browser always sends `Origin` on one.
40
- if (origin === '') return !writing
41
80
  try {
42
- return new URL(origin).host.toLowerCase() === String(request.headers.host ?? '').trim().toLowerCase()
81
+ const parsed = new URL(origin)
82
+ // The host comparison is for http(s), where a page and its route share one host.
83
+ return parsed.host.toLowerCase() === String(request.headers.host ?? '').trim().toLowerCase()
43
84
  } catch {
44
85
  return false
45
86
  }