dsh-builtin-browser 0.1.22 → 0.2.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 (38) hide show
  1. package/README.en.md +50 -3
  2. package/README.md +51 -4
  3. package/client.js +298 -0
  4. package/cordis.patch.yml +24 -5
  5. package/docs/README.md +1 -1
  6. package/docs/architecture.md +1 -1
  7. package/docs/tool-reference.md +2 -1
  8. package/lib/browser/runtime.d.ts +18 -1
  9. package/lib/browser/runtime.js +28 -1
  10. package/lib/browser/types.d.ts +33 -0
  11. package/lib/browser-electron/entry.js +115 -0
  12. package/lib/browser-electron/history-store.d.ts +71 -0
  13. package/lib/browser-electron/history-store.js +151 -0
  14. package/lib/browser-electron/host-main.d.ts +1 -0
  15. package/lib/browser-electron/host-main.js +88 -1
  16. package/lib/browser-electron/provider.d.ts +105 -4
  17. package/lib/browser-electron/provider.js +226 -14
  18. package/lib/browser-electron/remote-host.d.ts +29 -6
  19. package/lib/browser-electron/remote-host.js +114 -24
  20. package/lib/browser-electron/settings-store.d.ts +87 -0
  21. package/lib/browser-electron/settings-store.js +160 -0
  22. package/lib/browser-electron/virtual-cursor.d.ts +42 -0
  23. package/lib/browser-electron/virtual-cursor.js +71 -0
  24. package/lib/index.d.ts +16 -2
  25. package/lib/index.js +18 -2
  26. package/lib/tool-browser/index.js +72 -2
  27. package/package.json +20 -6
  28. package/src/browser/runtime.ts +37 -1
  29. package/src/browser/types.ts +31 -0
  30. package/src/browser-electron/entry.ts +135 -0
  31. package/src/browser-electron/history-store.ts +162 -0
  32. package/src/browser-electron/host-main.ts +83 -1
  33. package/src/browser-electron/provider.ts +267 -19
  34. package/src/browser-electron/remote-host.ts +114 -21
  35. package/src/browser-electron/settings-store.ts +198 -0
  36. package/src/browser-electron/virtual-cursor.ts +82 -0
  37. package/src/index.ts +20 -2
  38. package/src/tool-browser/index.ts +71 -1
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Synthetic pointer feedback for a human watching the tab: an overlay drawn
3
+ * INSIDE the page, never the real system pointer. Its presence is the "the agent
4
+ * has taken over this tab" signal, so it also covers DOM-level operations that
5
+ * move no pointer at all.
6
+ *
7
+ * Implementation notes that matter:
8
+ * - The overlay is `position: fixed` and positioned from viewport coordinates,
9
+ * which is exactly what the element locator already computes and what
10
+ * `Input.dispatchMouseEvent` consumes.
11
+ * - All styling is inline and the ripple uses the Web Animations API: a
12
+ * `<style>` element would be subject to the page's `style-src` CSP, an
13
+ * injected script is not. Injected page feedback must never be silently
14
+ * dropped by a strict CSP.
15
+ * - Every call is best-effort: feedback is never allowed to fail an operation.
16
+ * @module dsh-browser/browser-electron/virtual-cursor
17
+ */
18
+ /** Element id of the overlay root, stable so repeat calls update one node. */
19
+ const CURSOR_ID = '__dsh_agent_cursor__';
20
+ /**
21
+ * The in-page expression that materializes or moves the overlay. Idempotent: the
22
+ * first call creates the nodes, later calls only move them (and pulse).
23
+ * @param x - viewport x in CSS pixels.
24
+ * @param y - viewport y in CSS pixels.
25
+ * @param action - whether to pulse a click ripple at the point.
26
+ * @returns a self-contained expression that evaluates to `true`.
27
+ */
28
+ export function cursorScript(x, y, action) {
29
+ return `(() => {
30
+ const id = ${JSON.stringify(CURSOR_ID)}
31
+ let root = document.getElementById(id)
32
+ if (!root) {
33
+ root = document.createElement('div')
34
+ root.id = id
35
+ root.setAttribute('aria-hidden', 'true')
36
+ root.style.cssText = 'position:fixed;left:0;top:0;width:0;height:0;z-index:2147483647;pointer-events:none;transition:transform 120ms ease-out;will-change:transform'
37
+ const svg = '<svg width="22" height="22" viewBox="0 0 22 22" style="position:absolute;left:-2px;top:-2px;filter:drop-shadow(0 1px 2px rgba(0,0,0,.45))">'
38
+ + '<path d="M4 2 L4 18 L8.4 13.8 L11.2 19.6 L13.8 18.4 L11 12.8 L17 12.6 Z" fill="#ffffff" stroke="#111111" stroke-width="1.4" stroke-linejoin="round"/></svg>'
39
+ const ripple = '<span style="position:absolute;left:0;top:0;width:34px;height:34px;margin:-17px 0 0 -17px;border-radius:50%;border:2px solid rgba(64,160,255,.9);opacity:0"></span>'
40
+ root.innerHTML = svg + ripple
41
+ ;(document.body || document.documentElement).appendChild(root)
42
+ }
43
+ root.style.transform = 'translate(${x}px, ${y}px)'
44
+ ${action === 'click' ? `const ripple = root.lastElementChild
45
+ if (ripple && typeof ripple.animate === 'function') {
46
+ ripple.animate(
47
+ [{ transform: 'scale(.25)', opacity: 0.95 }, { transform: 'scale(1.6)', opacity: 0 }],
48
+ { duration: 550, easing: 'ease-out' },
49
+ )
50
+ }` : ''}
51
+ return true
52
+ })()`;
53
+ }
54
+ /**
55
+ * Draw (or move) the pointer overlay on one view's page.
56
+ *
57
+ * Fire-and-forget: a page that is mid-navigation, cross-origin-blocked, or
58
+ * otherwise unable to answer must not turn a successful operation into a
59
+ * failure, and the navigation that just committed may still be replacing the
60
+ * document this call would have painted into.
61
+ * @param handle - the view to paint into.
62
+ * @param x - viewport x in CSS pixels.
63
+ * @param y - viewport y in CSS pixels.
64
+ * @param action - whether to pulse a click ripple.
65
+ * @param evaluate - the provider's evaluate bridge (keeps the CDP coupling in one place).
66
+ */
67
+ export function paintCursor(handle, x, y, action, evaluate) {
68
+ if (!Number.isFinite(x) || !Number.isFinite(y))
69
+ return;
70
+ void evaluate(handle, cursorScript(Math.round(x), Math.round(y), action)).catch(() => { });
71
+ }
package/lib/index.d.ts CHANGED
@@ -4,10 +4,24 @@
4
4
  * - `dsh-builtin-browser/browser` -> the ctx.browser seam (Service)
5
5
  * - `dsh-builtin-browser/browser-electron` -> the Electron CDP provider
6
6
  * - `dsh-builtin-browser/tool-browser` -> the model-facing browser_* tools
7
- * This root entry only re-exports for programmatic use; the loader rows are
8
- * the composition surface.
7
+ * The capability itself is composed by those rows; this root entry exists for
8
+ * programmatic imports AND because the host's client-module scan identifies a
9
+ * package's browser half through a loader row whose specifier resolves to the
10
+ * PACKAGE ROOT. A row named after a subpath (`dsh-builtin-browser/browser`) is
11
+ * not a package specifier and is skipped by design, which is why the patch also
12
+ * registers a row under the bare package name — see cordis.patch.yml.
9
13
  * @module dsh-builtin-browser
10
14
  */
15
+ /** Plugin identity for the root row (the composition surface is the subpath rows). */
16
+ export declare const name = "dsh-builtin-browser";
17
+ /**
18
+ * Inert application for the root row: the browser seam, the Electron provider and
19
+ * the tools are mounted by the subpath rows, so this one only exists to give the
20
+ * package a loader row named after the package itself. Without it the host's
21
+ * client scan finds no row to resolve `dsh.client` from, and the settings panel
22
+ * never reaches the browser (the plugin works, its client half is invisible).
23
+ */
24
+ export declare function apply(): void;
11
25
  export { BrowserError } from './browser/types.js';
12
26
  export type { BrowserA11yNode, BrowserA11yRequest, BrowserA11yResult, BrowserChallenge, BrowserCheckRequest, BrowserClearRequest, BrowserClickRequest, BrowserContentFormat, BrowserContentRequest, BrowserContentResult, BrowserElementTarget, BrowserExecuteRequest, BrowserExecuteResult, BrowserFillField, BrowserFillRequest, BrowserFillResult, BrowserGetValueRequest, BrowserGetValueResult, BrowserNavigateRequest, BrowserOpenRequest, BrowserProvider, BrowserScrapeField, BrowserScrapeRequest, BrowserScrapeResult, BrowserScreenshotRequest, BrowserScreenshotResult, BrowserSelectRequest, BrowserSelectResult, BrowserSessionId, BrowserSetValueRequest, BrowserSetValueResult, BrowserSnapshotElement, BrowserSnapshotResult, BrowserTab, BrowserTypeRequest, ExportedCookie, } from './browser/types.js';
13
27
  export { BrowserRuntime } from './browser/runtime.js';
package/lib/index.js CHANGED
@@ -4,10 +4,26 @@
4
4
  * - `dsh-builtin-browser/browser` -> the ctx.browser seam (Service)
5
5
  * - `dsh-builtin-browser/browser-electron` -> the Electron CDP provider
6
6
  * - `dsh-builtin-browser/tool-browser` -> the model-facing browser_* tools
7
- * This root entry only re-exports for programmatic use; the loader rows are
8
- * the composition surface.
7
+ * The capability itself is composed by those rows; this root entry exists for
8
+ * programmatic imports AND because the host's client-module scan identifies a
9
+ * package's browser half through a loader row whose specifier resolves to the
10
+ * PACKAGE ROOT. A row named after a subpath (`dsh-builtin-browser/browser`) is
11
+ * not a package specifier and is skipped by design, which is why the patch also
12
+ * registers a row under the bare package name — see cordis.patch.yml.
9
13
  * @module dsh-builtin-browser
10
14
  */
15
+ /** Plugin identity for the root row (the composition surface is the subpath rows). */
16
+ export const name = 'dsh-builtin-browser';
17
+ /**
18
+ * Inert application for the root row: the browser seam, the Electron provider and
19
+ * the tools are mounted by the subpath rows, so this one only exists to give the
20
+ * package a loader row named after the package itself. Without it the host's
21
+ * client scan finds no row to resolve `dsh.client` from, and the settings panel
22
+ * never reaches the browser (the plugin works, its client half is invisible).
23
+ */
24
+ export function apply() {
25
+ // Intentionally empty: see the module comment above.
26
+ }
11
27
  export { BrowserError } from './browser/types.js';
12
28
  export { BrowserRuntime } from './browser/runtime.js';
13
29
  export { ElectronBrowserProvider } from './browser-electron/provider.js';
@@ -63,8 +63,13 @@ function taskKey(exec) {
63
63
  */
64
64
  async function ensureSession(browser, state, key, agent) {
65
65
  const existing = state.sessionsByTask.get(key);
66
- if (existing !== undefined)
67
- return existing;
66
+ if (existing !== undefined) {
67
+ // The human closing a browser window ends that session, so a cached id can
68
+ // be stale: never hand a dead session back to a tool call.
69
+ if (browser.exists(existing))
70
+ return existing;
71
+ state.sessionsByTask.delete(key);
72
+ }
68
73
  const pending = state.pendingOpens.get(key);
69
74
  if (pending !== undefined)
70
75
  return pending;
@@ -1285,6 +1290,71 @@ export function apply(ctx, config = {}) {
1285
1290
  return { entries: rendered };
1286
1291
  },
1287
1292
  }));
1293
+ ctx.tools.register(defineTool({
1294
+ name: 'browser_visited',
1295
+ description: 'List pages the shared browser has visited, newest first, from the PERSISTENT browsing history: it survives closing the browser and restarting DSH, so it is how you (or the human) find a page again afterwards. Reopen one by passing its url to browser_open. Not to be confused with browser_history, which is only the current session\'s operation log (navigate/click/type) and disappears with the session.',
1296
+ parameters: {
1297
+ limit: { type: 'number', description: 'Maximum entries to return (default 30, capped at 200).' },
1298
+ domain: { type: 'string', description: 'Only visits whose hostname contains this text (case-insensitive).' },
1299
+ },
1300
+ output: {
1301
+ schema: {
1302
+ type: 'object',
1303
+ additionalProperties: false,
1304
+ properties: {
1305
+ count: { type: 'number', required: true },
1306
+ entries: {
1307
+ type: 'array',
1308
+ required: true,
1309
+ items: {
1310
+ type: 'object',
1311
+ additionalProperties: false,
1312
+ properties: {
1313
+ at: { type: 'number', required: true },
1314
+ url: { type: 'string', required: true },
1315
+ title: { type: 'string' },
1316
+ session: { type: 'string' },
1317
+ },
1318
+ },
1319
+ },
1320
+ },
1321
+ },
1322
+ render: (_args, value) => {
1323
+ const entries = value.entries;
1324
+ if (entries.length === 0) {
1325
+ return [{ type: 'text', text: '(no recorded visits — nothing has been browsed yet, or history recording is turned off in settings)' }];
1326
+ }
1327
+ return [{
1328
+ type: 'text',
1329
+ text: entries.map(entry => {
1330
+ const when = new Date(entry.at).toISOString().replace('T', ' ').slice(0, 19);
1331
+ const title = entry.title !== undefined ? `${entry.title} — ` : '';
1332
+ return `${when} ${title}${entry.url}${entry.session !== undefined ? ` [${entry.session}]` : ''}`;
1333
+ }).join('\n'),
1334
+ }];
1335
+ },
1336
+ },
1337
+ timeoutMs,
1338
+ isConcurrencySafe: () => true,
1339
+ async execute(args, _exec) {
1340
+ const browser = ctx.get('browser');
1341
+ if (browser === undefined)
1342
+ throw new Error('tool-browser: browser service unavailable');
1343
+ const rawLimit = typeof args.limit === 'number' && Number.isFinite(args.limit) ? args.limit : 30;
1344
+ const limit = Math.max(1, Math.min(200, Math.floor(rawLimit)));
1345
+ const domain = typeof args.domain === 'string' && args.domain.trim() !== '' ? args.domain.trim() : undefined;
1346
+ const entries = browser.visited({ limit, ...domain !== undefined ? { domain } : {} });
1347
+ return {
1348
+ count: entries.length,
1349
+ entries: entries.map(entry => ({
1350
+ at: entry.at,
1351
+ url: entry.url,
1352
+ ...entry.title !== undefined ? { title: entry.title } : {},
1353
+ ...entry.session !== undefined ? { session: entry.session } : {},
1354
+ })),
1355
+ };
1356
+ },
1357
+ }));
1288
1358
  ctx.tools.register(defineTool({
1289
1359
  name: 'browser_replay',
1290
1360
  description: 'Replay one recorded browser operation by its history sequence number (from browser_history). Navigate/click/type are re-issued against the current page; execute re-runs its script. The replayed step is appended to history as a new entry.',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-builtin-browser",
3
- "version": "0.1.22",
3
+ "version": "0.2.0",
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",
@@ -25,6 +25,9 @@
25
25
  "./host-main": {
26
26
  "default": "./lib/browser-electron/host-main.js"
27
27
  },
28
+ "./client": {
29
+ "default": "./client.js"
30
+ },
28
31
  "./cordis.patch.yml": "./cordis.patch.yml",
29
32
  "./package.json": "./package.json"
30
33
  },
@@ -32,6 +35,7 @@
32
35
  "lib",
33
36
  "src",
34
37
  "docs",
38
+ "client.js",
35
39
  "cordis.patch.yml",
36
40
  "README.md",
37
41
  "README.en.md",
@@ -65,9 +69,9 @@
65
69
  },
66
70
  "peerDependencies": {
67
71
  "@deepseek-ai/cordis": "^4.0.1",
68
- "@deepseek-ai/dsh-llm": "^0.1.1-rc.2",
69
- "@deepseek-ai/dsh-tools": "^0.1.1-rc.2",
70
- "@deepseek-ai/dsh-system-prompt": "^0.1.1-rc.2",
72
+ "@deepseek-ai/dsh-llm": ">=0.1.1-rc.2 <0.3.0",
73
+ "@deepseek-ai/dsh-tools": ">=0.1.1-rc.2 <0.3.0",
74
+ "@deepseek-ai/dsh-system-prompt": ">=0.1.1-rc.2 <0.3.0",
71
75
  "@deepseek-ai/schemastery": "^3.18.1"
72
76
  },
73
77
  "devDependencies": {
@@ -78,8 +82,16 @@
78
82
  "bundle": {
79
83
  "patch": "./cordis.patch.yml"
80
84
  },
85
+ "client": {
86
+ "platform": "web",
87
+ "inject": [
88
+ "@deepseek-ai/dsh-client-locale",
89
+ "@deepseek-ai/dsh-client-ui-slots",
90
+ "@deepseek-ai/dsh-client-ui-settings"
91
+ ]
92
+ },
81
93
  "compatibility": {
82
- "dsh": ">=0.1.1-rc.1 <0.2.0",
94
+ "dsh": ">=0.1.1-rc.1 <0.3.0",
83
95
  "profiles": [
84
96
  "web",
85
97
  "desktop"
@@ -87,7 +99,9 @@
87
99
  "dshReleases": {
88
100
  "0.1.0-rc.8": "unknown",
89
101
  "0.1.1-rc.1": "compatible",
90
- "0.1.1-rc.2": "compatible"
102
+ "0.1.1-rc.2": "compatible",
103
+ "0.2.0-rc.1": "compatible",
104
+ "0.2.0-rc.2": "compatible"
91
105
  }
92
106
  }
93
107
  }
@@ -47,6 +47,7 @@ import type {
47
47
  BrowserKeyRequest,
48
48
  BrowserChallenge,
49
49
  ExportedCookie,
50
+ VisitedPage,
50
51
  } from './types.js'
51
52
  import { BrowserError } from './types.js'
52
53
 
@@ -177,7 +178,21 @@ export class BrowserRuntime extends Service {
177
178
  const usable = [...providers.values()].filter(provider => provider.available())
178
179
  const [single] = usable
179
180
  if (single === undefined) {
180
- throw new BrowserError('no usable browser provider is registered', 'BROWSER_PROVIDER_UNAVAILABLE')
181
+ // Two very different causes used to read identically here — nothing is
182
+ // registered, or something is registered but cannot run — and only the
183
+ // second one has a fix the user can apply without reinstalling.
184
+ const registered = [...providers.values()].map(provider => provider.id)
185
+ if (registered.length === 0) {
186
+ throw new BrowserError(
187
+ 'no browser provider is registered — install a browser plugin (e.g. dsh-builtin-browser) and enable it in this profile',
188
+ 'BROWSER_PROVIDER_UNAVAILABLE',
189
+ )
190
+ }
191
+ throw new BrowserError(
192
+ `browser provider(s) ${registered.join(', ')} are registered but report themselves unavailable — `
193
+ + 'for dsh-builtin-browser this means no spawnable Electron binary was found; run `npx install-electron` in the active profile or set ELECTRON_PATH',
194
+ 'BROWSER_PROVIDER_UNAVAILABLE',
195
+ )
181
196
  }
182
197
  if (usable.length > 1) {
183
198
  const ids = usable.map(provider => provider.id).join(', ')
@@ -331,6 +346,27 @@ export class BrowserRuntime extends Service {
331
346
  return this.resolveProvider().history(session)
332
347
  }
333
348
 
349
+ /**
350
+ * Persisted visits, newest first — the record that outlives the browser
351
+ * process, as opposed to {@link history}'s session-scoped operation log.
352
+ * @param options - result cap and an optional hostname filter.
353
+ * @returns the retained visits (empty when history recording is disabled).
354
+ */
355
+ visited(options?: { readonly limit?: number; readonly domain?: string }): readonly VisitedPage[] {
356
+ return this.resolveProvider().visited(options)
357
+ }
358
+
359
+ /**
360
+ * Whether a session is still live. Sessions can end outside the tool layer
361
+ * (the human closed its window), so a cached id must be re-checked before
362
+ * reuse; a provider that does not implement the check counts as live.
363
+ * @param session - the session id to test.
364
+ */
365
+ exists(session: BrowserSessionId): boolean {
366
+ const provider = this.resolveProvider()
367
+ return provider.exists === undefined ? true : provider.exists(session)
368
+ }
369
+
334
370
  /** Replay one recorded operation by sequence number through the provider. */
335
371
  async replay(session: BrowserSessionId, seq: number): Promise<void> {
336
372
  return this.resolveProvider().replay(session, seq)
@@ -573,12 +573,43 @@ export interface BrowserProvider {
573
573
  restoreAuth(session: BrowserSessionId, cookies: readonly ExportedCookie[]): Promise<number>
574
574
  /** Return the session's chronological operation log. */
575
575
  history(session: BrowserSessionId): Promise<readonly BrowserHistoryEntry[]>
576
+ /**
577
+ * Persisted visits, newest first: what survives the browser process, so a
578
+ * human or an Agent can find a page again after the interface was closed.
579
+ * @param options - result cap and an optional hostname filter.
580
+ * @returns the retained visits (empty when history is disabled).
581
+ */
582
+ visited(options?: { readonly limit?: number; readonly domain?: string }): readonly VisitedPage[]
583
+ /**
584
+ * Optional: whether a session is still live. A session can end outside the
585
+ * tool layer — the human closed the browser window — so a cached id must be
586
+ * checked before it is reused. Providers that never end a session on their own
587
+ * can omit this, and callers treat absence as "still live".
588
+ * @param session - the session id to test.
589
+ */
590
+ exists?(session: BrowserSessionId): boolean
576
591
  /** Replay one recorded operation by sequence number. */
577
592
  replay(session: BrowserSessionId, seq: number): Promise<void>
578
593
  /** Close the session and destroy its backing surface. Idempotent. */
579
594
  close(session: BrowserSessionId): Promise<void>
580
595
  }
581
596
 
597
+ /**
598
+ * One visited page in the persistent browsing history — the durable record that
599
+ * outlives sessions, unlike {@link BrowserHistoryEntry}, which is a
600
+ * session-scoped operation log (navigate/click/type/…).
601
+ */
602
+ export interface VisitedPage {
603
+ /** Epoch milliseconds when the visit was recorded. */
604
+ readonly at: number
605
+ /** Absolute URL of the visited document. */
606
+ readonly url: string
607
+ /** Document title at visit time, when the page exposed one. */
608
+ readonly title?: string
609
+ /** Session/task label that drove the visit, when the provider knows it. */
610
+ readonly session?: string
611
+ }
612
+
582
613
  /** One recorded browser operation, in chronological order (seq 1, 2, 3…). */
583
614
  export interface BrowserHistoryEntry {
584
615
  /** Monotonic sequence number within the session. */
@@ -11,11 +11,13 @@
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'
18
19
  import { defaultHostMainPath, RemoteElectronViewHost } from './remote-host.js'
20
+ import { SettingsStore } from './settings-store.js'
19
21
 
20
22
  export {
21
23
  ELECTRON_BROWSER_PROVIDER_ID,
@@ -62,6 +64,9 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
62
64
  // External host (desktop shell) wins; otherwise self-host. The self-hosted
63
65
  // child is disposed with the fiber, mirroring the shell's lifetime.
64
66
  const host: ElectronBrowserViewHost = config.viewHost ?? new RemoteElectronViewHost(defaultHostMainPath())
67
+ // One settings document per plugin instance: the settings panel writes it, the
68
+ // provider reads it live, and both ends agree on the same file.
69
+ const settings = new SettingsStore()
65
70
  // Own the disposer on THIS plugin's fiber: registerBrowserProvider's effect
66
71
  // is bound to the seam's own fiber (the browser row), so a reload of this
67
72
  // row would otherwise collide with the still-registered provider
@@ -71,6 +76,7 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
71
76
  downloadDir: config.downloadDir,
72
77
  snapshotMaxElements: config.snapshotMaxElements,
73
78
  contentMaxChars: config.contentMaxChars,
79
+ settings: () => settings.get(),
74
80
  }))
75
81
  ctx.effect(() => () => {
76
82
  unregister()
@@ -78,4 +84,133 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
78
84
  host.dispose()
79
85
  }
80
86
  })
87
+ installSettingsRoute(ctx, settings)
88
+ }
89
+
90
+ /** Route the settings panel reads and writes. */
91
+ const SETTINGS_ROUTE = '/dsh-builtin-browser/settings'
92
+
93
+ /** The host web-server surface this plugin uses, described structurally. */
94
+ interface WebServerHost {
95
+ readonly webServer: {
96
+ register(
97
+ spec: {
98
+ readonly kind: 'exact'
99
+ readonly path: string
100
+ readonly handler: (request: IncomingMessage, response: ServerResponse) => void | Promise<void>
101
+ },
102
+ label?: string,
103
+ ): () => void
104
+ }
105
+ effect(callback: () => (() => void) | void): void
106
+ }
107
+
108
+ /**
109
+ * Expose the settings document over the host's web server, which is how the
110
+ * settings panel reads and writes it. A host without that service (headless /
111
+ * CLI surfaces) simply skips this: the panel is then unreachable and the file
112
+ * stays hand-editable — never a plugin-startup failure.
113
+ * @param ctx - the plugin context.
114
+ * @param settings - the settings document to expose.
115
+ */
116
+ function installSettingsRoute(ctx: Context, settings: SettingsStore): void {
117
+ const inject = (ctx as unknown as {
118
+ inject?: (deps: readonly string[], callback: (host: WebServerHost) => void) => void
119
+ }).inject
120
+ if (typeof inject !== 'function') return
121
+ try {
122
+ inject(['webServer'], (host) => {
123
+ host.effect(() => host.webServer.register({
124
+ kind: 'exact',
125
+ path: SETTINGS_ROUTE,
126
+ handler: (request, response) => handleSettingsRequest(request, response, settings),
127
+ }, 'dsh-builtin-browser: settings'))
128
+ })
129
+ } catch {
130
+ // No web server on this surface: the settings file remains the interface.
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Same-origin guard. Settings are machine-local configuration, so a page loaded
136
+ * elsewhere must not be able to read or rewrite them.
137
+ * @param request - the incoming request.
138
+ * @returns whether the request may touch the settings document.
139
+ */
140
+ function sameOrigin(request: IncomingMessage): boolean {
141
+ const site = String(request.headers['sec-fetch-site'] ?? '').toLowerCase()
142
+ if (site === 'cross-site') return false
143
+ const origin = String(request.headers.origin ?? '').trim()
144
+ if (origin === 'null') return false
145
+ if (origin === '') return true
146
+ try {
147
+ return new URL(origin).host.toLowerCase() === String(request.headers.host ?? '').trim().toLowerCase()
148
+ } catch {
149
+ return false
150
+ }
151
+ }
152
+
153
+ /**
154
+ * One settings round-trip: `GET` reads the document, `PUT`/`POST` merges a
155
+ * partial patch into it.
156
+ * @param request - the incoming request.
157
+ * @param response - the response to write.
158
+ * @param settings - the settings document.
159
+ */
160
+ async function handleSettingsRequest(
161
+ request: IncomingMessage,
162
+ response: ServerResponse,
163
+ settings: SettingsStore,
164
+ ): Promise<void> {
165
+ const json = (status: number, body: unknown): void => {
166
+ response.writeHead(status, {
167
+ 'content-type': 'application/json; charset=utf-8',
168
+ 'cache-control': 'no-store',
169
+ })
170
+ response.end(JSON.stringify(body))
171
+ }
172
+ if (!sameOrigin(request)) {
173
+ json(403, { ok: false, error: 'cross-origin request refused' })
174
+ return
175
+ }
176
+ const method = request.method ?? 'GET'
177
+ if (method === 'GET') {
178
+ json(200, { ok: true, settings: settings.get(), path: settings.path() })
179
+ return
180
+ }
181
+ if (method !== 'PUT' && method !== 'POST') {
182
+ response.writeHead(405, { allow: 'GET, PUT' })
183
+ response.end()
184
+ return
185
+ }
186
+ try {
187
+ const body = await readBody(request)
188
+ const patch: unknown = body.trim() === '' ? {} : JSON.parse(body)
189
+ json(200, { ok: true, settings: settings.update(patch) })
190
+ } catch (error) {
191
+ json(400, { ok: false, error: `invalid settings patch: ${String(error)}` })
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Read a request body with a hard cap (the settings patch is tiny, and an
197
+ * unbounded read would let any same-origin caller exhaust memory).
198
+ * @param request - the incoming request.
199
+ * @param limit - maximum accepted characters.
200
+ * @returns the body text.
201
+ */
202
+ function readBody(request: IncomingMessage, limit = 64 * 1024): Promise<string> {
203
+ return new Promise<string>((resolve, reject) => {
204
+ let text = ''
205
+ request.setEncoding('utf8')
206
+ request.on('data', chunk => {
207
+ text += String(chunk)
208
+ if (text.length > limit) {
209
+ request.destroy()
210
+ reject(new Error('settings patch too large'))
211
+ }
212
+ })
213
+ request.on('end', () => resolve(text))
214
+ request.on('error', reject)
215
+ })
81
216
  }