dsh-builtin-browser 0.4.1 → 0.4.2

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 (44) hide show
  1. package/README.en.md +61 -23
  2. package/README.md +59 -23
  3. package/client.js +60 -5
  4. package/cordis.patch.yml +21 -0
  5. package/desktop-bridge/plugin-browser-bridge.js +12 -4
  6. package/docs/architecture.md +12 -2
  7. package/docs/tool-reference.md +12 -12
  8. package/docs/user-guide.md +10 -3
  9. package/docs/why-browser.md +1 -1
  10. package/lib/browser/types.d.ts +25 -1
  11. package/lib/browser-electron/bridge-connection.d.ts +31 -5
  12. package/lib/browser-electron/bridge-connection.js +143 -21
  13. package/lib/browser-electron/entry.d.ts +1 -1
  14. package/lib/browser-electron/entry.js +61 -35
  15. package/lib/browser-electron/ephemeral-profile.d.ts +37 -0
  16. package/lib/browser-electron/ephemeral-profile.js +146 -0
  17. package/lib/browser-electron/history-store.d.ts +25 -0
  18. package/lib/browser-electron/history-store.js +51 -4
  19. package/lib/browser-electron/host-main.js +75 -27
  20. package/lib/browser-electron/provider.d.ts +44 -1
  21. package/lib/browser-electron/provider.js +379 -48
  22. package/lib/browser-electron/remote-host.d.ts +52 -3
  23. package/lib/browser-electron/remote-host.js +244 -23
  24. package/lib/browser-electron/settings-route.d.ts +28 -0
  25. package/lib/browser-electron/settings-route.js +28 -0
  26. package/lib/browser-electron/settings-store.d.ts +40 -0
  27. package/lib/browser-electron/settings-store.js +107 -11
  28. package/lib/browser-electron/system-browser.d.ts +71 -3
  29. package/lib/browser-electron/system-browser.js +311 -40
  30. package/lib/tool-browser/index.js +142 -33
  31. package/package.json +5 -3
  32. package/scripts/verify-host-compat.mjs +195 -0
  33. package/src/browser/types.ts +25 -1
  34. package/src/browser-electron/bridge-connection.ts +142 -21
  35. package/src/browser-electron/entry.ts +58 -33
  36. package/src/browser-electron/ephemeral-profile.ts +152 -0
  37. package/src/browser-electron/history-store.ts +51 -4
  38. package/src/browser-electron/host-main.ts +105 -35
  39. package/src/browser-electron/provider.ts +401 -51
  40. package/src/browser-electron/remote-host.ts +261 -36
  41. package/src/browser-electron/settings-route.ts +46 -0
  42. package/src/browser-electron/settings-store.ts +121 -11
  43. package/src/browser-electron/system-browser.ts +302 -26
  44. package/src/tool-browser/index.ts +143 -35
@@ -266,6 +266,14 @@ export interface BrowserWaitResult {
266
266
  export interface BrowserScreenshotResult {
267
267
  /** Base64 data URL of the capture (PNG or JPEG per the request). */
268
268
  readonly dataUrl: string
269
+ /** Pixel size of the image in `dataUrl`, when the capture was measured and scaled.
270
+ *
271
+ * Present when the request carried `maxWidth`/`maxHeight` and the layout metrics were
272
+ * readable: the scaling happens inside CDP through a clip, and without this the only way to
273
+ * learn what came back was to decode the data URL. Undefined means "not measured", which is
274
+ * also what an unscaled capture reports. */
275
+ readonly width?: number
276
+ readonly height?: number
269
277
  /** File path the capture was also saved to, when the request asked for it. */
270
278
  readonly path?: string
271
279
  }
@@ -396,6 +404,14 @@ export interface BrowserA11yNode {
396
404
  readonly depth: number
397
405
  /** Element tag name (lowercase). */
398
406
  readonly tag: string
407
+ /**
408
+ * A CSS selector for this node, when one can be derived from its id or name.
409
+ *
410
+ * Empty for the many elements that have neither. Present because a role and a name are not
411
+ * always enough to target a node: `{by: 'text'}` matches an accessible name only loosely,
412
+ * and this is what lets a caller point at the exact element it was shown.
413
+ */
414
+ readonly selector: string
399
415
  /** Viewport-relative center, for coordinate fallbacks. */
400
416
  readonly x: number
401
417
  readonly y: number
@@ -499,7 +515,15 @@ export interface BrowserProvider {
499
515
  /**
500
516
  * Open a new session. The provider mints the session id and prepares its
501
517
  * backing surface (a view, a headless page, and so on). Sessions are isolated from
502
- * each other; a session must not be visible to any other session's caller.
518
+ * each other in what they OWN — tabs, the active tab, history, the view — so one
519
+ * session must not be visible to any other session's caller.
520
+ *
521
+ * That isolation stops at the browser's own state, and is not meant to go further:
522
+ * cookies, logins and site storage are deliberately shared by every session, because
523
+ * every session drives the same browser and "sign in once, use it everywhere" is the
524
+ * point of using a real one. Reads that hand that state to a caller (see
525
+ * `flushAuth`) are scoped to the page in question, which is least exposure rather
526
+ * than a claim that the jar is per-session.
503
527
  * @param label - optional human-readable label (e.g. the calling DSH task
504
528
  * id) the provider may surface on the visible surface (window title) so a
505
529
  * human can tell which task's page is currently shown.
@@ -34,17 +34,49 @@ interface PendingCall {
34
34
  readonly reject: (error: Error) => void
35
35
  }
36
36
 
37
+ /**
38
+ * Safety cap on one un-answered reply line. CDP answers from the sidebar (a screenshot's
39
+ * base64 is the big one) cross this socket, so the cap is generous; what it exists for is a
40
+ * peer that never sends a newline — `this.buffer += chunk` grew without bound, unlike the
41
+ * same buffer in remote-host.ts, which caps at 512 MiB.
42
+ */
43
+ const MAX_BUFFER_BYTES = 128 * 1024 * 1024
44
+
45
+ /**
46
+ * Field this client adds to every request so the shell can echo it back and never relies on
47
+ * byte order to pair an answer with its request. Loosely named, and additive on purpose: a
48
+ * bridge that does not know it simply ignores it (the current shipped shell does), and the
49
+ * client then falls back to arrival order, which is what the protocol guaranteed before.
50
+ */
51
+ const REQUEST_ID_FIELD = 'bridgeRequestId'
52
+
37
53
  /**
38
54
  * A reusable connection to the bridge.
39
55
  *
40
- * Requests are serialised by construction: one socket, one in-flight request at a
41
- * time, answered in order. A broken socket is discarded so the next call dials
42
- * again — a dead connection must never become a dead plugin.
56
+ * Requests may be in flight CONCURRENTLY, so answers are correlated by id rather than by
57
+ * position: `call()` stamps a unique {@link REQUEST_ID_FIELD} on the request and `onData()`
58
+ * resolves the entry that carries the echoed id. A bridge that does not echo it is still
59
+ * supported — its answer is handed to the oldest outstanding request, which is the
60
+ * arrival-order rule the protocol states. Once such an answer has been seen, the connection
61
+ * stops keeping two requests in flight (a second one re-dials), so position can never be the
62
+ * only thing distinguishing two callers' answers. An answer that matches nothing is never
63
+ * handed to whichever request happens to be first: the socket is dropped instead.
64
+ *
65
+ * A broken socket is discarded so the next call dials again — a dead connection must never
66
+ * become a dead plugin.
43
67
  */
44
68
  export class BridgeConnection {
45
69
  private socket: Socket | undefined
46
70
  private buffer = ''
47
- private readonly queue: PendingCall[] = []
71
+ /** Outstanding requests by id. */
72
+ private readonly pending = new Map<number, PendingCall>()
73
+ /** Ids of the requests sent on the current socket, oldest first. */
74
+ private readonly queue: number[] = []
75
+ private nextId = 1
76
+ /** True once any reply arrived without an id: this bridge does not echo request ids. */
77
+ private idLessBridge = false
78
+ /** True once this connection may only have one request in flight at a time. */
79
+ private serialisable = false
48
80
 
49
81
  /**
50
82
  * @param endpoint - the shell's published bridge endpoint.
@@ -53,31 +85,63 @@ export class BridgeConnection {
53
85
 
54
86
  /**
55
87
  * Send one request, reusing the socket if it is still healthy.
56
- * @param request - the request body (the token is added here).
88
+ *
89
+ * Requests may overlap: the request is written as soon as it is queued, so two concurrent
90
+ * callers have two requests in flight. Each carries a unique {@link REQUEST_ID_FIELD}, and
91
+ * `onData()` resolves the entry that carries the echoed id — which is what makes
92
+ * correlation independent of arrival order. A bridge that does not echo the field (the
93
+ * shipped shell today) still works by arrival order; see {@link onData}.
94
+ * @param request - the request body (the token and the request id are added here).
57
95
  * @param timeoutMs - how long to wait for the answer before dropping the socket.
58
96
  * @returns the bridge's answer.
59
97
  */
60
98
  async call(request: Record<string, unknown>, timeoutMs = 20_000): Promise<Record<string, unknown>> {
61
- const socket = this.ensureSocket()
99
+ // One re-dial, and only for the case this exists for: a socket that has already carried
100
+ // an id-less answer cannot have a second request in flight, because two outstanding
101
+ // requests would be matched by position — the swap this correlation prevents. It is a
102
+ // straight-line re-dial rather than a retry loop: an earlier revision re-entered call()
103
+ // from inside its own promise executor, which left an orphaned pending entry behind and,
104
+ // when the condition could not clear, spun until the process ran out of heap.
105
+ let socket = this.ensureSocket()
106
+ if (this.serialisable && this.queue.length > 0) {
107
+ this.reset(new Error('dsh-builtin-browser: bridge connection reopened so replies stay attributable'))
108
+ socket = this.ensureSocket()
109
+ }
110
+ const id = this.nextId++
62
111
  return await new Promise<Record<string, unknown>>((resolve, reject) => {
112
+ this.pending.set(id, {
113
+ resolve: answer => { clearTimeout(timer); resolve(answer) },
114
+ reject: error => { clearTimeout(timer); reject(error) },
115
+ })
116
+ this.queue.push(id)
63
117
  const timer = setTimeout(() => {
64
- const index = this.queue.findIndex(entry => entry.resolve === resolve)
65
- if (index !== -1) this.queue.splice(index, 1)
66
118
  const error = new Error(`dsh-builtin-browser: bridge timed out after ${timeoutMs}ms`)
67
- this.reset(error)
119
+ // An unanswered request desynchronises the stream for an id-less bridge, and leaves one
120
+ // outstanding for one that echoes ids — either way the next call should start on a
121
+ // fresh socket. Only when THIS request's socket is still the live one: tearing down the
122
+ // REPLACEMENT because an abandoned request timed out would break whatever is using it.
123
+ //
124
+ // This test used to read `this.pending.has(id) && this.socket === socket`, with
125
+ // `forget(id)` on the line above it — and forget() is what removes the entry that `has`
126
+ // was looking for, so the condition was false every single time and the rebuild never
127
+ // ran. A timed-out call therefore left the desynchronised socket in place, and every
128
+ // later call inherited exactly the ambiguity this reset exists to clear. Deciding
129
+ // before forgetting is the whole fix; the `else` still drops the stale entry.
130
+ if (this.socket === socket) this.reset(error)
131
+ else this.forget(id)
132
+ // reset() already rejected this entry through its wrapper when it ran, so this is
133
+ // either the only rejection or a no-op that guarantees the caller settles.
68
134
  reject(error)
69
135
  }, timeoutMs)
70
- this.queue.push({
71
- resolve: answer => { clearTimeout(timer); resolve(answer) },
72
- reject: error => { clearTimeout(timer); reject(error) },
73
- })
74
136
  try {
75
- socket.write(JSON.stringify({ token: this.endpoint.token, ...request }) + '\n')
137
+ // The id is added LAST so a request body can never shadow it, and the token is
138
+ // added here rather than by the caller.
139
+ socket.write(JSON.stringify({ token: this.endpoint.token, ...request, [REQUEST_ID_FIELD]: id }) + '\n')
76
140
  } catch (error) {
77
141
  clearTimeout(timer)
78
- this.queue.pop()
142
+ this.forget(id)
79
143
  const failure = error instanceof Error ? error : new Error(String(error))
80
- this.reset(failure)
144
+ if (this.socket === socket) this.reset(failure)
81
145
  reject(failure)
82
146
  }
83
147
  })
@@ -94,25 +158,60 @@ export class BridgeConnection {
94
158
  const socket = connect({ host: '127.0.0.1', port: this.endpoint.port })
95
159
  socket.setEncoding('utf8')
96
160
  socket.on('data', chunk => this.onData(String(chunk)))
97
- socket.on('error', error => this.reset(error))
98
- socket.on('close', () => this.reset(new Error('dsh-builtin-browser: bridge connection closed')))
161
+ // Both handlers pass the socket they belong to, so a late event from a socket that was
162
+ // already replaced cannot tear down its successor: 'close' still fires for a socket that
163
+ // was removed mid-recycle, and acting on it there would kill a perfectly good connection.
164
+ socket.on('error', error => this.socketFailed(socket, error))
165
+ socket.on('close', () => this.socketFailed(socket, new Error('dsh-builtin-browser: bridge connection closed')))
99
166
  this.buffer = ''
100
167
  this.socket = socket
101
168
  socket.write(JSON.stringify({ token: this.endpoint.token }) + '\n')
102
169
  return socket
103
170
  }
104
171
 
105
- /** Resolve queued requests in order as answers arrive. */
172
+ /** Tear the connection down for an event raised by `source`, unless it is already gone. */
173
+ private socketFailed(source: Socket, error: Error): void {
174
+ if (this.socket !== source) return
175
+ this.reset(error)
176
+ }
177
+
178
+ /** Resolve queued requests by id, or in order when the bridge does not echo one. */
106
179
  private onData(chunk: string): void {
107
180
  this.buffer += chunk
181
+ // A peer that never sends a newline must not grow this buffer without bound. Failing
182
+ // the connection is the honest outcome: the caller gets an error naming the cap, while
183
+ // the next call dials a fresh socket.
184
+ if (this.buffer.length > MAX_BUFFER_BYTES) {
185
+ this.buffer = ''
186
+ this.reset(new Error(`dsh-builtin-browser: bridge reply exceeded ${String(MAX_BUFFER_BYTES)} bytes`))
187
+ return
188
+ }
108
189
  let newline = this.buffer.indexOf('\n')
109
190
  while (newline !== -1) {
110
191
  const line = this.buffer.slice(0, newline).trim()
111
192
  this.buffer = this.buffer.slice(newline + 1)
112
193
  newline = this.buffer.indexOf('\n')
113
194
  if (line === '') continue
114
- const entry = this.queue.shift()
195
+ const echoed = echoOf(line)
196
+ const id = echoed ?? this.queue[0]
197
+ if (id === undefined) {
198
+ // Nothing is waiting for this answer: the peer is answering something this socket
199
+ // never asked (or answered twice). Drop the socket rather than trust the stream.
200
+ this.reset(new Error('dsh-builtin-browser: bridge answered with no request outstanding'))
201
+ return
202
+ }
203
+ const index = this.queue.indexOf(id)
204
+ if (index !== -1) this.queue.splice(index, 1)
205
+ const entry = this.pending.get(id)
115
206
  if (entry === undefined) continue
207
+ this.pending.delete(id)
208
+ // Id-less bridges pair by order, so a reply for the WRONG request cannot be told apart
209
+ // from the right one. Remember that this bridge does not echo ids, and serialise every
210
+ // later call so the ambiguity cannot arise again.
211
+ if (echoed === undefined) {
212
+ this.idLessBridge = true
213
+ this.serialisable = true
214
+ }
116
215
  try {
117
216
  const answer = JSON.parse(line) as Record<string, unknown>
118
217
  // A rejected token means the shell restarted with a new one, so this socket
@@ -130,6 +229,13 @@ export class BridgeConnection {
130
229
  }
131
230
  }
132
231
 
232
+ /** Forget one request (its answer can no longer be matched). */
233
+ private forget(id: number): void {
234
+ this.pending.delete(id)
235
+ const index = this.queue.indexOf(id)
236
+ if (index !== -1) this.queue.splice(index, 1)
237
+ }
238
+
133
239
  /** Tear the socket down, failing pending work. */
134
240
  private reset(error: Error): void {
135
241
  if (this.socket !== undefined) {
@@ -139,7 +245,22 @@ export class BridgeConnection {
139
245
  socket.destroy()
140
246
  }
141
247
  this.buffer = ''
142
- const pending = this.queue.splice(0, this.queue.length)
248
+ this.queue.length = 0
249
+ const pending = [...this.pending.values()]
250
+ this.pending.clear()
143
251
  for (const entry of pending) entry.reject(error)
144
252
  }
145
253
  }
254
+
255
+ /** The request id a raw answer line echoes back, when it carries one. */
256
+ function echoOf(line: string): number | undefined {
257
+ try {
258
+ const answer = JSON.parse(line) as Record<string, unknown>
259
+ const value = answer[REQUEST_ID_FIELD]
260
+ return typeof value === 'number' && Number.isSafeInteger(value) ? value : undefined
261
+ } catch {
262
+ // A malformed line cannot be matched by id; the caller consumes it in order and
263
+ // reports the parse failure itself.
264
+ return undefined
265
+ }
266
+ }
@@ -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
 
@@ -116,7 +118,11 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
116
118
  selfHosted.dispose()
117
119
  ctx.logger?.info?.(`dsh-builtin-browser: ${note}`)
118
120
  } catch (error) {
119
- // Never leave the surface without a provider.
121
+ // Never leave the surface without a provider. The half-built host is disposed FIRST: an
122
+ // adopt that got as far as constructing it may already hold a socket, a listener or a child
123
+ // process, and dropping it on the floor is how a failed upgrade becomes a leak that only
124
+ // shows up as a stray Electron in the task manager.
125
+ try { host.dispose?.() } catch { /* the fallback below matters more than this */ }
120
126
  unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(
121
127
  new RemoteElectronViewHost(defaultHostMainPath()), providerConfig))
122
128
  ctx.logger?.warn?.(`dsh-builtin-browser: could not adopt ${note} (${String(error)})`)
@@ -152,15 +158,29 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
152
158
  `the selected ${channel} is not installed — commands will explain how to fix it`)
153
159
  }
154
160
  } else {
155
- const home = process.env.DSH_HOME ?? homedir()
161
+ // Same profile root as settings-store, history-store and desktop-bridge-host: the
162
+ // `?.dsh` component used to be missing HERE only, so a desktop launched from a
163
+ // shortcut (no DSH_HOME in the environment) put its browser profile in
164
+ // `C:\Users\<user>\dsh-builtin-browser-host\` while its settings and history lived in
165
+ // `~/.dsh/dsh-builtin-browser-host/` — two half-profiles that never met, and a
166
+ // settings panel whose switches appeared not to apply.
167
+ const home = process.env.DSH_HOME ?? join(homedir(), '.dsh')
156
168
  // Login state lives in the browser profile, so the cookies setting decides where
157
169
  // that profile goes: a stable directory keeps the user signed in across restarts
158
170
  // (the point of using their own browser), while turning persistence off gets a
159
171
  // throwaway directory that is removed when the browser is released.
160
172
  const persist = settings.get().cookies.persist
173
+ const profileRoot = join(home, BROWSER_PROFILE_DIR)
161
174
  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()}`)
175
+ ? join(profileRoot, `${detected.kind}-profile`)
176
+ : join(profileRoot, ephemeralProfileName(detected.kind, randomUUID()))
177
+ if (!persist) {
178
+ // A process killed outright never runs its own cleanup (see ephemeral-profile.ts),
179
+ // so the sweep happens HERE — at the only moment that can still act on a profile
180
+ // whose owner is gone — and before this run claims a directory of its own.
181
+ sweepAbandonedEphemeralProfiles(profileRoot, detected.kind)
182
+ claimEphemeralProfile(profileDir)
183
+ }
164
184
  adopt(new SystemBrowserViewHost(detected, profileDir, [], persist ? undefined : profileDir),
165
185
  `will drive the installed ${detected.kind} (${detected.path}${persist ? '' : ', ephemeral profile'})`)
166
186
  }
@@ -170,17 +190,41 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
170
190
  // replace it, which is exactly what this guard prevents.
171
191
  if (explicitChoice) return
172
192
 
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 */ })
193
+ // The shell may not be up yet. `dsh web` starts before — or entirely without — the desktop app
194
+ // that hosts the bridge, and a one-shot discovery meant a bridge that appeared a second later
195
+ // was never adopted for the rest of the process's life. Retry a handful of times over roughly
196
+ // half a minute, then stop: this is a convenience, not a promise, and the session the user is
197
+ // already driving in the self-hosted window is not worth replacing minutes into their work.
198
+ // `adopt` is itself one-shot (its `upgraded` guard), so the only possible effect of a retry
199
+ // that lands late is adopting a bridge that showed up just after startup.
200
+ void (async () => {
201
+ for (const delay of [0, 2_000, 5_000, 15_000]) {
202
+ if (delay > 0) await new Promise(resolve => setTimeout(resolve, delay))
203
+ let sidebar: Awaited<ReturnType<typeof DesktopBridgeViewHost.discover>>
204
+ try {
205
+ sidebar = await DesktopBridgeViewHost.discover()
206
+ } catch {
207
+ sidebar = undefined // discovery is documented not to throw; never let it stop the retries
208
+ }
209
+ if (sidebar === undefined) continue
210
+ adopt(sidebar, 'driving the desktop sidebar browser')
211
+ return
212
+ }
213
+ })()
179
214
  }
180
215
 
181
216
  /** Route the settings panel reads and writes. */
182
217
  const SETTINGS_ROUTE = '/dsh-builtin-browser/settings'
183
218
 
219
+ /**
220
+ * The directory under the DSH home that holds this plugin's browser profile.
221
+ *
222
+ * Named once, because every consumer of it must agree: `settingsPath()` and the history
223
+ * store resolve their own copy from `$DSH_HOME`, and a profile written under a different
224
+ * root than the settings document is a pair that silently never meets.
225
+ */
226
+ const BROWSER_PROFILE_DIR = 'dsh-builtin-browser-host'
227
+
184
228
  /** The host web-server surface this plugin uses, described structurally. */
185
229
  interface WebServerHost {
186
230
  readonly webServer: {
@@ -222,28 +266,9 @@ function installSettingsRoute(ctx: Context, settings: SettingsStore): void {
222
266
  }
223
267
  }
224
268
 
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
269
  /**
245
270
  * One settings round-trip: `GET` reads the document, `PUT`/`POST` merges a
246
- * partial patch into it.
271
+ * partial patch into it. Admission lives in `settings-route.ts` so it can be tested.
247
272
  * @param request - the incoming request.
248
273
  * @param response - the response to write.
249
274
  * @param settings - the settings document.
@@ -260,11 +285,11 @@ async function handleSettingsRequest(
260
285
  })
261
286
  response.end(JSON.stringify(body))
262
287
  }
263
- if (!sameOrigin(request)) {
288
+ const method = request.method ?? 'GET'
289
+ if (!sameOrigin(request, method !== 'GET')) {
264
290
  json(403, { ok: false, error: 'cross-origin request refused' })
265
291
  return
266
292
  }
267
- const method = request.method ?? 'GET'
268
293
  if (method === 'GET') {
269
294
  json(200, { ok: true, settings: settings.get(), path: settings.path() })
270
295
  return
@@ -0,0 +1,152 @@
1
+ /**
2
+ * The ephemeral browser profile: a throwaway directory under the DSH home, used when the
3
+ * user has turned profile persistence off, and deleted when the browser is released.
4
+ *
5
+ * Deleting on release is not enough by itself. A process killed outright — a DSH restart
6
+ * that ends in SIGKILL, "End task" in a task manager, a crashed host — never runs its
7
+ * cleanup, so the profile it was using survives on disk: the user asked for no profile to
8
+ * be kept and the machine keeps one anyway, holding cookies and history. Nothing can run
9
+ * at SIGKILL time, so the cleanup has to happen on the NEXT run instead, which is what
10
+ * this module provides.
11
+ *
12
+ * Ownership is a marker file inside the directory naming the process that claimed it. A
13
+ * directory whose owner is gone AND which has not been written to for a while is
14
+ * abandoned. The age check is what keeps the sweep away from a profile an orphaned
15
+ * browser (a child that outlived the parent which was killed) may still be using: a live
16
+ * Chromium writes to its profile continuously, so a directory that has not changed in an
17
+ * hour is not in use.
18
+ */
19
+ import { mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
20
+ import { join } from 'node:path'
21
+
22
+ /** The marker file, named once because both the claim and the sweep read it. */
23
+ const OWNER_FILE = '.dsh-ephemeral-owner'
24
+
25
+ /**
26
+ * How long a profile is left alone regardless of ownership. An orphaned browser keeps
27
+ * writing to its profile, so this is what distinguishes "abandoned" from "in use by a
28
+ * process this one did not start".
29
+ */
30
+ const ABANDONED_AFTER_MS = 60 * 60 * 1000
31
+
32
+ /** Test seams; production passes neither. */
33
+ export interface SweepOptions {
34
+ /** The clock, for the age check. */
35
+ readonly now?: number
36
+ /** Liveness test for an owner pid. */
37
+ readonly alive?: (pid: number) => boolean
38
+ }
39
+
40
+ /**
41
+ * Whether a process with this pid exists.
42
+ *
43
+ * A signal of 0 performs the permission and existence checks without delivering anything.
44
+ * EPERM is a live process owned by someone else — alive, not gone.
45
+ * @param pid - the pid to test.
46
+ * @returns true when the process exists.
47
+ */
48
+ function processAlive(pid: number): boolean {
49
+ try {
50
+ process.kill(pid, 0)
51
+ return true
52
+ } catch (error) {
53
+ return (error as NodeJS.ErrnoException).code === 'EPERM'
54
+ }
55
+ }
56
+
57
+ /**
58
+ * The directory name an ephemeral profile for `kind` gets, before it is claimed.
59
+ *
60
+ * The sweep matches on the same shape, so the two must agree: a name built anywhere else
61
+ * is a directory the sweep will never find.
62
+ * @param kind - the detected browser product (`chrome`, `edge`, ...).
63
+ * @param id - a per-run unique suffix.
64
+ * @returns the directory NAME, not a path.
65
+ */
66
+ export function ephemeralProfileName(kind: string, id: string): string {
67
+ return `${kind}-profile-ephemeral-${id}`
68
+ }
69
+
70
+ /**
71
+ * Mark an ephemeral profile directory as this process's, so a later run can tell whether
72
+ * the process that created it is still there.
73
+ * @param dir - the profile directory (created if it does not exist yet).
74
+ * @param pid - the claiming process, defaulting to this one.
75
+ * @param now - the claim time, defaulting to the current clock.
76
+ * @returns true when the marker was written.
77
+ */
78
+ export function claimEphemeralProfile(dir: string, pid: number = process.pid, now: number = Date.now()): boolean {
79
+ try {
80
+ mkdirSync(dir, { recursive: true })
81
+ writeFileSync(join(dir, OWNER_FILE), JSON.stringify({ pid, at: now }))
82
+ return true
83
+ } catch {
84
+ // The browser creates the directory itself when this fails, and cleanup is best
85
+ // effort throughout: a marker that could not be written must not stop the launch.
86
+ return false
87
+ }
88
+ }
89
+
90
+ /**
91
+ * The claimed owner of an ephemeral profile, when it has one.
92
+ * @param dir - the profile directory.
93
+ * @returns the owner pid, or undefined when there is no readable marker.
94
+ */
95
+ function readOwner(dir: string): number | undefined {
96
+ try {
97
+ const parsed = JSON.parse(readFileSync(join(dir, OWNER_FILE), 'utf8')) as { pid?: unknown }
98
+ return typeof parsed.pid === 'number' ? parsed.pid : undefined
99
+ } catch {
100
+ // No marker: a run from before this existed, or one killed before it wrote one. The
101
+ // age check has already applied, and a directory with no claimant that old is
102
+ // abandoned by any reading.
103
+ return undefined
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Delete the ephemeral profiles under `root` that belong to processes which are gone.
109
+ *
110
+ * Called before a new one is claimed, because that is the only moment that can still act
111
+ * on a profile whose owner was killed: nothing runs when the signal lands.
112
+ * @param root - the plugin's profile root (`<DSH_HOME>/dsh-builtin-browser-host`).
113
+ * @param kind - the browser product, which selects the directory-name prefix.
114
+ * @param options - test seams for the clock and the liveness test.
115
+ * @returns the names of the directories that were removed.
116
+ */
117
+ export function sweepAbandonedEphemeralProfiles(root: string, kind: string, options: SweepOptions = {}): string[] {
118
+ const now = options.now ?? Date.now()
119
+ const alive = options.alive ?? processAlive
120
+ const prefix = `${kind}-profile-ephemeral-`
121
+ const removed: string[] = []
122
+ let names: string[]
123
+ try {
124
+ names = readdirSync(root)
125
+ } catch {
126
+ // No profile root yet: nothing has ever been written here, so nothing to sweep.
127
+ return removed
128
+ }
129
+ for (const name of names) {
130
+ if (!name.startsWith(prefix)) continue
131
+ const dir = join(root, name)
132
+ let ageMs: number
133
+ try {
134
+ ageMs = now - statSync(dir).mtimeMs
135
+ } catch {
136
+ continue
137
+ }
138
+ // Recent writes mean a browser is still working in there. A killed DSH can leave its
139
+ // Chromium child running, and that child's profile is not garbage.
140
+ if (ageMs < ABANDONED_AFTER_MS) continue
141
+ const owner = readOwner(dir)
142
+ if (owner !== undefined && alive(owner)) continue
143
+ try {
144
+ rmSync(dir, { recursive: true, force: true })
145
+ removed.push(name)
146
+ } catch {
147
+ // In use, or not ours to remove. Leaving it is the safe outcome and the next run
148
+ // tries again — the alternative is deleting a profile out from under a live browser.
149
+ }
150
+ }
151
+ return removed
152
+ }