dsh-builtin-browser 0.4.6 → 0.4.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.en.md +62 -491
  3. package/README.md +44 -470
  4. package/client.js +10 -10
  5. package/cordis.patch.yml +4 -4
  6. package/desktop-bridge/plugin-browser-bridge.js +1 -1
  7. package/docs/README.md +8 -4
  8. package/docs/architecture.md +82 -11
  9. package/docs/development.md +38 -0
  10. package/docs/limitations.md +49 -0
  11. package/docs/tool-reference.md +42 -42
  12. package/docs/user-guide.md +93 -37
  13. package/docs/why-browser.md +3 -3
  14. package/lib/browser/types.d.ts +2 -2
  15. package/lib/browser-electron/bridge-connection.d.ts +7 -1
  16. package/lib/browser-electron/bridge-connection.js +58 -3
  17. package/lib/browser-electron/desktop-bridge-host.d.ts +11 -0
  18. package/lib/browser-electron/desktop-bridge-host.js +21 -1
  19. package/lib/browser-electron/entry.d.ts +2 -2
  20. package/lib/browser-electron/entry.js +186 -103
  21. package/lib/browser-electron/history-store.d.ts +1 -1
  22. package/lib/browser-electron/history-store.js +1 -1
  23. package/lib/browser-electron/host-main.js +3 -3
  24. package/lib/browser-electron/provider.d.ts +32 -8
  25. package/lib/browser-electron/provider.js +97 -16
  26. package/lib/browser-electron/remote-host.js +1 -1
  27. package/lib/browser-electron/settings-store.d.ts +14 -6
  28. package/lib/browser-electron/settings-store.js +29 -9
  29. package/lib/index.d.ts +1 -1
  30. package/lib/index.js +1 -1
  31. package/lib/tool-browser/element-fields.d.ts +4 -4
  32. package/lib/tool-browser/element-fields.js +6 -6
  33. package/lib/tool-browser/index.d.ts +6 -6
  34. package/lib/tool-browser/index.js +196 -139
  35. package/package.json +2 -2
  36. package/scripts/verify-host-compat.mjs +6 -6
  37. package/src/browser/types.ts +2 -2
  38. package/src/browser-electron/bridge-connection.ts +61 -3
  39. package/src/browser-electron/desktop-bridge-host.ts +22 -1
  40. package/src/browser-electron/entry.ts +101 -9
  41. package/src/browser-electron/history-store.ts +1 -1
  42. package/src/browser-electron/host-main.ts +3 -3
  43. package/src/browser-electron/provider.ts +135 -31
  44. package/src/browser-electron/remote-host.ts +1 -1
  45. package/src/browser-electron/settings-store.ts +32 -13
  46. package/src/index.ts +1 -1
  47. package/src/tool-browser/element-fields.ts +6 -6
  48. package/src/tool-browser/index.ts +197 -140
@@ -217,6 +217,17 @@ export interface ElectronBrowserViewHost {
217
217
  * which is only correct from a teardown that is itself global.
218
218
  */
219
219
  releasePage?(owner?: string): Promise<void>
220
+ /**
221
+ * Report that this carrier can no longer serve anything, so its owner may replace it.
222
+ *
223
+ * A carrier that borrows an outside process can lose it: the desktop shell exits, and every call
224
+ * after that would dial a port nobody owns. Without this signal the swap happens only at
225
+ * startup, so the choice made then is final for the process's life — which is what a user
226
+ * experiences as "the browser stopped working until I restart DSH". Optional: a carrier that
227
+ * owns its own lifetime has nothing to report.
228
+ * @param listener - called once, when the carrier concludes it is gone.
229
+ */
230
+ onLost?(listener: () => void): void
220
231
  /**
221
232
  * Fold the carrier's presentation away while keeping the page alive.
222
233
  *
@@ -312,10 +323,21 @@ export interface ElectronViewHandle {
312
323
  capture?(opts?: ScreenshotOptions): Promise<{ base64: string; mime: string; width?: number; height?: number }>
313
324
  }
314
325
 
315
- /** One tab inside a session: its view plus a stable id. */
326
+ /**
327
+ * One tab inside a session: its view, a stable id, and the last URL this provider accepted.
328
+ *
329
+ * `url` does two jobs. It separates "never navigated" from "navigated, but the URL could not be
330
+ * read" — the two used to collapse into the same empty string — and it lets `listTabs` skip
331
+ * probing a tab it already knows is blank, where a probe is a round trip on an uncommitted view
332
+ * that can only answer nothing.
333
+ *
334
+ * `handle` is deliberately NOT readonly: a view that has wedged is replaced in place, and the tab
335
+ * keeps its public id across that replacement (see `replaceTabWithBlank`).
336
+ */
316
337
  interface Tab {
317
338
  readonly id: string
318
- readonly handle: ElectronViewHandle
339
+ handle: ElectronViewHandle
340
+ url: string
319
341
  }
320
342
 
321
343
  /** One live browser session: an ordered list of tabs, one active. */
@@ -371,7 +393,7 @@ export interface ElectronBrowserProviderConfig {
371
393
  */
372
394
  readonly contentMaxChars?: number
373
395
  /**
374
- * Directory `browser_download` save paths must resolve inside (prevents a
396
+ * Directory `dsh_browser_download` save paths must resolve inside (prevents a
375
397
  * prompt-injected agent from writing arbitrary machine paths). Default:
376
398
  * `~/Downloads`. NOTE: this default is a convenience, not a security
377
399
  * boundary — on Windows it may resolve to OneDrive/redirected paths;
@@ -455,7 +477,7 @@ export function layoutSize(metrics: Record<string, unknown>): { width: number; h
455
477
  }
456
478
 
457
479
  /**
458
- * Map CDP cookies onto the shape `browser_auth` exports.
480
+ * Map CDP cookies onto the shape `dsh_browser_auth` exports.
459
481
  *
460
482
  * CDP reports a cookie as domain + path rather than a URL, and in seconds rather than
461
483
  * milliseconds, so both are converted. Entries missing a name, value or domain are
@@ -717,7 +739,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
717
739
  // session), carrying the label for the window title. Hosts without
718
740
  // groupView keep the shared-window behavior.
719
741
  this.host.groupView?.(handle, id, label)
720
- this.sessions.set(id, { id, label, tabs: [{ id: `tab:${randomUUID()}`, handle }], activeIndex: 0, history: [], nextSeq: 1 })
742
+ this.sessions.set(id, { id, label, tabs: [{ id: `tab:${randomUUID()}`, handle, url: '' }], activeIndex: 0, history: [], nextSeq: 1 })
721
743
  return Promise.resolve(id)
722
744
  }
723
745
 
@@ -737,9 +759,15 @@ export class ElectronBrowserProvider implements BrowserProvider {
737
759
  for (let i = 0; i < s.tabs.length; i++) {
738
760
  const tab = s.tabs[i]
739
761
  if (tab === undefined) continue // defensive: array can shift under concurrency
762
+ // A tab we know has never navigated is NOT probed: its view has committed no document, so
763
+ // the round trip can only answer nothing — and it is the round trip that costs. A tab that
764
+ // HAS navigated is re-read, because the page may have moved itself since; a read that fails
765
+ // keeps the last accepted URL rather than reporting the tab as blank.
766
+ const liveUrl = tab.url === '' ? '' : await this.currentUrl(tab.handle).catch(() => tab.url)
767
+ if (liveUrl !== '') tab.url = liveUrl
740
768
  result.push({
741
769
  id: tab.id,
742
- url: await this.currentUrl(tab.handle).catch(() => ''),
770
+ url: liveUrl,
743
771
  active: i === s.activeIndex,
744
772
  })
745
773
  }
@@ -825,7 +853,8 @@ export class ElectronBrowserProvider implements BrowserProvider {
825
853
  /** Navigate the active tab's view to a URL, honoring HTTP(S)-only admission. */
826
854
  async navigate(session: BrowserSessionId, request: { readonly url: string }, signal?: AbortSignal): Promise<void> {
827
855
  const s = this.session(session)
828
- const { handle } = this.activeTab(s)
856
+ const tab = this.activeTab(s)
857
+ const { handle } = tab
829
858
  const url = request.url
830
859
  try {
831
860
  if (this.httpOnly) {
@@ -870,6 +899,11 @@ export class ElectronBrowserProvider implements BrowserProvider {
870
899
  // the visit written to the persistent history for a navigation nobody awaits any more.
871
900
  signal?.throwIfAborted()
872
901
  this.record(s, 'navigate', { url }, true)
902
+ // The tab now holds a committed document, which is what the blank short-circuit in
903
+ // `snapshot`/`a11y` keys on. Recorded here rather than after the settle below: the
904
+ // navigation HAS committed, and a tab wrongly treated as blank would return empty results
905
+ // for a page that is really there.
906
+ tab.url = url
873
907
  await this.settleDocument(handle, before, signal)
874
908
  this.recordVisit(s, handle)
875
909
  this.showActive(s)
@@ -921,9 +955,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
921
955
  /**
922
956
  * Page.navigate resolves at navigation COMMIT, not at load: the new document
923
957
  * is already current (so location.href and document.title are the new page's)
924
- * while its DOM is still being parsed. browser_open snapshots immediately
958
+ * while its DOM is still being parsed. dsh_browser_open snapshots immediately
925
959
  * after navigating, which is why it could report the right title with zero
926
- * interactive elements — and why a separate browser_snapshot right after
960
+ * interactive elements — and why a separate dsh_browser_snapshot right after
927
961
  * always found them.
928
962
  *
929
963
  * Wait — bounded, best-effort — for the new document to settle: readyState
@@ -1043,7 +1077,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
1043
1077
  *
1044
1078
  * A renderer only delivers key events to the view that holds web focus. A
1045
1079
  * view that was created but never clicked holds none — and `Input` events
1046
- * injected over CDP do not grant it — so the FIRST `browser_key` of a fresh
1080
+ * injected over CDP do not grant it — so the FIRST `dsh_browser_key` of a fresh
1047
1081
  * session vanished inside the renderer while the command still answered
1048
1082
  * success. Hosts that cannot focus a view (a test double, a different shell
1049
1083
  * adapter) simply have no `focus`, and nothing changes for them.
@@ -1052,7 +1086,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
1052
1086
  if (handle.focus === undefined) return
1053
1087
  try {
1054
1088
  // Bounded, unlike the rest of this call: the child RPC has no timeout of its own,
1055
- // and this runs before typing, so an unresponsive child would hang browser_key for
1089
+ // and this runs before typing, so an unresponsive child would hang dsh_browser_key for
1056
1090
  // good. A timeout is caught below exactly like any other focus failure, so the
1057
1091
  // best-effort contract is unchanged.
1058
1092
  await withTimeout(handle.focus(), 5_000, undefined, 'browser: focusing the view timed out')
@@ -1205,6 +1239,10 @@ export class ElectronBrowserProvider implements BrowserProvider {
1205
1239
  const s = this.session(session)
1206
1240
  const tab = this.activeTab(s)
1207
1241
  signal?.throwIfAborted()
1242
+ // A tab that has never navigated is a blank, uncommitted view. The collector cannot answer in
1243
+ // it — there is no document to commit — so the call would sit out its whole budget before
1244
+ // saying anything. Answering the empty page immediately is both faster and more truthful.
1245
+ if (tab.url === '') return { url: '', elements: [], truncated: false }
1208
1246
  const script = `(() => {
1209
1247
  const cap = ${String(this.snapshotMaxElements)}
1210
1248
  const url = location.href
@@ -1277,13 +1315,31 @@ export class ElectronBrowserProvider implements BrowserProvider {
1277
1315
  // Same hang guard as execute: a renderer that has not committed after
1278
1316
  // navigate would otherwise block snapshot forever.
1279
1317
  const timeoutMs = 30_000
1280
- const result = await withTimeout(
1281
- handleSendEvaluate(tab.handle, script),
1282
- timeoutMs,
1283
- signal,
1284
- `browser: snapshot timed out after ${timeoutMs}ms`,
1285
- () => terminatePage(tab.handle),
1286
- )
1318
+ let result: { ok: true; value: unknown } | { ok: false; exception: string }
1319
+ try {
1320
+ result = await withTimeout(
1321
+ handleSendEvaluate(tab.handle, script),
1322
+ timeoutMs,
1323
+ signal,
1324
+ `browser: snapshot timed out after ${timeoutMs}ms`,
1325
+ () => terminatePage(tab.handle),
1326
+ ) as { ok: true; value: unknown } | { ok: false; exception: string }
1327
+ } catch (error) {
1328
+ // A timeout leaves the wedged view in the session, so every later read would pay the same
1329
+ // 30 seconds again — and `terminatePage` only interrupts the running script, it does not
1330
+ // make the view readable. Replacing it is what turns a permanent loss into one bad call:
1331
+ // the tab keeps the id the caller holds, and the next dsh_browser_open with the real URL
1332
+ // repopulates it.
1333
+ if (error instanceof Error && error.name === 'TimeoutError') {
1334
+ this.replaceTabWithBlank(s, tab)
1335
+ throw new BrowserError(
1336
+ 'browser: snapshot timed out; the stuck tab was reset to blank. Call dsh_browser_open with the real target URL before reading it again.',
1337
+ 'BROWSER_SNAPSHOT_TIMEOUT',
1338
+ { cause: error },
1339
+ )
1340
+ }
1341
+ throw error
1342
+ }
1287
1343
  if (!result.ok) throw new BrowserError(`browser: snapshot evaluation failed: ${result.exception}`, 'BROWSER_SNAPSHOT_FAILED')
1288
1344
  const value = result.value as BrowserSnapshotResult
1289
1345
  return value
@@ -1301,6 +1357,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
1301
1357
  const s = this.session(session)
1302
1358
  const tab = this.activeTab(s)
1303
1359
  signal?.throwIfAborted()
1360
+ // Same short-circuit as `snapshot`: a tab that never navigated has no committed document, so
1361
+ // the tree cannot be read and the call would spend its whole budget discovering that.
1362
+ if (tab.url === '') return { url: '', title: undefined, count: 0, nodes: [], truncated: false }
1304
1363
  const includeHidden = request.includeHidden === true
1305
1364
  // 150 rather than 500. The cap fills on any real page, and 500 nodes measured 38,953
1306
1365
  // characters — 10k-13k tokens for a single call. A caller that needs more asks for it.
@@ -1462,13 +1521,28 @@ export class ElectronBrowserProvider implements BrowserProvider {
1462
1521
  return { url: location.href, title: document.title || undefined, count: out.length, nodes: out, truncated: out.length >= maxNodes }
1463
1522
  })()`
1464
1523
  const timeoutMs = 30_000
1465
- const result = await withTimeout(
1466
- handleSendEvaluate(tab.handle, script),
1467
- timeoutMs,
1468
- signal,
1469
- `browser: a11y timed out after ${timeoutMs}ms`,
1470
- () => terminatePage(tab.handle),
1471
- )
1524
+ let result: { ok: true; value: unknown } | { ok: false; exception: string }
1525
+ try {
1526
+ result = await withTimeout(
1527
+ handleSendEvaluate(tab.handle, script),
1528
+ timeoutMs,
1529
+ signal,
1530
+ `browser: a11y timed out after ${timeoutMs}ms`,
1531
+ () => terminatePage(tab.handle),
1532
+ ) as { ok: true; value: unknown } | { ok: false; exception: string }
1533
+ } catch (error) {
1534
+ // As in `snapshot`: a timeout means the view is wedged, and leaving it in the session would
1535
+ // charge every later read the same 30 seconds. Replaced in place, so the tab id survives.
1536
+ if (error instanceof Error && error.name === 'TimeoutError') {
1537
+ this.replaceTabWithBlank(s, tab)
1538
+ throw new BrowserError(
1539
+ 'browser: a11y timed out; the stuck tab was reset to blank. Call dsh_browser_open with the real target URL before reading it again.',
1540
+ 'BROWSER_A11Y_TIMEOUT',
1541
+ { cause: error },
1542
+ )
1543
+ }
1544
+ throw error
1545
+ }
1472
1546
  if (!result.ok) throw new BrowserError(`browser: a11y evaluation failed: ${result.exception}`, 'BROWSER_A11Y_FAILED')
1473
1547
  const value = result.value as BrowserA11yResult
1474
1548
  this.record(s, 'a11y', { includeHidden, maxNodes }, true, { result: `${value.count} nodes` })
@@ -1929,7 +2003,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
1929
2003
  placeholder: f.placeholder ?? null,
1930
2004
  // A field with NO kind is a field with no kind FILTER: absent, not 'text'. Defaulting
1931
2005
  // it to 'text' here made `if (spec.kind)` above always true, so every unspecified
1932
- // field was narrowed to an <input> — browser_fill on a <textarea> reported
2006
+ // field was narrowed to an <input> — dsh_browser_fill on a <textarea> reported
1933
2007
  // `no "text" control matched`, and a contenteditable could not match any kind at all.
1934
2008
  // The value stays a string or null (never undefined): the in-page check tests truthiness,
1935
2009
  // and JSON drops an undefined member entirely, which is the same thing here.
@@ -2064,7 +2138,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
2064
2138
  el.dispatchEvent(new Event('change', { bubbles: true }))
2065
2139
  out.push({ ok: true, method: 'select', target: describe(spec) })
2066
2140
  } else if (type === 'file') {
2067
- out.push({ ok: false, error: 'file inputs cannot be set from script; use browser_download or ask the human', target: describe(spec) })
2141
+ out.push({ ok: false, error: 'file inputs cannot be set from script; use dsh_browser_download or ask the human', target: describe(spec) })
2068
2142
  } else if (type === 'checkbox') {
2069
2143
  const want = spec.value === true || spec.value === 'true' || spec.value === 'on'
2070
2144
  if (el.checked !== want) el.click()
@@ -2518,7 +2592,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
2518
2592
  * Refuse an action the settings have switched off.
2519
2593
  *
2520
2594
  * These switches belong to the OPERATOR. The settings document is written through the
2521
- * plugin's own panel and no tool can reach it, so — unlike `browser_restrict`, which the
2595
+ * plugin's own panel and no tool can reach it, so — unlike `dsh_browser_restrict`, which the
2522
2596
  * model owns and can lift at will — a refusal here stands for as long as the setting does.
2523
2597
  * That is the point of them: a deployment can take page-script execution, downloads, or
2524
2598
  * login-state writes off the table without relying on the model's cooperation.
@@ -2545,7 +2619,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
2545
2619
 
2546
2620
  /**
2547
2621
  * Admit a caller-supplied save path for a file the browser writes to disk.
2548
- * ONE gate for both `browser_download` and `browser_screenshot`: the path
2622
+ * ONE gate for both `dsh_browser_download` and `dsh_browser_screenshot`: the path
2549
2623
  * must be absolute, must resolve inside `downloadDir`, and must not already
2550
2624
  * exist. Without it a prompt-injected agent could write — or silently
2551
2625
  * replace — any file the DSH process can reach, which also escapes the file
@@ -2979,7 +3053,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
2979
3053
  }
2980
3054
 
2981
3055
  /**
2982
- * List persisted visits, newest first — what `browser_visited` reads back.
3056
+ * List persisted visits, newest first — what `dsh_browser_visited` reads back.
2983
3057
  * @param options - result cap and an optional hostname filter.
2984
3058
  * @returns the matching visits, or an empty list when history is disabled.
2985
3059
  */
@@ -3116,11 +3190,41 @@ export class ElectronBrowserProvider implements BrowserProvider {
3116
3190
  const handle = this.host.createView(s.label)
3117
3191
  // Same window as the session's other tabs.
3118
3192
  this.host.groupView?.(handle, s.id, s.label)
3119
- s.tabs.push({ id: `tab:${randomUUID()}`, handle })
3193
+ s.tabs.push({ id: `tab:${randomUUID()}`, handle, url: '' })
3120
3194
  s.activeIndex = s.tabs.length - 1
3121
3195
  this.showActive(s)
3122
3196
  }
3123
3197
 
3198
+ /**
3199
+ * Swap a wedged view for a fresh blank one, keeping the tab's public id.
3200
+ *
3201
+ * A view whose renderer never commits cannot be read again: every evaluate into it runs out its
3202
+ * whole budget. Replacing it is what turns that from permanent into one bad call — the tab id
3203
+ * callers hold stays valid, and the next `dsh_browser_open` with the real URL re-populates it.
3204
+ *
3205
+ * The old view is destroyed AFTER the new one is in place, so a failure here cannot leave the
3206
+ * session holding a handle that is already gone.
3207
+ * @param s - the session owning the tab.
3208
+ * @param tab - the tab to reset.
3209
+ */
3210
+ private replaceTabWithBlank(s: Session, tab: Tab): void {
3211
+ const index = s.tabs.indexOf(tab)
3212
+ if (index < 0) return
3213
+ let handle: ElectronViewHandle
3214
+ try {
3215
+ handle = this.host.createView(s.label)
3216
+ this.host.groupView?.(handle, s.id, s.label)
3217
+ } catch {
3218
+ // Creating the replacement failed: leaving the wedged tab in place is worse than nothing
3219
+ // only for the caller, who is about to be told the tab timed out either way.
3220
+ return
3221
+ }
3222
+ const wedged = tab.handle
3223
+ s.tabs[index] = { id: tab.id, handle, url: '' }
3224
+ if (index === s.activeIndex) this.showActive(s)
3225
+ this.host.destroyView(wedged)
3226
+ }
3227
+
3124
3228
  /** Find a session's tab by its backing view id (toolbar actions carry view ids). */
3125
3229
  private tabByViewId(s: Session, viewId: string): Tab | undefined {
3126
3230
  return s.tabs.find(tab => tab.handle.id === viewId)
@@ -1327,7 +1327,7 @@ class DeferredRemoteView implements ElectronViewHandle {
1327
1327
  * with "browser host is not running". Instead: drop the cache, rebuild the
1328
1328
  * view against a freshly spawned child, and retry the operation exactly
1329
1329
  * once — so sessions that survive a host death self-heal on their FIRST
1330
- * call after it, without a manual browser_reset_session. Page-level errors
1330
+ * call after it, without a manual dsh_browser_reset_session. Page-level errors
1331
1331
  * are never retried (the view itself is fine).
1332
1332
  */
1333
1333
  private async withRecovery<T>(op: (view: RemoteView) => Promise<T>): Promise<T> {
@@ -55,15 +55,15 @@ export interface BrowserSettings {
55
55
  *
56
56
  * These are OPERATOR switches: the settings panel is not reachable from the model, so a
57
57
  * value here refuses whatever the model asks for, and no tool can lift it. That is what
58
- * separates them from `browser_restrict`, which is the model's own soft guardrail. All
58
+ * separates them from `dsh_browser_restrict`, which is the model's own soft guardrail. All
59
59
  * three default to on, which is the documented out-of-the-box behaviour.
60
60
  */
61
61
  readonly actions: {
62
- /** Run arbitrary page scripts in the active tab (`browser_execute`). */
62
+ /** Run arbitrary page scripts in the active tab (`dsh_browser_execute`). */
63
63
  readonly allowExecute: boolean
64
- /** Download a URL to disk (`browser_download`). */
64
+ /** Download a URL to disk (`dsh_browser_download`). */
65
65
  readonly allowDownload: boolean
66
- /** Write cookies — importing login state (`browser_auth` restore). */
66
+ /** Write cookies — importing login state (`dsh_browser_auth` restore). */
67
67
  readonly allowCredentialWrite: boolean
68
68
  }
69
69
  }
@@ -129,16 +129,35 @@ const SETTINGS_WHEN_UNREADABLE: BrowserSettings = {
129
129
  actions: { allowExecute: false, allowDownload: false, allowCredentialWrite: false },
130
130
  }
131
131
 
132
+ /**
133
+ * The channels a settings document may name.
134
+ *
135
+ * Exported so the log line and the error message can quote the same set the parser accepts: a
136
+ * message that lists a value the code rejects (or omits one it takes) is worse than no message.
137
+ */
138
+ export const BROWSER_CHANNELS = ['chrome', 'edge', 'auto', 'bundled', 'sidebar'] as const
139
+
132
140
  /**
133
141
  * Coerce the browser channel, keeping the default when it is not one of the
134
142
  * known choices. A hand-edited typo must not silently select a different browser.
143
+ *
144
+ * Coercing is right; coercing QUIETLY is not. A typo and "the setting did nothing" look exactly
145
+ * the same to whoever typed it, which is why every discarded value is reported.
135
146
  * @param value - the raw value from the settings document.
147
+ * @param warn - where to report a discarded value, when the caller has somewhere to put it.
136
148
  * @returns a valid channel.
137
149
  */
138
- function resolveChannel(value: unknown): BrowserChannel {
139
- return value === 'chrome' || value === 'edge' || value === 'auto' || value === 'bundled' || value === 'sidebar'
140
- ? value
141
- : DEFAULT_SETTINGS.browser.channel
150
+ function resolveChannel(value: unknown, warn?: (message: string) => void): BrowserChannel {
151
+ // Absent is the ordinary case, not a mistake: a document that never mentioned the channel gets
152
+ // the default without a word.
153
+ if (value === undefined) return DEFAULT_SETTINGS.browser.channel
154
+ if ((BROWSER_CHANNELS as readonly unknown[]).includes(value)) return value as BrowserChannel
155
+ warn?.(
156
+ `dsh-builtin-browser: browser.channel ${JSON.stringify(value)} is not a known channel, so the `
157
+ + `default "${DEFAULT_SETTINGS.browser.channel}" was used instead. Expected one of: `
158
+ + `${BROWSER_CHANNELS.join(', ')}.`,
159
+ )
160
+ return DEFAULT_SETTINGS.browser.channel
142
161
  }
143
162
 
144
163
  /** Coerce one positive-integer field, keeping the default when invalid. */
@@ -181,7 +200,7 @@ function section(source: Record<string, unknown>, key: string): Record<string, u
181
200
  * @param raw - the parsed file contents.
182
201
  * @returns the resolved settings.
183
202
  */
184
- export function resolveSettings(raw: unknown): BrowserSettings {
203
+ export function resolveSettings(raw: unknown, warn?: (message: string) => void): BrowserSettings {
185
204
  const source = typeof raw === 'object' && raw !== null ? raw as Record<string, unknown> : {}
186
205
  const history = section(source, 'history')
187
206
  const cookies = section(source, 'cookies')
@@ -208,7 +227,7 @@ export function resolveSettings(raw: unknown): BrowserSettings {
208
227
  virtualCursor: bool(ui?.virtualCursor, DEFAULT_SETTINGS.ui.virtualCursor),
209
228
  },
210
229
  vision: { strategy: vision?.strategy === 'nonVisual' ? 'nonVisual' : DEFAULT_SETTINGS.vision.strategy },
211
- browser: { channel: resolveChannel(browser?.channel) },
230
+ browser: { channel: resolveChannel(browser?.channel, warn) },
212
231
  credentials: {
213
232
  // `gate` returns false for anything present-and-not-a-boolean, so an unreadable section
214
233
  // is refused here for free: the section itself is the "present but wrong" value.
@@ -244,7 +263,7 @@ export class SettingsStore {
244
263
  /**
245
264
  * @param file - settings file path (defaults beside the browser profile).
246
265
  */
247
- constructor(private readonly file: string = settingsPath()) {}
266
+ constructor(private readonly file: string = settingsPath(), private readonly log?: (message: string) => void) {}
248
267
 
249
268
  /** Path this store reads and writes. */
250
269
  path(): string {
@@ -291,7 +310,7 @@ export class SettingsStore {
291
310
  // value in it: the document looked correct, and the plugin quietly ran on
292
311
  // defaults — including switches the user had just turned off.
293
312
  const body = text?.replace(/^\uFEFF/, '')
294
- this.cached = body === undefined ? DEFAULT_SETTINGS : resolveSettings(JSON.parse(body))
313
+ this.cached = body === undefined ? DEFAULT_SETTINGS : resolveSettings(JSON.parse(body), m => this.log?.(m))
295
314
  } catch {
296
315
  // A document that EXISTS but cannot be parsed is not the same as no document at all: the
297
316
  // first is a corrupted or half-written file whose contents were the operator's decisions,
@@ -311,7 +330,7 @@ export class SettingsStore {
311
330
  */
312
331
  update(patch: unknown): BrowserSettings {
313
332
  const current = this.get()
314
- const merged = resolveSettings(deepMerge(current as unknown as Record<string, unknown>, patch))
333
+ const merged = resolveSettings(deepMerge(current as unknown as Record<string, unknown>, patch), m => this.log?.(m))
315
334
  try {
316
335
  mkdirSync(dirname(this.file), { recursive: true })
317
336
  writeFileAtomic(this.file, `${JSON.stringify(merged, null, 2)}\n`)
package/src/index.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * pieces. The cordis.patch.yml rows reference subpath exports:
4
4
  * - `dsh-builtin-browser/browser` -> the ctx.browser seam (Service)
5
5
  * - `dsh-builtin-browser/browser-electron` -> the Electron CDP provider
6
- * - `dsh-builtin-browser/tool-browser` -> the model-facing browser_* tools
6
+ * - `dsh-builtin-browser/tool-browser` -> the model-facing dsh_browser_* tools
7
7
  * The capability itself is composed by those rows; this root entry exists for
8
8
  * programmatic imports AND because the host's client-module scan identifies a
9
9
  * package's browser half through a loader row whose specifier resolves to the
@@ -8,9 +8,9 @@
8
8
  * and the tool's `output.schema` declared the properties. Nothing connected them, and the
9
9
  * result is this project's most repeatable bug:
10
10
  *
11
- * - `browser_a11y`'s `selector` — computed by the provider, declared by the schema,
11
+ * - `dsh_browser_a11y`'s `selector` — computed by the provider, declared by the schema,
12
12
  * printed by the renderer, and dropped by the projection. 134 tests stayed green.
13
- * - `browser_screenshot`'s `width`/`height` — measured by the provider, declared by the
13
+ * - `dsh_browser_screenshot`'s `width`/`height` — measured by the provider, declared by the
14
14
  * schema, and dropped by the projection. 172 tests stayed green. It was found a round
15
15
  * later, by reading the three places that agree and noticing the fourth did not.
16
16
  *
@@ -33,7 +33,7 @@ interface FieldSpec {
33
33
  }
34
34
 
35
35
  /**
36
- * The fields of one entry in `browser_snapshot` / `browser_open`'s `elements`.
36
+ * The fields of one entry in `dsh_browser_snapshot` / `dsh_browser_open`'s `elements`.
37
37
  *
38
38
  * `selector` is the reason this file exists; see the header. It was added to the schema and
39
39
  * the projection separately, and only one of them landed.
@@ -49,7 +49,7 @@ const ELEMENT_FIELDS = {
49
49
  } as const satisfies Record<string, FieldSpec>
50
50
 
51
51
  /**
52
- * The fields of one entry in `browser_a11y`'s `nodes`.
52
+ * The fields of one entry in `dsh_browser_a11y`'s `nodes`.
53
53
  *
54
54
  * Different shape from an element on purpose: a role and an accessible name carry meaning a
55
55
  * tag and a label string do not, and `depth` is what renders the tree.
@@ -91,7 +91,7 @@ function project(fields: Record<string, FieldSpec>, source: Record<string, unkno
91
91
  }
92
92
 
93
93
  /**
94
- * The `elements` item schema for browser_snapshot and browser_open.
94
+ * The `elements` item schema for dsh_browser_snapshot and dsh_browser_open.
95
95
  *
96
96
  * Written as a literal rather than generated, because `defineTool` validates the output schema
97
97
  * against its own `ValueSchemaSpec` and a generated `Record<string, …>` is too wide to satisfy
@@ -114,7 +114,7 @@ export const ELEMENT_ITEM_SCHEMA = {
114
114
  } as const
115
115
 
116
116
  /**
117
- * The `nodes` item schema for browser_a11y.
117
+ * The `nodes` item schema for dsh_browser_a11y.
118
118
  *
119
119
  * Same reasoning as {@link ELEMENT_ITEM_SCHEMA}: literal for the type checker, table-backed by
120
120
  * test.