dsh-builtin-browser 0.1.23 → 0.3.0

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/README.en.md +91 -3
  2. package/README.md +98 -4
  3. package/client.js +334 -0
  4. package/cordis.patch.yml +24 -5
  5. package/desktop-bridge/install.mjs +98 -0
  6. package/desktop-bridge/plugin-browser-bridge.js +385 -0
  7. package/docs/README.md +1 -1
  8. package/docs/architecture.md +1 -1
  9. package/docs/tool-reference.md +2 -1
  10. package/lib/browser/runtime.d.ts +18 -1
  11. package/lib/browser/runtime.js +19 -0
  12. package/lib/browser/types.d.ts +33 -0
  13. package/lib/browser-electron/bridge-connection.d.ts +58 -0
  14. package/lib/browser-electron/bridge-connection.js +133 -0
  15. package/lib/browser-electron/desktop-bridge-host.d.ts +124 -0
  16. package/lib/browser-electron/desktop-bridge-host.js +297 -0
  17. package/lib/browser-electron/entry.js +195 -11
  18. package/lib/browser-electron/history-store.d.ts +75 -0
  19. package/lib/browser-electron/history-store.js +162 -0
  20. package/lib/browser-electron/host-main.js +28 -1
  21. package/lib/browser-electron/provider.d.ts +122 -1
  22. package/lib/browser-electron/provider.js +233 -21
  23. package/lib/browser-electron/remote-host.d.ts +17 -1
  24. package/lib/browser-electron/remote-host.js +81 -9
  25. package/lib/browser-electron/settings-store.d.ts +92 -0
  26. package/lib/browser-electron/settings-store.js +183 -0
  27. package/lib/browser-electron/system-browser.d.ts +100 -0
  28. package/lib/browser-electron/system-browser.js +322 -0
  29. package/lib/browser-electron/virtual-cursor.d.ts +67 -0
  30. package/lib/browser-electron/virtual-cursor.js +141 -0
  31. package/lib/index.d.ts +16 -2
  32. package/lib/index.js +18 -2
  33. package/lib/tool-browser/index.js +122 -16
  34. package/package.json +14 -1
  35. package/src/browser/runtime.ts +22 -0
  36. package/src/browser/types.ts +31 -0
  37. package/src/browser-electron/bridge-connection.ts +145 -0
  38. package/src/browser-electron/desktop-bridge-host.ts +305 -0
  39. package/src/browser-electron/entry.ts +217 -11
  40. package/src/browser-electron/history-store.ts +171 -0
  41. package/src/browser-electron/host-main.ts +26 -1
  42. package/src/browser-electron/provider.ts +284 -21
  43. package/src/browser-electron/remote-host.ts +84 -7
  44. package/src/browser-electron/settings-store.ts +227 -0
  45. package/src/browser-electron/system-browser.ts +318 -0
  46. package/src/browser-electron/virtual-cursor.ts +152 -0
  47. package/src/index.ts +20 -2
  48. package/src/tool-browser/index.ts +121 -15
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Desktop-sidebar browser host: drive the page the desktop shell shows in its
3
+ * sidebar, instead of spawning a second, parallel Electron window.
4
+ *
5
+ * WHY
6
+ * The plugin runs inside the desktop's Node-mode host, where there is no Electron
7
+ * API — which is why it self-hosts a whole browser today. The shell, however, can
8
+ * own views, and its sidebar already displays real web pages. This host borrows
9
+ * that page over the shell's bridge (see apps/desktop/bridge/plugin-browser-bridge.js),
10
+ * so the agent and the human end up on ONE page instead of two.
11
+ *
12
+ * CONTRACT
13
+ * It implements the same `ElectronBrowserViewHost` seam the self-hosted host does,
14
+ * so the provider, the tools, browsing history, the synthetic cursor and the
15
+ * teardown rules are all unchanged: only the carrier differs. Everything is
16
+ * lazily materialized — a fresh shell has no sidebar guest until something asks
17
+ * for a page, and the bridge creates one on demand.
18
+ *
19
+ * FALLBACK
20
+ * `discover()` returns undefined whenever the shell offers no usable bridge
21
+ * (older desktop build, plain `dsh web`, bridge not started), and the entry point
22
+ * then keeps the self-hosted Electron it has always used.
23
+ * @module dsh-browser/browser-electron/desktop-bridge-host
24
+ */
25
+
26
+ import { BridgeConnection, type BridgeEndpoint } from './bridge-connection.js'
27
+ import { readFileSync } from 'node:fs'
28
+ import { connect, type Socket } from 'node:net'
29
+ import { homedir } from 'node:os'
30
+ import { join } from 'node:path'
31
+ import { randomUUID } from 'node:crypto'
32
+ import type { ElectronBrowserViewHost, ElectronViewHandle } from './provider.js'
33
+ import type { BrowserUserAction } from './provider.js'
34
+
35
+
36
+ /** Absolute path of the endpoint file the shell writes. */
37
+ export function bridgeEndpointPath(): string {
38
+ const home = process.env.DSH_HOME ?? join(homedir(), '.dsh')
39
+ return join(home, 'dsh-builtin-browser-bridge.json')
40
+ }
41
+
42
+ /**
43
+ * Whether a command failed because the page is no longer there (as opposed to
44
+ * failing on its own merits). Only these are worth retrying on a fresh guest.
45
+ * @param error - the failure to classify.
46
+ */
47
+ function isGuestGone(error: unknown): boolean {
48
+ const message = error instanceof Error ? error.message : String(error)
49
+ return /guest \d+ is not available|sidebar unavailable|did not create a browser guest/i.test(message)
50
+ }
51
+
52
+ /**
53
+ * The desktop sidebar, presented as a browser view host.
54
+ *
55
+ * One sidebar exists per shell window, so every view this host hands out refers
56
+ * to the same guest: the provider keeps its tab bookkeeping, and the tabs simply
57
+ * share the visible page. That is the intended behaviour for this carrier — the
58
+ * point is a single page both parties can see.
59
+ */
60
+ export class DesktopBridgeViewHost implements ElectronBrowserViewHost {
61
+ private readonly connection: BridgeConnection
62
+ private readonly views = new Map<string, number>()
63
+ /**
64
+ * Guests whose view is gone but whose page may still be open in the sidebar.
65
+ *
66
+ * The provider destroys its view handles before it asks for a release, so the
67
+ * mapping is dropped by then — keeping the guest ids here is what lets us close
68
+ * only our own tabs when several sessions are running (requirements §4: each
69
+ * session gets its own page).
70
+ */
71
+ private readonly orphaned = new Set<number>()
72
+ private userActionHandler: ((action: BrowserUserAction) => void) | undefined
73
+
74
+ /**
75
+ * @param endpoint - the shell's published bridge endpoint.
76
+ */
77
+ private constructor(private readonly endpoint: BridgeEndpoint) {
78
+ this.connection = new BridgeConnection(endpoint)
79
+ }
80
+
81
+ /**
82
+ * Find a usable desktop bridge, if this surface has one.
83
+ *
84
+ * A stale endpoint (a shell that already exited, leaving its file behind) is
85
+ * rejected here rather than surfacing later as a mysterious ECONNREFUSED: the
86
+ * caller falls back to self-hosting, which always works.
87
+ * @returns the host, or undefined when no bridge is available.
88
+ */
89
+ static async discover(): Promise<DesktopBridgeViewHost | undefined> {
90
+ let endpoint: BridgeEndpoint
91
+ try {
92
+ const raw = JSON.parse(readFileSync(bridgeEndpointPath(), 'utf8')) as Partial<BridgeEndpoint>
93
+ if (typeof raw.port !== 'number' || typeof raw.token !== 'string' || typeof raw.pid !== 'number') return undefined
94
+ endpoint = { port: raw.port, token: raw.token, pid: raw.pid, ...raw.updatedAt !== undefined ? { updatedAt: raw.updatedAt } : {} }
95
+ } catch {
96
+ return undefined
97
+ }
98
+ try {
99
+ // A throwaway connection for discovery: the adopted host opens its own, and a
100
+ // rejected endpoint must not leave a socket behind.
101
+ const probe = new BridgeConnection(endpoint)
102
+ try {
103
+ const answer = await probe.call({ op: 'list' }, 5_000)
104
+ return answer.ok === true ? new DesktopBridgeViewHost(endpoint) : undefined
105
+ } finally {
106
+ probe.close()
107
+ }
108
+ } catch {
109
+ // Dead endpoint or an older bridge: self-hosting remains the answer.
110
+ return undefined
111
+ }
112
+ }
113
+
114
+ /** Whether this host can back views: the bridge already answered `list`. */
115
+ available(): boolean {
116
+ return true
117
+ }
118
+
119
+ /**
120
+ * The guest id backing a view, materialized on first use.
121
+ *
122
+ * The sidebar browser is itself a multi-tab surface, so each view gets its own
123
+ * tab's guest: the provider's tab bookkeeping then maps onto real tabs the human
124
+ * can see and switch between. Two rules keep that honest:
125
+ * - a cached guest is used as-is: probing it first cost a round-trip on every
126
+ * command, so liveness is established by the command failing instead;
127
+ * - a fresh view takes an unclaimed guest, growing the tab strip only when
128
+ * every existing guest is already spoken for.
129
+ * @param viewId - the view whose guest is wanted.
130
+ * @param url - address to use when a sidebar browser has to be opened first.
131
+ */
132
+ private async guestFor(viewId: string, url?: string): Promise<number> {
133
+ const existing = this.views.get(viewId)
134
+ // No liveness check here: verifying the cached guest cost a round-trip on every
135
+ // command. A guest that has gone away is detected by the command itself failing,
136
+ // and `createView`'s sendCommand then discards the cache and calls back in here.
137
+ if (existing !== undefined) return existing
138
+
139
+ // The browser tab must exist before its strip can be extended: `ensureSidebar`
140
+ // opens the sidebar (and, on a fresh shell, materializes the first guest).
141
+ const sidebar = await this.connection.call({
142
+ op: 'ensureSidebar',
143
+ ...url !== undefined ? { url } : {},
144
+ })
145
+ if (sidebar.ok !== true) throw new Error(`dsh-builtin-browser: sidebar unavailable (${String(sidebar.error)})`)
146
+
147
+ const wanted = this.views.size + 1
148
+ let ids = await this.guestIds(Math.max(1, wanted))
149
+ const taken = new Set(this.views.values())
150
+ let free = ids.find(id => !taken.has(id))
151
+ if (free === undefined) {
152
+ // Every tab is already driving something: the strip has to grow.
153
+ ids = await this.guestIds(ids.length + 1)
154
+ free = ids.find(id => !taken.has(id))
155
+ }
156
+ if (free === undefined) throw new Error('dsh-builtin-browser: the sidebar reported no free browser tab')
157
+ this.views.set(viewId, free)
158
+ return free
159
+ }
160
+
161
+ /**
162
+ * Ask for at least `count` sidebar tabs and return their guest ids.
163
+ * @param count - minimum number of tabs.
164
+ */
165
+ private async guestIds(count: number): Promise<number[]> {
166
+ const answer = await this.connection.call({ op: 'ensureTabs', count })
167
+ if (answer.ok !== true) throw new Error(`dsh-builtin-browser: could not open a sidebar tab (${String(answer.error)})`)
168
+ const ids = Array.isArray(answer.ids) ? answer.ids.map(Number).filter(Number.isFinite) : []
169
+ if (ids.length === 0) throw new Error('dsh-builtin-browser: the sidebar reported no browser tabs')
170
+ return ids
171
+ }
172
+
173
+ createView(): ElectronViewHandle {
174
+ const viewId = randomUUID()
175
+ const navigateUrl = (method: string, params?: Record<string, unknown>): string | undefined =>
176
+ method === 'Page.navigate' ? String((params as { url?: unknown })?.url ?? '') : undefined
177
+ const run = async (guest: number, method: string, params?: Record<string, unknown>): Promise<Record<string, unknown>> => {
178
+ const answer = await this.connection.call({ op: 'cdp', id: guest, method, params })
179
+ if (answer.ok !== true) throw new Error(`dsh-builtin-browser: sidebar command failed: ${String(answer.error)}`)
180
+ // The bridge forwards whatever CDP answered; the seam expects an object.
181
+ return (answer.result ?? {}) as Record<string, unknown>
182
+ }
183
+ return {
184
+ id: viewId,
185
+ sendCommand: async (method: string, params?: Record<string, unknown>) => {
186
+ // No liveness probe up front. Checking first cost a whole round-trip on
187
+ // every command; instead the command runs and its failure is what triggers
188
+ // recovery. Only a genuinely missing guest is retried, so a real error (a
189
+ // bad selector, a timeout) still surfaces unchanged.
190
+ const url = navigateUrl(method, params)
191
+ let guest = await this.guestFor(viewId, url)
192
+ try {
193
+ return await run(guest, method, params)
194
+ } catch (error) {
195
+ if (!isGuestGone(error)) throw error
196
+ // The human closed the tab: the page is gone, so take a fresh one and
197
+ // replay the command once. This is the "closing the interface ends the
198
+ // session" rule, now paid for only when it actually happens.
199
+ this.views.delete(viewId)
200
+ guest = await this.guestFor(viewId, url)
201
+ return await run(guest, method, params)
202
+ }
203
+ },
204
+ }
205
+ }
206
+
207
+ destroyView(handle: ElectronViewHandle): void {
208
+ const guest = this.views.get(handle.id)
209
+ if (guest !== undefined) {
210
+ // Remember it: the page outlives our handle, and a later release must be able
211
+ // to name exactly the tabs this plugin opened.
212
+ this.orphaned.add(guest)
213
+ this.views.delete(handle.id)
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Nothing to show: the sidebar is already on screen, and showing it again is
219
+ * the shell's business (it owns the tab strip and the visibility toggle).
220
+ */
221
+ showView(): void {}
222
+
223
+ /** Same as {@link showView}: the page is already presented by the shell. */
224
+ async presentView(): Promise<void> {}
225
+
226
+ /**
227
+ * The shell owns window grouping, and one sidebar serves every view, so there
228
+ * is nothing to record here. (It must not write a placeholder guest id either:
229
+ * a view whose guest is not yet materialized has to stay absent from the map,
230
+ * or the first real command would be sent to the placeholder.)
231
+ */
232
+ groupView(): void {}
233
+
234
+ /** Focus is the shell's to manage, and there is no separate window to raise. */
235
+ async focus(): Promise<void> {}
236
+
237
+ onUserAction(handler: (action: BrowserUserAction) => void): void {
238
+ this.userActionHandler = handler
239
+ }
240
+
241
+ /** The shell reports its own window lifecycle; nothing to subscribe to here. */
242
+ onViewClosed(): void {}
243
+
244
+ /**
245
+ * Release the sidebar's browser pages (requirements §3, `ui.closeWithSession`).
246
+ *
247
+ * Destroying our own view handles is not enough on this carrier: the sidebar
248
+ * belongs to the shell and would happily keep the page (and its renderer) alive.
249
+ * Closing the tabs is what actually ends the page — and only the page: cookies
250
+ * live in the partition, history on disk, so both survive.
251
+ * @returns a promise that settles once the shell has been asked.
252
+ */
253
+ async releasePage(): Promise<void> {
254
+ // Only our own tabs: the ids come from the views we handed out (and their
255
+ // orphans), never from "every webview currently visible". With two sessions
256
+ // live, releasing one must leave the other's page alone.
257
+ const mine = [...this.views.values(), ...this.orphaned]
258
+ this.views.clear()
259
+ this.orphaned.clear()
260
+ // Nothing of ours is open: release nothing. Falling through to an unfiltered
261
+ // release here would close tabs a human opened, or another session's page.
262
+ if (mine.length === 0) return
263
+ let titles: string[]
264
+ try {
265
+ const answer = await this.connection.call({ op: 'list' }, 5_000)
266
+ const sidebar = Array.isArray(answer.sidebar) ? answer.sidebar as Array<{ id?: unknown; title?: unknown }> : []
267
+ titles = sidebar
268
+ .filter(guest => mine.includes(Number(guest.id)))
269
+ .map(guest => String(guest.title ?? ''))
270
+ .filter(title => title !== '')
271
+ } catch {
272
+ // Could not read the titles back; releasing nothing is safer than closing
273
+ // tabs that belong to somebody else.
274
+ return
275
+ }
276
+ if (titles.length === 0) return
277
+ try {
278
+ await this.connection.call({ op: 'closeSidebarBrowser', titles }, 10_000)
279
+ } catch {
280
+ // Best effort: the setting expresses a preference, and a shell that cannot
281
+ // be reached is no reason to fail the session teardown that called us.
282
+ }
283
+ }
284
+
285
+ /**
286
+ * Fold the sidebar away without ending the page (`ui.autoExpandOnce` is off, or
287
+ * the caller wants the screen back while work continues).
288
+ * @returns a promise that settles once the shell has been asked.
289
+ */
290
+ async collapse(): Promise<void> {
291
+ try {
292
+ await this.connection.call({ op: 'collapseSidebar' }, 10_000)
293
+ } catch {
294
+ // Presentation only; failing to fold is never worth an error.
295
+ }
296
+ }
297
+
298
+ /** No child process of our own to stop; close the shared connection instead. */
299
+ dispose(): void {
300
+ this.connection.close()
301
+ this.views.clear()
302
+ this.orphaned.clear()
303
+ this.userActionHandler = undefined
304
+ }
305
+ }
@@ -11,11 +11,18 @@
11
11
  */
12
12
 
13
13
  import type { Context } from '@deepseek-ai/cordis'
14
+ import type { IncomingMessage, ServerResponse } from 'node:http'
14
15
  import z from '@deepseek-ai/schemastery'
15
16
  import type { BrowserRuntime } from '../browser/runtime.js'
16
17
  import { ElectronBrowserProvider } from './provider.js'
17
18
  import type { ElectronBrowserViewHost } from './provider.js'
19
+ import { randomUUID } from 'node:crypto'
20
+ import { homedir } from 'node:os'
21
+ import { join } from 'node:path'
18
22
  import { defaultHostMainPath, RemoteElectronViewHost } from './remote-host.js'
23
+ import { DesktopBridgeViewHost } from './desktop-bridge-host.js'
24
+ import { detectBrowser, SystemBrowserViewHost } from './system-browser.js'
25
+ import { SettingsStore } from './settings-store.js'
19
26
 
20
27
  export {
21
28
  ELECTRON_BROWSER_PROVIDER_ID,
@@ -59,23 +66,222 @@ export const Config: z<Config> = z.object({
59
66
 
60
67
  /** Register the Electron browser provider with `ctx.browser`. */
61
68
  export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config): void {
62
- // External host (desktop shell) wins; otherwise self-host. The self-hosted
63
- // child is disposed with the fiber, mirroring the shell's lifetime.
64
- const host: ElectronBrowserViewHost = config.viewHost ?? new RemoteElectronViewHost(defaultHostMainPath())
65
- // Own the disposer on THIS plugin's fiber: registerBrowserProvider's effect
66
- // is bound to the seam's own fiber (the browser row), so a reload of this
67
- // row would otherwise collide with the still-registered provider
68
- // (BROWSER_DUPLICATE_PROVIDER) or leave a stale provider behind.
69
- const unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(host, {
69
+ // One settings document per plugin instance: the settings panel writes it, the
70
+ // provider reads it live, and both ends agree on the same file.
71
+ const settings = new SettingsStore()
72
+ installSettingsRoute(ctx, settings)
73
+
74
+ const providerConfig = {
70
75
  httpOnly: config.httpOnly,
71
76
  downloadDir: config.downloadDir,
72
77
  snapshotMaxElements: config.snapshotMaxElements,
73
78
  contentMaxChars: config.contentMaxChars,
74
- }))
79
+ settings: () => settings.get(),
80
+ }
81
+
82
+ // A host supplied by the composition (the desktop shell's own seam) wins and is
83
+ // synchronous, so registration stays synchronous on that path.
84
+ if (config.viewHost !== undefined) {
85
+ const external = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(config.viewHost as ElectronBrowserViewHost, providerConfig))
86
+ ctx.effect(() => () => { external() })
87
+ return
88
+ }
89
+
90
+ // Otherwise start self-hosted — a working browser from the first call, on every
91
+ // surface — and upgrade to the desktop sidebar if this machine has one. The
92
+ // upgrade is deliberately a swap of the whole provider: the two carriers own
93
+ // different lifetimes (a spawned child vs. the shell's view), and pretending
94
+ // otherwise would leave a stray window behind.
95
+ const selfHosted = new RemoteElectronViewHost(defaultHostMainPath())
96
+ let unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(selfHosted, providerConfig))
97
+ let upgraded = false
98
+
75
99
  ctx.effect(() => () => {
76
100
  unregister()
77
- if (config.viewHost === undefined && host instanceof RemoteElectronViewHost) {
78
- host.dispose()
101
+ selfHosted.dispose()
102
+ })
103
+
104
+ /**
105
+ * Swap the provider to another carrier, releasing the previous one.
106
+ * @param host - the carrier to adopt.
107
+ * @param note - a one-line description for the log.
108
+ */
109
+ const adopt = (host: ElectronBrowserViewHost, note: string): void => {
110
+ if (upgraded) { host.dispose?.(); return }
111
+ upgraded = true
112
+ try {
113
+ unregister()
114
+ unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(host, providerConfig))
115
+ selfHosted.dispose()
116
+ ctx.logger?.info?.(`dsh-builtin-browser: ${note}`)
117
+ } catch (error) {
118
+ // Never leave the surface without a provider.
119
+ unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(
120
+ new RemoteElectronViewHost(defaultHostMainPath()), providerConfig))
121
+ ctx.logger?.warn?.(`dsh-builtin-browser: could not adopt ${note} (${String(error)})`)
79
122
  }
123
+ }
124
+
125
+ // A browser the user explicitly asked for outranks every automatic choice: the
126
+ // whole point of the setting is that they know which browser they want. It is
127
+ // launched with a plugin-owned profile, so their own windows are untouched.
128
+ void (async () => {
129
+ const channel = settings.get().browser.channel
130
+ if (channel === 'bundled') return
131
+ const detected = detectBrowser(channel)
132
+ if (detected === undefined) {
133
+ ctx.logger?.warn?.(`dsh-builtin-browser: ${channel} was requested but no installation was found; using the bundled browser`)
134
+ return
135
+ }
136
+ const home = process.env.DSH_HOME ?? homedir()
137
+ // Login state lives in the browser profile, so the cookies setting decides where
138
+ // that profile goes: a stable directory keeps the user signed in across restarts
139
+ // (the point of using their own browser), while turning persistence off gets a
140
+ // throwaway directory that is removed when the browser is released.
141
+ const persist = settings.get().cookies.persist
142
+ const profileDir = persist
143
+ ? join(home, 'dsh-builtin-browser-host', `${detected.kind}-profile`)
144
+ : join(home, 'dsh-builtin-browser-host', `${detected.kind}-profile-ephemeral-${randomUUID()}`)
145
+ const host = await SystemBrowserViewHost.launch(detected, profileDir, [], persist ? undefined : profileDir)
146
+ if (host === undefined) {
147
+ ctx.logger?.warn?.(`dsh-builtin-browser: ${detected.kind} did not start; using the bundled browser`)
148
+ return
149
+ }
150
+ adopt(host, `driving the installed ${detected.kind} (${detected.path}${persist ? '' : ', ephemeral profile'})`)
151
+ })().catch(() => { /* the bundled browser stays in place */ })
152
+
153
+ void DesktopBridgeViewHost.discover().then(sidebar => {
154
+ // No bridge (plain `dsh web`, an older desktop build, or a shell that already
155
+ // exited): keep self-hosting, which is what every surface has always done.
156
+ if (sidebar === undefined) return
157
+ adopt(sidebar, 'driving the desktop sidebar browser')
158
+ }).catch(() => { /* discovery never throws; keep self-hosting */ })
159
+ }
160
+
161
+ /** Route the settings panel reads and writes. */
162
+ const SETTINGS_ROUTE = '/dsh-builtin-browser/settings'
163
+
164
+ /** The host web-server surface this plugin uses, described structurally. */
165
+ interface WebServerHost {
166
+ readonly webServer: {
167
+ register(
168
+ spec: {
169
+ readonly kind: 'exact'
170
+ readonly path: string
171
+ readonly handler: (request: IncomingMessage, response: ServerResponse) => void | Promise<void>
172
+ },
173
+ label?: string,
174
+ ): () => void
175
+ }
176
+ effect(callback: () => (() => void) | void): void
177
+ }
178
+
179
+ /**
180
+ * Expose the settings document over the host's web server, which is how the
181
+ * settings panel reads and writes it. A host without that service (headless /
182
+ * CLI surfaces) simply skips this: the panel is then unreachable and the file
183
+ * stays hand-editable — never a plugin-startup failure.
184
+ * @param ctx - the plugin context.
185
+ * @param settings - the settings document to expose.
186
+ */
187
+ function installSettingsRoute(ctx: Context, settings: SettingsStore): void {
188
+ const inject = (ctx as unknown as {
189
+ inject?: (deps: readonly string[], callback: (host: WebServerHost) => void) => void
190
+ }).inject
191
+ if (typeof inject !== 'function') return
192
+ try {
193
+ inject(['webServer'], (host) => {
194
+ host.effect(() => host.webServer.register({
195
+ kind: 'exact',
196
+ path: SETTINGS_ROUTE,
197
+ handler: (request, response) => handleSettingsRequest(request, response, settings),
198
+ }, 'dsh-builtin-browser: settings'))
199
+ })
200
+ } catch {
201
+ // No web server on this surface: the settings file remains the interface.
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Same-origin guard. Settings are machine-local configuration, so a page loaded
207
+ * elsewhere must not be able to read or rewrite them.
208
+ * @param request - the incoming request.
209
+ * @returns whether the request may touch the settings document.
210
+ */
211
+ function sameOrigin(request: IncomingMessage): boolean {
212
+ const site = String(request.headers['sec-fetch-site'] ?? '').toLowerCase()
213
+ if (site === 'cross-site') return false
214
+ const origin = String(request.headers.origin ?? '').trim()
215
+ if (origin === 'null') return false
216
+ if (origin === '') return true
217
+ try {
218
+ return new URL(origin).host.toLowerCase() === String(request.headers.host ?? '').trim().toLowerCase()
219
+ } catch {
220
+ return false
221
+ }
222
+ }
223
+
224
+ /**
225
+ * One settings round-trip: `GET` reads the document, `PUT`/`POST` merges a
226
+ * partial patch into it.
227
+ * @param request - the incoming request.
228
+ * @param response - the response to write.
229
+ * @param settings - the settings document.
230
+ */
231
+ async function handleSettingsRequest(
232
+ request: IncomingMessage,
233
+ response: ServerResponse,
234
+ settings: SettingsStore,
235
+ ): Promise<void> {
236
+ const json = (status: number, body: unknown): void => {
237
+ response.writeHead(status, {
238
+ 'content-type': 'application/json; charset=utf-8',
239
+ 'cache-control': 'no-store',
240
+ })
241
+ response.end(JSON.stringify(body))
242
+ }
243
+ if (!sameOrigin(request)) {
244
+ json(403, { ok: false, error: 'cross-origin request refused' })
245
+ return
246
+ }
247
+ const method = request.method ?? 'GET'
248
+ if (method === 'GET') {
249
+ json(200, { ok: true, settings: settings.get(), path: settings.path() })
250
+ return
251
+ }
252
+ if (method !== 'PUT' && method !== 'POST') {
253
+ response.writeHead(405, { allow: 'GET, PUT' })
254
+ response.end()
255
+ return
256
+ }
257
+ try {
258
+ const body = await readBody(request)
259
+ const patch: unknown = body.trim() === '' ? {} : JSON.parse(body)
260
+ json(200, { ok: true, settings: settings.update(patch) })
261
+ } catch (error) {
262
+ json(400, { ok: false, error: `invalid settings patch: ${String(error)}` })
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Read a request body with a hard cap (the settings patch is tiny, and an
268
+ * unbounded read would let any same-origin caller exhaust memory).
269
+ * @param request - the incoming request.
270
+ * @param limit - maximum accepted characters.
271
+ * @returns the body text.
272
+ */
273
+ function readBody(request: IncomingMessage, limit = 64 * 1024): Promise<string> {
274
+ return new Promise<string>((resolve, reject) => {
275
+ let text = ''
276
+ request.setEncoding('utf8')
277
+ request.on('data', chunk => {
278
+ text += String(chunk)
279
+ if (text.length > limit) {
280
+ request.destroy()
281
+ reject(new Error('settings patch too large'))
282
+ }
283
+ })
284
+ request.on('end', () => resolve(text))
285
+ request.on('error', reject)
80
286
  })
81
287
  }