dsh-builtin-browser 0.4.5 → 0.4.7

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.
@@ -69,6 +69,13 @@ export declare const DEFAULT_SETTINGS: BrowserSettings;
69
69
  * @returns the settings file path.
70
70
  */
71
71
  export declare function settingsPath(): string;
72
+ /**
73
+ * The channels a settings document may name.
74
+ *
75
+ * Exported so the log line and the error message can quote the same set the parser accepts: a
76
+ * message that lists a value the code rejects (or omits one it takes) is worse than no message.
77
+ */
78
+ export declare const BROWSER_CHANNELS: readonly ["chrome", "edge", "auto", "bundled", "sidebar"];
72
79
  /**
73
80
  * Resolve a raw settings document (already parsed, shape unknown) into the full
74
81
  * document, field by field. Unknown keys are dropped: the file is not a place to
@@ -76,7 +83,7 @@ export declare function settingsPath(): string;
76
83
  * @param raw - the parsed file contents.
77
84
  * @returns the resolved settings.
78
85
  */
79
- export declare function resolveSettings(raw: unknown): BrowserSettings;
86
+ export declare function resolveSettings(raw: unknown, warn?: (message: string) => void): BrowserSettings;
80
87
  /**
81
88
  * File-backed settings with an in-memory snapshot. Reads re-stat the file so an
82
89
  * edit from another process (or the settings panel writing through a second
@@ -85,13 +92,14 @@ export declare function resolveSettings(raw: unknown): BrowserSettings;
85
92
  */
86
93
  export declare class SettingsStore {
87
94
  private readonly file;
95
+ private readonly log?;
88
96
  private cached;
89
97
  /** Last observed mtime, used to invalidate the cache on external edits. */
90
98
  private cachedMtimeMs;
91
99
  /**
92
100
  * @param file - settings file path (defaults beside the browser profile).
93
101
  */
94
- constructor(file?: string);
102
+ constructor(file?: string, log?: ((message: string) => void) | undefined);
95
103
  /** Path this store reads and writes. */
96
104
  path(): string;
97
105
  /**
@@ -68,16 +68,34 @@ const SETTINGS_WHEN_UNREADABLE = {
68
68
  credentials: { allowRead: false },
69
69
  actions: { allowExecute: false, allowDownload: false, allowCredentialWrite: false },
70
70
  };
71
+ /**
72
+ * The channels a settings document may name.
73
+ *
74
+ * Exported so the log line and the error message can quote the same set the parser accepts: a
75
+ * message that lists a value the code rejects (or omits one it takes) is worse than no message.
76
+ */
77
+ export const BROWSER_CHANNELS = ['chrome', 'edge', 'auto', 'bundled', 'sidebar'];
71
78
  /**
72
79
  * Coerce the browser channel, keeping the default when it is not one of the
73
80
  * known choices. A hand-edited typo must not silently select a different browser.
81
+ *
82
+ * Coercing is right; coercing QUIETLY is not. A typo and "the setting did nothing" look exactly
83
+ * the same to whoever typed it, which is why every discarded value is reported.
74
84
  * @param value - the raw value from the settings document.
85
+ * @param warn - where to report a discarded value, when the caller has somewhere to put it.
75
86
  * @returns a valid channel.
76
87
  */
77
- function resolveChannel(value) {
78
- return value === 'chrome' || value === 'edge' || value === 'auto' || value === 'bundled' || value === 'sidebar'
79
- ? value
80
- : DEFAULT_SETTINGS.browser.channel;
88
+ function resolveChannel(value, warn) {
89
+ // Absent is the ordinary case, not a mistake: a document that never mentioned the channel gets
90
+ // the default without a word.
91
+ if (value === undefined)
92
+ return DEFAULT_SETTINGS.browser.channel;
93
+ if (BROWSER_CHANNELS.includes(value))
94
+ return value;
95
+ warn?.(`dsh-builtin-browser: browser.channel ${JSON.stringify(value)} is not a known channel, so the `
96
+ + `default "${DEFAULT_SETTINGS.browser.channel}" was used instead. Expected one of: `
97
+ + `${BROWSER_CHANNELS.join(', ')}.`);
98
+ return DEFAULT_SETTINGS.browser.channel;
81
99
  }
82
100
  /** Coerce one positive-integer field, keeping the default when invalid. */
83
101
  function count(value, fallback) {
@@ -119,7 +137,7 @@ function section(source, key) {
119
137
  * @param raw - the parsed file contents.
120
138
  * @returns the resolved settings.
121
139
  */
122
- export function resolveSettings(raw) {
140
+ export function resolveSettings(raw, warn) {
123
141
  const source = typeof raw === 'object' && raw !== null ? raw : {};
124
142
  const history = section(source, 'history');
125
143
  const cookies = section(source, 'cookies');
@@ -146,7 +164,7 @@ export function resolveSettings(raw) {
146
164
  virtualCursor: bool(ui?.virtualCursor, DEFAULT_SETTINGS.ui.virtualCursor),
147
165
  },
148
166
  vision: { strategy: vision?.strategy === 'nonVisual' ? 'nonVisual' : DEFAULT_SETTINGS.vision.strategy },
149
- browser: { channel: resolveChannel(browser?.channel) },
167
+ browser: { channel: resolveChannel(browser?.channel, warn) },
150
168
  credentials: {
151
169
  // `gate` returns false for anything present-and-not-a-boolean, so an unreadable section
152
170
  // is refused here for free: the section itself is the "present but wrong" value.
@@ -175,14 +193,16 @@ export function resolveSettings(raw) {
175
193
  */
176
194
  export class SettingsStore {
177
195
  file;
196
+ log;
178
197
  cached;
179
198
  /** Last observed mtime, used to invalidate the cache on external edits. */
180
199
  cachedMtimeMs = -1;
181
200
  /**
182
201
  * @param file - settings file path (defaults beside the browser profile).
183
202
  */
184
- constructor(file = settingsPath()) {
203
+ constructor(file = settingsPath(), log) {
185
204
  this.file = file;
205
+ this.log = log;
186
206
  }
187
207
  /** Path this store reads and writes. */
188
208
  path() {
@@ -230,7 +250,7 @@ export class SettingsStore {
230
250
  // value in it: the document looked correct, and the plugin quietly ran on
231
251
  // defaults — including switches the user had just turned off.
232
252
  const body = text?.replace(/^\uFEFF/, '');
233
- this.cached = body === undefined ? DEFAULT_SETTINGS : resolveSettings(JSON.parse(body));
253
+ this.cached = body === undefined ? DEFAULT_SETTINGS : resolveSettings(JSON.parse(body), m => this.log?.(m));
234
254
  }
235
255
  catch {
236
256
  // A document that EXISTS but cannot be parsed is not the same as no document at all: the
@@ -250,7 +270,7 @@ export class SettingsStore {
250
270
  */
251
271
  update(patch) {
252
272
  const current = this.get();
253
- const merged = resolveSettings(deepMerge(current, patch));
273
+ const merged = resolveSettings(deepMerge(current, patch), m => this.log?.(m));
254
274
  try {
255
275
  mkdirSync(dirname(this.file), { recursive: true });
256
276
  writeFileAtomic(this.file, `${JSON.stringify(merged, null, 2)}\n`);
@@ -98,6 +98,40 @@ function assertAllowed(state, toolName, task) {
98
98
  function agentOf(exec) {
99
99
  return exec?.agent;
100
100
  }
101
+ /**
102
+ * Refuse the bare DSH web root, which answers 401 and looks like a real page.
103
+ *
104
+ * The address comes from `DSH_WEB_URL`, which the harness exports to its children — NOT from a
105
+ * hardcoded port. This machine's GUI has run on 3080 and on 19387, so a number would stop matching
106
+ * exactly when the guard is needed, and the trap is not a port anyway: it is the SHAPE. A loopback
107
+ * address at its root with no token renders as an unauthenticated 401 page, which an agent reads
108
+ * as the site it meant to open.
109
+ * @param rawUrl - the address a caller asked to open.
110
+ */
111
+ function assertNotBareDshWebUrl(rawUrl) {
112
+ const advertised = process.env.DSH_WEB_URL;
113
+ if (typeof advertised !== 'string' || advertised === '')
114
+ return;
115
+ let target;
116
+ let dsh;
117
+ try {
118
+ target = new URL(rawUrl);
119
+ dsh = new URL(advertised);
120
+ }
121
+ catch {
122
+ return; // not a URL this guard can judge; the provider's own admission is the next gate
123
+ }
124
+ if (target.origin !== dsh.origin)
125
+ return;
126
+ if (target.pathname !== '/')
127
+ return;
128
+ // The authenticated URL printed by `dsh web` carries a token; the bare root does not.
129
+ if (target.searchParams.has('token'))
130
+ return;
131
+ throw new Error('browser_open: refusing the bare DSH web root (' + dsh.origin + '/) because it carries no '
132
+ + 'authentication token, so it answers with a 401 page that reads like a real site. Open the '
133
+ + 'target site instead; to open DSH itself, use the authenticated URL printed by `dsh web`.');
134
+ }
101
135
  /**
102
136
  * Read a target's `index`, refusing the values that cannot mean anything.
103
137
  *
@@ -263,7 +297,7 @@ export function apply(ctx, config = {}) {
263
297
  });
264
298
  ctx.tools.register(defineTool({
265
299
  name: 'browser_open',
266
- description: 'Open a URL in the shared browser window. Opens this task\'s browser session on first use; optionally opens in a new tab. Returns the resulting page snapshot.',
300
+ description: 'Open a URL in the shared browser window. Opens this task\'s browser session on first use; optionally opens in a new tab. Returns the resulting page snapshot. A fresh session starts on a blank, uncommitted tab: call this with the REAL target URL before reading, because reading that tab returns an empty page instead of waiting. Never treat the bare DSH host (e.g. http://127.0.0.1:3080/) as the target website — it requires the authenticated URL printed by `dsh web`, and the bare root is refused outright.',
267
301
  parameters: {
268
302
  url: { type: 'string', required: true, description: 'The URL to open (HTTP/HTTPS).' },
269
303
  newTab: { type: 'boolean', description: 'Open in a new tab instead of the active one.' },
@@ -302,6 +336,7 @@ export function apply(ctx, config = {}) {
302
336
  isConcurrencySafe: () => false, // opens tabs / navigates; exclusive within a task
303
337
  async execute(args, exec) {
304
338
  assertAllowed(state, 'browser_open', taskKey(exec));
339
+ assertNotBareDshWebUrl(args.url);
305
340
  const browser = ctx.get('browser');
306
341
  if (browser === undefined)
307
342
  throw new Error('tool-browser: browser service unavailable');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-builtin-browser",
3
- "version": "0.4.5",
3
+ "version": "0.4.7",
4
4
  "description": "Shared real browser plugin for DeepSeek Harness: install-and-use — a visible native browser the human can take over, driven by the agent over CDP. DOM-level interaction (React/Vue safe), per-task session isolation, cookie persistence (browser_auth), CAPTCHA detection, batch form fill (browser_fill), authenticated download, history/replay, action restriction",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -51,6 +51,16 @@ const MAX_BUFFER_BYTES = 128 * 1024 * 1024
51
51
  */
52
52
  const REQUEST_ID_FIELD = 'bridgeRequestId'
53
53
 
54
+ /**
55
+ * How long the published endpoint may go un-refreshed before its shell is presumed gone.
56
+ *
57
+ * A live shell rewrites the file every fifteen seconds (its keep-alive), so four missed refreshes
58
+ * is not a slow shell — it is a shell that exited and left its last write behind. The distinction
59
+ * matters because the file is the ONLY thing that says where the bridge is: a tombstone names a
60
+ * port nobody owns, and a client that trusts it dials that port for the rest of its life.
61
+ */
62
+ const ENDPOINT_TTL_MS = 60_000
63
+
54
64
  /**
55
65
  * A reusable connection to the bridge.
56
66
  *
@@ -78,12 +88,19 @@ export class BridgeConnection {
78
88
  private idLessBridge = false
79
89
  /** True once this connection may only have one request in flight at a time. */
80
90
  private serialisable = false
91
+ /** True once the shell behind this connection has been reported gone. */
92
+ private lostNotified = false
81
93
 
82
94
  /**
83
95
  * @param endpoint - the shell's published bridge endpoint.
84
96
  * @param endpointPath - where that endpoint is republished, so a moved port can be followed.
97
+ * @param onLost - called once when this connection concludes the shell behind it is gone.
85
98
  */
86
- constructor(private endpoint: BridgeEndpoint, private readonly endpointPath?: string) {}
99
+ constructor(
100
+ private endpoint: BridgeEndpoint,
101
+ private readonly endpointPath?: string,
102
+ private readonly onLost?: () => void,
103
+ ) {}
87
104
 
88
105
  /**
89
106
  * Re-read the published endpoint, and adopt it if the shell has moved.
@@ -103,6 +120,13 @@ export class BridgeConnection {
103
120
  try {
104
121
  const raw = JSON.parse(readFileSync(this.endpointPath, 'utf8')) as Partial<BridgeEndpoint>
105
122
  if (typeof raw.port !== 'number' || typeof raw.token !== 'string') return false
123
+ // A shell that exited stops refreshing the file, and what it left names a port nobody owns.
124
+ // Trusting it is how a host ends up dialling a dead port for the rest of the process's life
125
+ // — reported as "the browser stopped working and only restarting DSH brought it back".
126
+ if (endpointExpired(raw.updatedAt)) {
127
+ this.notifyLost()
128
+ return false
129
+ }
106
130
  if (raw.port === this.endpoint.port && raw.token === this.endpoint.token) return false
107
131
  this.endpoint = {
108
132
  port: raw.port,
@@ -117,6 +141,17 @@ export class BridgeConnection {
117
141
  }
118
142
  }
119
143
 
144
+ /** Report once that the shell is gone, so the surface can fall back instead of dialling it. */
145
+ private notifyLost(): void {
146
+ if (this.lostNotified) return
147
+ this.lostNotified = true
148
+ try {
149
+ this.onLost?.()
150
+ } catch {
151
+ // A listener must never break the transport: the caller's error is the one that matters.
152
+ }
153
+ }
154
+
120
155
  /**
121
156
  * Send one request, reusing the socket if it is still healthy.
122
157
  *
@@ -138,6 +173,16 @@ export class BridgeConnection {
138
173
  // when the condition could not clear, spun until the process ran out of heap.
139
174
  // Follow the shell if its bridge moved to a new port (see refreshEndpoint).
140
175
  this.refreshEndpoint()
176
+ // The shell behind this connection is gone, and the endpoint file says so. Refusing here is
177
+ // what turns a permanent ECONNREFUSED into one named error the surface can act on — by
178
+ // falling back to the browser it hosts itself, instead of dialling a dead port until DSH is
179
+ // restarted.
180
+ if (this.lostNotified) {
181
+ throw new Error(
182
+ 'dsh-builtin-browser: the desktop shell that backed this browser has exited '
183
+ + '(its published endpoint stopped being refreshed)',
184
+ )
185
+ }
141
186
  let socket = this.ensureSocket()
142
187
  if (this.serialisable && this.queue.length > 0) {
143
188
  this.reset(new Error('dsh-builtin-browser: bridge connection reopened so replies stay attributable'))
@@ -288,8 +333,21 @@ export class BridgeConnection {
288
333
  }
289
334
  }
290
335
 
291
- /** The request id a raw answer line echoes back, when it carries one. */
292
- function echoOf(line: string): number | undefined {
336
+ /**
337
+ * Whether a published endpoint has gone un-refreshed long enough to be a tombstone.
338
+ *
339
+ * A missing or unparseable stamp is NOT expired: an older bridge that never wrote one must keep
340
+ * working, and guessing "stale" from absence would break every shell that predates the field.
341
+ * @param updatedAt - the file's own refresh stamp, when it carries one.
342
+ */
343
+ function endpointExpired(updatedAt: unknown): boolean {
344
+ if (typeof updatedAt !== 'string') return false
345
+ const at = Date.parse(updatedAt)
346
+ if (!Number.isFinite(at)) return false
347
+ return Date.now() - at > ENDPOINT_TTL_MS
348
+ }
349
+
350
+ /** The request id a raw answer line echoes back, when it carries one. */function echoOf(line: string): number | undefined {
293
351
  try {
294
352
  const answer = JSON.parse(line) as Record<string, unknown>
295
353
  const value = answer[REQUEST_ID_FIELD]
@@ -118,13 +118,34 @@ export class DesktopBridgeViewHost implements ElectronBrowserViewHost {
118
118
  * the desktop sidebar is the carrier — by design, not by omission.
119
119
  */
120
120
  private userActionHandler: ((action: BrowserUserAction) => void) | undefined
121
+ /** Told when the shell behind this bridge exits; see `onLost`. */
122
+ private lostListener: (() => void) | undefined
121
123
 
122
124
  /**
123
125
  * @param endpoint - the shell's published bridge endpoint.
124
126
  */
125
127
  private constructor(endpoint: BridgeEndpoint, sessionId?: string) {
126
128
  this.owner = sessionId !== undefined && sessionId !== String.fromCharCode(39,39) ? sessionId : randomUUID()
127
- this.connection = new BridgeConnection(endpoint, bridgeEndpointPath())
129
+ this.connection = new BridgeConnection(endpoint, bridgeEndpointPath(), () => this.reportLost())
130
+ }
131
+
132
+ /**
133
+ * Called by the transport once the shell behind it is gone.
134
+ *
135
+ * Forwarded rather than acted on here: only the entry point owns the swap back to the self-hosted
136
+ * browser, and a host that tried to replace itself would be reaching outside its own contract.
137
+ */
138
+ private reportLost(): void {
139
+ try {
140
+ this.lostListener?.()
141
+ } catch {
142
+ // The listener's failure is not the transport's problem.
143
+ }
144
+ }
145
+
146
+ /** Register the "this carrier is gone" listener (see the interface's `onLost`). */
147
+ onLost(listener: () => void): void {
148
+ this.lostListener = listener
128
149
  }
129
150
 
130
151
  /**
@@ -17,8 +17,9 @@ import type { BrowserRuntime } from '../browser/runtime.js'
17
17
  import { ElectronBrowserProvider } from './provider.js'
18
18
  import type { ElectronBrowserViewHost } from './provider.js'
19
19
  import { randomUUID } from 'node:crypto'
20
+ import { existsSync, mkdirSync, renameSync } from 'node:fs'
20
21
  import { homedir } from 'node:os'
21
- import { join } from 'node:path'
22
+ import { dirname, join } from 'node:path'
22
23
  import { defaultHostMainPath, RemoteElectronViewHost } from './remote-host.js'
23
24
  import { DesktopBridgeViewHost } from './desktop-bridge-host.js'
24
25
  import { detectBrowser, searchSummary, SystemBrowserViewHost } from './system-browser.js'
@@ -111,9 +112,10 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
111
112
  const sessionId = readSessionId(ctx)
112
113
 
113
114
  // One settings document per plugin instance: the settings panel writes it, the
114
- // provider reads it live, and both ends agree on the same file.
115
- const settings = new SettingsStore()
116
- installSettingsRoute(ctx, settings)
115
+ // provider reads it live, and both ends agree on the same file. The logger is passed so a
116
+ // discarded value (a mistyped channel, most of all) is reported instead of being silently
117
+ // replaced by the default — "my setting did nothing" and "I misspelled it" must not look alike.
118
+ const settings = new SettingsStore(undefined, message => ctx.logger?.warn?.(message))
117
119
 
118
120
  const providerConfig = {
119
121
  httpOnly: config.httpOnly,
@@ -128,6 +130,17 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
128
130
  if (config.viewHost !== undefined) {
129
131
  const external = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(config.viewHost as ElectronBrowserViewHost, providerConfig))
130
132
  ctx.effect(() => () => { external() })
133
+ // The composition supplied the carrier, so `browser.channel` is not ours to honour on this
134
+ // surface. Saying so is the difference between a setting that cannot apply and a setting that
135
+ // appears not to have worked — the second is what sends people looking for a bug elsewhere.
136
+ const asked = settings.get().browser.channel
137
+ if (asked !== 'sidebar' && asked !== 'bundled') {
138
+ ctx.logger?.warn?.(
139
+ `dsh-builtin-browser: browser.channel is "${asked}", but this surface's carrier was `
140
+ + 'supplied by its composition (the desktop shell), which outranks the setting. Set it to '
141
+ + '"sidebar" to record the intent, or run on a surface that picks its own carrier.',
142
+ )
143
+ }
131
144
  return
132
145
  }
133
146
 
@@ -139,25 +152,69 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
139
152
  const selfHosted = new RemoteElectronViewHost(defaultHostMainPath())
140
153
  let unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(selfHosted, providerConfig))
141
154
  let upgraded = false
155
+ /** The carrier an `adopt` installed, so a later choice can release it. */
156
+ let adoptedCarrier: ElectronBrowserViewHost | undefined
142
157
 
143
158
  ctx.effect(() => () => {
144
159
  unregister()
145
160
  selfHosted.dispose()
146
161
  })
147
162
 
163
+ /**
164
+ * Put the self-hosted browser back after an adopted carrier reports that it is gone.
165
+ *
166
+ * This is the half the startup retries never covered: a bridge that was alive at apply time and
167
+ * exited later left every subsequent call dialling a port nobody owns, because the swap happened
168
+ * once and only in one direction. Reaching here means the shell is gone, so the choice made at
169
+ * startup is no longer a choice — the alternative to falling back is a browser surface that does
170
+ * nothing until DSH is restarted.
171
+ * @param adopted - the carrier that reported the loss, disposed once its replacement is in place.
172
+ */
173
+ const restoreSelfHosted = (adopted: ElectronBrowserViewHost): void => {
174
+ try {
175
+ const restored = new RemoteElectronViewHost(defaultHostMainPath())
176
+ unregister()
177
+ unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(restored, providerConfig))
178
+ ctx.logger?.warn?.(
179
+ 'dsh-builtin-browser: the desktop shell backed the sidebar browser and has exited, so the '
180
+ + 'browser this plugin hosts itself takes over. Pages from the sidebar are gone with it; '
181
+ + 'open the page again.',
182
+ )
183
+ } catch (error) {
184
+ // Nothing better is available: the adopted carrier is gone and a provider that throws is
185
+ // worse than one that explains.
186
+ ctx.logger?.warn?.(`dsh-builtin-browser: could not fall back to the self-hosted browser: ${String(error)}`)
187
+ } finally {
188
+ adopted.dispose?.()
189
+ }
190
+ }
191
+
148
192
  /**
149
193
  * Swap the provider to another carrier, releasing the previous one.
150
194
  * @param host - the carrier to adopt.
151
195
  * @param note - a one-line description for the log.
152
196
  */
153
197
  const adopt = (host: ElectronBrowserViewHost, note: string): void => {
154
- if (upgraded) { host.dispose?.(); return }
198
+ // This guard used to make the FIRST choice final, disposing anything that arrived later. That
199
+ // is right against two carriers racing in, and exactly wrong for a user changing the setting:
200
+ // `chooseCarrier` runs again on purpose, and a refusal here is indistinguishable from the
201
+ // write having done nothing. The previous carrier is now released by the replacement instead.
155
202
  upgraded = true
156
203
  try {
157
204
  unregister()
158
205
  unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(host, providerConfig))
159
206
  selfHosted.dispose()
160
207
  ctx.logger?.info?.(`dsh-builtin-browser: ${note}`)
208
+ // A borrowed carrier can lose the process behind it, and this is the only notification that
209
+ // will ever arrive. Registering AFTER the swap succeeded means a listener can never fire
210
+ // against a provider that was never installed.
211
+ host.onLost?.(() => { restoreSelfHosted(host) })
212
+ const previous = adoptedCarrier
213
+ adoptedCarrier = host
214
+ // Released AFTER the replacement is installed, so the surface is never without a provider.
215
+ if (previous !== undefined && previous !== host) {
216
+ try { previous.dispose?.() } catch { /* the replacement is already serving */ }
217
+ }
161
218
  } catch (error) {
162
219
  // Never leave the surface without a provider. The half-built host is disposed FIRST: an
163
220
  // adopt that got as far as constructing it may already hold a socket, a listener or a child
@@ -183,6 +240,16 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
183
240
  }
184
241
  }
185
242
 
243
+ /**
244
+ * Choose and install the carrier that `browser.channel` names RIGHT NOW.
245
+ *
246
+ * A function rather than straight-line code because the setting is read live everywhere else:
247
+ * the settings route writes the file and the provider picks the change up on its next call, so a
248
+ * channel that could only be honoured at apply time was the one setting whose write silently did
249
+ * nothing. Re-entrant by design — changing the channel means replacing the carrier, which is
250
+ * exactly what a second call does.
251
+ */
252
+ const chooseCarrier = (): void => {
186
253
  // A browser the user explicitly asked for outranks every automatic choice: the
187
254
  // whole point of the setting is that they know which browser they want. It is
188
255
  // launched with a plugin-owned profile, so their own windows are untouched.
@@ -225,6 +292,7 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
225
292
  // throwaway directory that is removed when the browser is released.
226
293
  const persist = settings.get().cookies.persist
227
294
  const profileRoot = join(home, BROWSER_PROFILE_DIR)
295
+ adoptLegacyProfileRoot(profileRoot)
228
296
  const profileDir = persist
229
297
  ? join(profileRoot, `${detected.kind}-profile`)
230
298
  : join(profileRoot, ephemeralProfileName(detected.kind, randomUUID()))
@@ -279,6 +347,14 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
279
347
  )
280
348
  }
281
349
  })()
350
+ }
351
+
352
+ chooseCarrier()
353
+
354
+ // Wired AFTER the chooser exists, because the handler re-runs it: a settings write that changes
355
+ // `browser.channel` is a request to switch carriers, and answering `ok` without switching is the
356
+ // one thing a caller cannot tell apart from a write that worked.
357
+ installSettingsRoute(ctx, settings, () => { chooseCarrier() })
282
358
  }
283
359
 
284
360
  /** Route the settings panel reads and writes. */
@@ -293,6 +369,41 @@ const SETTINGS_ROUTE = '/dsh-builtin-browser/settings'
293
369
  */
294
370
  const BROWSER_PROFILE_DIR = 'dsh-builtin-browser-host'
295
371
 
372
+ /**
373
+ * Move a browser profile left behind by the old layout into the directory the plugin uses now.
374
+ *
375
+ * Before the `?.dsh` component was added here, a desktop launched from a shortcut (no `DSH_HOME`
376
+ * in the environment) put its browser profile in `<homedir>/dsh-builtin-browser-host/` while its
377
+ * settings and history lived in `<homedir>/.dsh/dsh-builtin-browser-host/`. Login state lives
378
+ * INSIDE the browser profile, so leaving the old directory behind silently signs the user out of
379
+ * everything they had signed into — reported after the fix shipped, as a one-time cost of it.
380
+ *
381
+ * That cost does not have to be paid. The move happens only when the new location is absent, so a
382
+ * profile in use is never overwritten, and every failure — a cross-device rename, a permission
383
+ * problem, a profile another process holds — falls through to an empty profile, which is exactly
384
+ * what would have happened without this function. The old directory is then left untouched rather
385
+ * than deleted, because a half-moved profile is worse than a stale one.
386
+ *
387
+ * @param profileRoot - the root the plugin resolves today (`<DSH_HOME>/dsh-builtin-browser-host`).
388
+ * @param legacyRoot - the pre-`DSH_HOME` layout's root. Injected so the move can be exercised
389
+ * without touching the real home directory.
390
+ */
391
+ export function adoptLegacyProfileRoot(
392
+ profileRoot: string,
393
+ legacyRoot: string = join(homedir(), BROWSER_PROFILE_DIR),
394
+ ): void {
395
+ try {
396
+ if (legacyRoot === profileRoot) return
397
+ if (!existsSync(legacyRoot) || existsSync(profileRoot)) return
398
+ // `renameSync` needs the destination's PARENT to exist, and on a fresh install it does not:
399
+ // the old layout never created `.dsh` at all. Creating it is harmless if it is already there.
400
+ mkdirSync(dirname(profileRoot), { recursive: true })
401
+ renameSync(legacyRoot, profileRoot)
402
+ } catch {
403
+ // Nothing here is worth failing an apply over; the fallback is the pre-existing behaviour.
404
+ }
405
+ }
406
+
296
407
  /** The host web-server surface this plugin uses, described structurally. */
297
408
  interface WebServerHost {
298
409
  readonly webServer: {
@@ -316,7 +427,11 @@ interface WebServerHost {
316
427
  * @param ctx - the plugin context.
317
428
  * @param settings - the settings document to expose.
318
429
  */
319
- function installSettingsRoute(ctx: Context, settings: SettingsStore): void {
430
+ function installSettingsRoute(
431
+ ctx: Context,
432
+ settings: SettingsStore,
433
+ onChannelChange?: () => void,
434
+ ): void {
320
435
  const inject = (ctx as unknown as {
321
436
  inject?: (deps: readonly string[], callback: (host: WebServerHost) => void) => void
322
437
  }).inject
@@ -326,7 +441,7 @@ function installSettingsRoute(ctx: Context, settings: SettingsStore): void {
326
441
  host.effect(() => host.webServer.register({
327
442
  kind: 'exact',
328
443
  path: SETTINGS_ROUTE,
329
- handler: (request, response) => handleSettingsRequest(request, response, settings),
444
+ handler: (request, response) => handleSettingsRequest(request, response, settings, onChannelChange),
330
445
  }, 'dsh-builtin-browser: settings'))
331
446
  })
332
447
  } catch {
@@ -340,11 +455,13 @@ function installSettingsRoute(ctx: Context, settings: SettingsStore): void {
340
455
  * @param request - the incoming request.
341
456
  * @param response - the response to write.
342
457
  * @param settings - the settings document.
458
+ * @param onChannelChange - re-runs the carrier choice when this write changed `browser.channel`.
343
459
  */
344
460
  async function handleSettingsRequest(
345
461
  request: IncomingMessage,
346
462
  response: ServerResponse,
347
463
  settings: SettingsStore,
464
+ onChannelChange?: () => void,
348
465
  ): Promise<void> {
349
466
  const json = (status: number, body: unknown): void => {
350
467
  response.writeHead(status, {
@@ -370,7 +487,19 @@ async function handleSettingsRequest(
370
487
  try {
371
488
  const body = await readBody(request)
372
489
  const patch: unknown = body.trim() === '' ? {} : JSON.parse(body)
373
- json(200, { ok: true, settings: settings.update(patch) })
490
+ const before = settings.get().browser.channel
491
+ const updated = settings.update(patch)
492
+ // A changed `browser.channel` is a request to switch carriers, not just a value to store. The
493
+ // choice is re-run here so the write takes effect now — and the response says so, because a
494
+ // caller that cannot tell "applied" from "stored for later" has no way to know what it got,
495
+ // which is exactly how a write that did nothing read as a success.
496
+ const channelChanged = updated.browser.channel !== before
497
+ if (channelChanged) onChannelChange?.()
498
+ json(200, {
499
+ ok: true,
500
+ settings: updated,
501
+ ...channelChanged ? { carrierRechosen: true } : {},
502
+ })
374
503
  } catch (error) {
375
504
  json(400, { ok: false, error: `invalid settings patch: ${String(error)}` })
376
505
  }