dsh-builtin-browser 0.4.2 → 0.4.6

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 (49) hide show
  1. package/CHANGELOG.md +1753 -0
  2. package/README.en.md +63 -20
  3. package/README.md +60 -20
  4. package/client.js +433 -24
  5. package/desktop-bridge/install.mjs +66 -6
  6. package/desktop-bridge/plugin-browser-bridge.js +425 -163
  7. package/docs/architecture.md +4 -4
  8. package/docs/tool-reference.md +3 -3
  9. package/docs/user-guide.md +50 -2
  10. package/lib/browser/types.d.ts +9 -2
  11. package/lib/browser-electron/bridge-connection.d.ts +18 -21
  12. package/lib/browser-electron/bridge-connection.js +42 -1
  13. package/lib/browser-electron/desktop-bridge-host.d.ts +69 -36
  14. package/lib/browser-electron/desktop-bridge-host.js +151 -86
  15. package/lib/browser-electron/entry.d.ts +20 -0
  16. package/lib/browser-electron/entry.js +110 -7
  17. package/lib/browser-electron/history-store.d.ts +11 -2
  18. package/lib/browser-electron/history-store.js +27 -5
  19. package/lib/browser-electron/host-main.js +11 -1
  20. package/lib/browser-electron/provider.d.ts +62 -2
  21. package/lib/browser-electron/provider.js +28 -12
  22. package/lib/browser-electron/remote-host.js +19 -6
  23. package/lib/browser-electron/rpc-ops.d.ts +18 -0
  24. package/lib/browser-electron/rpc-ops.js +36 -0
  25. package/lib/browser-electron/settings-route.d.ts +6 -6
  26. package/lib/browser-electron/settings-route.js +48 -8
  27. package/lib/browser-electron/settings-store.js +57 -16
  28. package/lib/browser-electron/system-browser.d.ts +31 -9
  29. package/lib/browser-electron/system-browser.js +116 -27
  30. package/lib/index.js +0 -1
  31. package/lib/tool-browser/element-fields.d.ts +145 -0
  32. package/lib/tool-browser/element-fields.js +152 -0
  33. package/lib/tool-browser/index.js +26 -60
  34. package/package.json +6 -2
  35. package/src/browser/types.ts +9 -2
  36. package/src/browser-electron/bridge-connection.ts +37 -1
  37. package/src/browser-electron/desktop-bridge-host.ts +146 -84
  38. package/src/browser-electron/entry.ts +113 -8
  39. package/src/browser-electron/history-store.ts +32 -7
  40. package/src/browser-electron/host-main.ts +10 -1
  41. package/src/browser-electron/provider.ts +91 -18
  42. package/src/browser-electron/remote-host.ts +19 -6
  43. package/src/browser-electron/rpc-ops.ts +41 -0
  44. package/src/browser-electron/settings-route.ts +54 -13
  45. package/src/browser-electron/settings-store.ts +56 -17
  46. package/src/browser-electron/system-browser.ts +121 -28
  47. package/src/index.ts +3 -2
  48. package/src/tool-browser/element-fields.ts +167 -0
  49. package/src/tool-browser/index.ts +31 -60
@@ -832,6 +832,7 @@ async function handle(op, msg) {
832
832
  catch { /* not minimized */ }
833
833
  win.window.focus();
834
834
  let base64 = '';
835
+ let renderedSize;
835
836
  try {
836
837
  let image;
837
838
  try {
@@ -856,6 +857,10 @@ async function handle(op, msg) {
856
857
  }
857
858
  if (w > 0 && h > 0 && (w !== size.width || h !== size.height))
858
859
  image = image.resize({ width: w, height: h });
860
+ // What the caller actually receives: after the downscale, not the captured size.
861
+ // Recorded here because this is the last point that knows both.
862
+ if (w > 0 && h > 0)
863
+ renderedSize = { width: w, height: h };
859
864
  const buf = wantFormat === 'jpeg' ? image.toJPEG(wantQuality) : image.toPNG();
860
865
  if (wantFormat === 'jpeg')
861
866
  mime = 'image/jpeg';
@@ -914,7 +919,12 @@ async function handle(op, msg) {
914
919
  if (base64 === '') {
915
920
  throw new Error('capture produced no image (view not painted)');
916
921
  }
917
- reply(msg.id, { ok: true, result: { base64, mime } });
922
+ // The measured size travels with the image. Without it the native path — which is the
923
+ // default self-hosted one — could never answer `browser_screenshot`'s width/height,
924
+ // because the seam's only channel was {base64, mime} and the provider had nothing to
925
+ // forward. The caller's `maxWidth`/`maxHeight` are applied here, so this is the only
926
+ // place that knows what came back.
927
+ reply(msg.id, { ok: true, result: { base64, mime, width: renderedSize?.width, height: renderedSize?.height } });
918
928
  return;
919
929
  }
920
930
  case 'download': {
@@ -20,8 +20,13 @@ export interface ElectronBrowserViewHost {
20
20
  * Create a new browser view and return a handle to its webContents-like
21
21
  * surface. The host owns windowing (adding the view to the window, sizing,
22
22
  * removal); the provider owns CDP-driven behavior.
23
+ * @param owner - the conversation this view serves. A host shared by several conversations
24
+ * needs it to keep one conversation's page out of another's; a host with one conversation per
25
+ * process may ignore it. It is per VIEW and not per host on purpose: the desktop's host is a
26
+ * single instance that every conversation runs through, so an owner held there would be one
27
+ * conversation's id applied to all of them.
23
28
  */
24
- createView(): ElectronViewHandle;
29
+ createView(owner?: string): ElectronViewHandle;
25
30
  /**
26
31
  * Destroy a view created by this host. Called on session close; idempotent
27
32
  * for an already-destroyed view.
@@ -61,6 +66,18 @@ export interface ElectronBrowserViewHost {
61
66
  * of failing only at first use.
62
67
  */
63
68
  available?(): boolean;
69
+ /**
70
+ * Tell the human that a toolbar action the agent could not complete has failed.
71
+ *
72
+ * Optional. Declared rather than reached through a cast because this is what issue #16 was:
73
+ * the call used to be unbound, so `this` inside the host's implementation was undefined and
74
+ * its first statement threw — inside an async catch, which turned it into an unhandled
75
+ * rejection that took the whole DSH process down. A declared member is checkable; a cast is
76
+ * not.
77
+ * @param windowId - the window the action came from.
78
+ * @param message - what to show the human.
79
+ */
80
+ notifyUserActionError?(windowId: string, message: string): void;
64
81
  /**
65
82
  * Optional: associate a view with a window group. Views grouped under the
66
83
  * same `windowId` share one window (one window per browser session); a host
@@ -96,7 +113,12 @@ export interface ElectronBrowserViewHost {
96
113
  * (settings: ui.closeWithSession) has to be asked for explicitly there.
97
114
  * @returns a promise that settles once the release was attempted.
98
115
  */
99
- releasePage?(): Promise<void>;
116
+ /**
117
+ * Release this carrier's browser pages for one conversation.
118
+ * @param owner - the conversation whose pages to close. Omit to close every conversation's,
119
+ * which is only correct from a teardown that is itself global.
120
+ */
121
+ releasePage?(owner?: string): Promise<void>;
100
122
  /**
101
123
  * Fold the carrier's presentation away while keeping the page alive.
102
124
  *
@@ -175,6 +197,42 @@ export interface ElectronViewHandle {
175
197
  * then behaves exactly as before.
176
198
  */
177
199
  focus?(): Promise<void>;
200
+ /**
201
+ * Save a URL straight to disk, when the backing view can do that itself.
202
+ *
203
+ * Optional. Declared here rather than reached through a structural cast at the call site:
204
+ * a cast tells the compiler nothing, so a host that implements the interface without this
205
+ * method compiles and then silently does nothing.
206
+ * @param url - the URL to fetch.
207
+ * @param savePath - where to write it.
208
+ */
209
+ download?(url: string, savePath: string): Promise<void>;
210
+ /**
211
+ * Export this view's cookies. Optional; see {@link download} for why it is declared.
212
+ * @returns the cookies, scoped to the view's own page.
213
+ */
214
+ flushAuth?(): Promise<readonly ExportedCookie[]>;
215
+ /**
216
+ * Import cookies into this view. Optional.
217
+ * @param cookies - the cookies to write.
218
+ * @returns how many were accepted.
219
+ */
220
+ restoreAuth?(cookies: readonly ExportedCookie[]): Promise<number>;
221
+ /**
222
+ * Capture this view natively, returning the image and its measured size.
223
+ *
224
+ * Optional. The size is part of the contract because the caller's own tool schema declares
225
+ * `width`/`height`; a host that can measure the image should say so here rather than leaving
226
+ * the provider to guess.
227
+ * @param opts - format, quality and downscale bounds.
228
+ * @returns the encoded image and the pixel size the caller will receive.
229
+ */
230
+ capture?(opts?: ScreenshotOptions): Promise<{
231
+ base64: string;
232
+ mime: string;
233
+ width?: number;
234
+ height?: number;
235
+ }>;
178
236
  }
179
237
  /** Provider config: navigation admission defaults and snapshot caps. */
180
238
  export interface ElectronBrowserProviderConfig {
@@ -583,6 +641,8 @@ export declare class ElectronBrowserProvider implements BrowserProvider {
583
641
  screenshot(session: BrowserSessionId, request?: BrowserScreenshotRequest, signal?: AbortSignal): Promise<{
584
642
  readonly dataUrl: string;
585
643
  readonly path?: string;
644
+ readonly width?: number;
645
+ readonly height?: number;
586
646
  }>;
587
647
  /**
588
648
  * Build the data URL and optionally write the image to disk. The caller's
@@ -343,7 +343,10 @@ export class ElectronBrowserProvider {
343
343
  * in the window title so a human can tell which task's page is visible.
344
344
  */
345
345
  open(label) {
346
- const handle = this.host.createView();
346
+ // The label is the calling DSH task, and it is handed to the view as its owner: a host shared
347
+ // by several conversations (the desktop's) keeps one conversation's page out of another's on
348
+ // the strength of it, so it is not decoration.
349
+ const handle = this.host.createView(label);
347
350
  const id = `browser:${randomUUID()}`;
348
351
  // Group the session's views under its own window (one window per
349
352
  // session), carrying the label for the window title. Hosts without
@@ -2289,7 +2292,13 @@ export class ElectronBrowserProvider {
2289
2292
  if (shot.base64 === '') {
2290
2293
  throw new BrowserError('browser: capture returned an empty image (view not painted); retry shortly', 'BROWSER_SCREENSHOT_FAILED');
2291
2294
  }
2292
- return this.saveScreenshot(shot.base64, request?.savePath, shot.mime ?? 'image/png');
2295
+ return this.saveScreenshot(shot.base64, request?.savePath, shot.mime ?? 'image/png',
2296
+ // The child measured these after applying any downscale, and they describe the image
2297
+ // the caller receives. Without them this path — the default self-hosted one — could
2298
+ // never answer the width/height its own tool schema declares.
2299
+ shot.width !== undefined && shot.height !== undefined
2300
+ ? { width: shot.width, height: shot.height }
2301
+ : undefined);
2293
2302
  }
2294
2303
  // Fallback: a handle without a native capture() (the sidebar, or an installed Chrome
2295
2304
  // or Edge) uses CDP; full-page needs `captureBeyondViewport`, which capturePage lacks.
@@ -2403,9 +2412,15 @@ export class ElectronBrowserProvider {
2403
2412
  if (store === undefined)
2404
2413
  return;
2405
2414
  // The settings panel can switch recording off at runtime; the static config
2406
- // only supplies the startup default.
2407
- if (this.settingsSource !== undefined && !this.settingsSource().history.enabled)
2408
- return;
2415
+ // only supplies the startup default. The RETENTION limits are read here too: they were
2416
+ // parsed and stored but never handed to the store, so editing maxEntries or maxAgeDays in
2417
+ // the file changed nothing — the switch worked and the numbers beside it did not.
2418
+ if (this.settingsSource !== undefined) {
2419
+ const live = this.settingsSource().history;
2420
+ if (!live.enabled)
2421
+ return;
2422
+ store.updateLimits({ maxEntries: live.maxEntries, maxAgeDays: live.maxAgeDays });
2423
+ }
2409
2424
  void handleSendEvaluate(handle, 'location.href + "\\u0000" + (document.title || "")')
2410
2425
  .then(result => {
2411
2426
  if (!result.ok || typeof result.value !== 'string')
@@ -2537,7 +2552,9 @@ export class ElectronBrowserProvider {
2537
2552
  // the views above; the desktop's sidebar does not, so it is asked explicitly
2538
2553
  // — and its cookies plus the browsing history outlive the page either way.
2539
2554
  if (this.settingsSource !== undefined && this.settingsSource().ui.closeWithSession) {
2540
- void this.host.releasePage?.().catch(() => undefined);
2555
+ // Named, because one host serves every conversation: an unnamed release would end the
2556
+ // pages of conversations that are still running.
2557
+ void this.host.releasePage?.(existing.label).catch(() => undefined);
2541
2558
  }
2542
2559
  }
2543
2560
  return Promise.resolve();
@@ -2559,7 +2576,7 @@ export class ElectronBrowserProvider {
2559
2576
  }
2560
2577
  /** Append a fresh tab and make it active. */
2561
2578
  newTab(s) {
2562
- const handle = this.host.createView();
2579
+ const handle = this.host.createView(s.label);
2563
2580
  // Same window as the session's other tabs.
2564
2581
  this.host.groupView?.(handle, s.id, s.label);
2565
2582
  s.tabs.push({ id: `tab:${randomUUID()}`, handle });
@@ -2664,11 +2681,10 @@ export class ElectronBrowserProvider {
2664
2681
  const message = `action ${action.type} failed: ${String(error)}`;
2665
2682
  let reported = false;
2666
2683
  try {
2667
- const host = this.host;
2668
- const notify = host.notifyUserActionError;
2669
- if (typeof notify === 'function') {
2670
- // Called on its owner: an unbound call here kills the host process.
2671
- notify.call(host, action.windowId, message);
2684
+ const notify = this.host.notifyUserActionError?.bind(this.host);
2685
+ if (notify !== undefined) {
2686
+ // Bound at the call: an unbound call here kills the host process (issue #16).
2687
+ notify(action.windowId, message);
2672
2688
  reported = true;
2673
2689
  }
2674
2690
  }
@@ -487,9 +487,11 @@ class ElectronChildClient {
487
487
  /**
488
488
  * Drop the escalation kill once there is nothing left for it to target.
489
489
  *
490
- * Without this the timer would keep the parent's event loop alive for its full grace period
491
- * after exactly the ordinary exit it was armed to catch as the bad case — and the child does
492
- * not need killing once `fail()` has run for it.
490
+ * Called on the paths where the child is known to be gone — after an observed exit, and on
491
+ * release. It is deliberately NOT called from `fail()`: a dead socket says nothing about the
492
+ * process on the other end, and the timer exists for exactly the case where the child ignores
493
+ * the socket closing. Clearing it there disarmed the only escalation there was, which is how
494
+ * a child that ignored its socket could outlive dispose() and keep holding the profile.
493
495
  */
494
496
  clearKillGrace() {
495
497
  if (this.killGraceTimer === undefined)
@@ -515,7 +517,11 @@ class ElectronChildClient {
515
517
  // kept a live timer until it fired (harmless, but it is a dangling timer in a process
516
518
  // that may be shutting down).
517
519
  this.clearHelloTimer();
518
- this.clearKillGrace();
520
+ // NOT clearKillGrace(): this path runs when the SOCKET died, which says nothing about
521
+ // whether the process did. Clearing the escalation here disarmed it for exactly the case it
522
+ // exists for — a child that ignores its socket closing — so dispose() could return with the
523
+ // child still running and still holding the profile. The timer clears itself when the child
524
+ // exits, and `kill()` replaces it on an explicit release.
519
525
  // Tag every failure as "host gone" so the view layer can tell a dead host
520
526
  // apart from a page-level error and rebuild against a fresh child.
521
527
  const wrapped = err instanceof HostGoneError ? err : new HostGoneError(err.message);
@@ -1245,11 +1251,18 @@ class DeferredRemoteView {
1245
1251
  materializeOnce() {
1246
1252
  if (this.materialized === undefined) {
1247
1253
  const pending = this.materialize();
1248
- this.materialized = pending.catch(error => {
1249
- if (this.materialized === pending)
1254
+ // Compare against the promise actually stored — the derived one — not against `pending`.
1255
+ // Storing `pending.catch(...)` and comparing `this.materialized === pending` compared two
1256
+ // different objects, so the test was never true and the reset never ran: a materialization
1257
+ // that failed with anything other than a HostGoneError (the child alive but not answering
1258
+ // a ping, say) poisoned the handle for good, and the retry the comment below promises was
1259
+ // unreachable.
1260
+ const guarded = pending.catch(error => {
1261
+ if (this.materialized === guarded)
1250
1262
  this.materialized = undefined;
1251
1263
  throw error;
1252
1264
  });
1265
+ this.materialized = guarded;
1253
1266
  }
1254
1267
  return this.materialized;
1255
1268
  }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The RPC operation vocabulary, in one place.
3
+ *
4
+ * The child (`host-main.ts`) switches on these names and the parent (`remote-host.ts`) sends
5
+ * them. Both spelled them out independently and nothing connected the two, so a rename on one
6
+ * side compiled fine and failed at run time as "unknown op" — and this pair has already
7
+ * produced one silent failure of that shape (`noteStartFailure`'s dedupe comparison, which was
8
+ * never true and meant a log line that never appeared).
9
+ *
10
+ * The lists are the source of truth for a test that reads both switch statements and asserts
11
+ * they agree, which is what makes a one-sided rename fail loudly instead of at run time.
12
+ */
13
+ /** Operations the parent asks the child to perform. */
14
+ export declare const RPC_OPS: readonly ["ping", "createView", "destroyView", "showView", "groupView", "focus", "command", "capture", "download", "flushAuth", "restoreAuth", "navigate", "new-tab", "activate", "close", "back", "forward", "reload", "userActionError"];
15
+ /** Notifications the child sends the parent, which carry no reply. */
16
+ export declare const RPC_NOTIFICATIONS: readonly ["userAction", "viewClosed"];
17
+ /** One operation name. */
18
+ export type RpcOp = (typeof RPC_OPS)[number];
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The RPC operation vocabulary, in one place.
3
+ *
4
+ * The child (`host-main.ts`) switches on these names and the parent (`remote-host.ts`) sends
5
+ * them. Both spelled them out independently and nothing connected the two, so a rename on one
6
+ * side compiled fine and failed at run time as "unknown op" — and this pair has already
7
+ * produced one silent failure of that shape (`noteStartFailure`'s dedupe comparison, which was
8
+ * never true and meant a log line that never appeared).
9
+ *
10
+ * The lists are the source of truth for a test that reads both switch statements and asserts
11
+ * they agree, which is what makes a one-sided rename fail loudly instead of at run time.
12
+ */
13
+ /** Operations the parent asks the child to perform. */
14
+ export const RPC_OPS = [
15
+ 'ping',
16
+ 'createView',
17
+ 'destroyView',
18
+ 'showView',
19
+ 'groupView',
20
+ 'focus',
21
+ 'command',
22
+ 'capture',
23
+ 'download',
24
+ 'flushAuth',
25
+ 'restoreAuth',
26
+ 'navigate',
27
+ 'new-tab',
28
+ 'activate',
29
+ 'close',
30
+ 'back',
31
+ 'forward',
32
+ 'reload',
33
+ 'userActionError',
34
+ ];
35
+ /** Notifications the child sends the parent, which carry no reply. */
36
+ export const RPC_NOTIFICATIONS = ['userAction', 'viewClosed'];
@@ -9,14 +9,14 @@
9
9
  * It is NOT authentication, and cannot be. The plugin has no credential to check: DSH hands
10
10
  * plugin routes the raw request (`packages/host/webserver/src/index.ts` registers handlers with
11
11
  * no gate of its own), so the strongest same-origin evidence available is what a browser
12
- * reliably sends — `Sec-Fetch-Site`, plus `Origin` on every write, same-origin included.
12
+ * reliably sends.
13
13
  *
14
14
  * What that buys and what it does not:
15
- * - a plain HTTP client (curl, a script, another process) cannot WRITE any more, because it
16
- * sends no `Origin`;
17
- * - a page of THIS origin still can, because a browser sending a same-origin write looks
18
- * exactly like the panel. That is inherent to exposing an HTTP endpoint at all, and it is
19
- * why the settings panel's own text must not promise a lock that does not exist.
15
+ * - a plain HTTP client (curl, a script, another process) cannot WRITE, because it sends no
16
+ * `user-agent` and no `accept-language`;
17
+ * - a page of THIS origin can, because a browser sending a same-origin write looks exactly
18
+ * like the panel. That is inherent to exposing an HTTP endpoint at all, and it is why the
19
+ * settings panel's own text must not promise a lock that does not exist.
20
20
  */
21
21
  import type { IncomingMessage } from 'node:http';
22
22
  /**
@@ -1,3 +1,20 @@
1
+ /** The shell's own URL schemes. Only the shell can mint a document on one of these. */
2
+ const APP_SCHEMES = ['dsh-app:', 'dsh-desktop:'];
3
+ /**
4
+ * The scheme of a header value, when it names a URL.
5
+ * @param value - a raw header value.
6
+ * @returns the scheme, or undefined when the value is absent, opaque or unparseable.
7
+ */
8
+ function schemeOf(value) {
9
+ if (value === '' || value === 'null')
10
+ return undefined;
11
+ try {
12
+ return new URL(value).protocol;
13
+ }
14
+ catch {
15
+ return undefined;
16
+ }
17
+ }
1
18
  /**
2
19
  * Whether a request may touch the settings document.
3
20
  * @param request - the incoming request.
@@ -5,22 +22,45 @@
5
22
  * @returns whether the request is admitted.
6
23
  */
7
24
  export function sameOrigin(request, writing = false) {
25
+ const origin = String(request.headers.origin ?? '').trim();
8
26
  const site = String(request.headers['sec-fetch-site'] ?? '').toLowerCase();
27
+ // A shell URL, in either the Origin or the referrer: the strongest evidence there is.
28
+ if (APP_SCHEMES.includes(schemeOf(origin) ?? ''))
29
+ return true;
30
+ if (APP_SCHEMES.includes(schemeOf(String(request.headers.referer ?? '')) ?? ''))
31
+ return true;
32
+ // MEASURED on the running desktop, and what three earlier attempts each guessed wrong: a
33
+ // request from the shell's own settings panel carries NO origin, NO sec-fetch-site and NO
34
+ // referer. It is not "dsh-app://app" and it is not "null" — the headers are simply absent,
35
+ // because Chromium attaches no cross-origin metadata to a request leaving a custom scheme.
36
+ //
37
+ // So the rule for that shape cannot be "a request without an Origin is a script", which is
38
+ // what refused every desktop save, and it cannot lean on `Sec-Fetch-Site` either. It has to
39
+ // test what a hand-rolled client does not send:
40
+ // - a renderer sends a full Mozilla-compatible `user-agent` AND an `accept-language`;
41
+ // - curl sends neither unless told to, and a local process that forges both can also read
42
+ // the settings document directly — it sits readable on disk, as the panel's own text
43
+ // says. This was never the boundary it looked like.
44
+ if (origin === '') {
45
+ const agent = String(request.headers['user-agent'] ?? '');
46
+ const language = String(request.headers['accept-language'] ?? '');
47
+ if (/^Mozilla\/5\.0/.test(agent) && language !== '')
48
+ return true;
49
+ // Reads stay open: they return the document the panel is already showing, and refusing them
50
+ // would only break hand-inspection of a file that sits readable on disk anyway.
51
+ return !writing;
52
+ }
9
53
  // `cross-site` is another origin entirely; `same-site` is another origin of the same site
10
54
  // (a.example.com -> b.example.com), which is not this panel either. Both are refused.
11
55
  if (site === 'cross-site' || site === 'same-site')
12
56
  return false;
13
- const origin = String(request.headers.origin ?? '').trim();
14
- // `Origin: null` is what a sandboxed or opaque-origin document sends. Never this panel.
57
+ // `Origin: null` is a sandboxed or opaque-origin document. Never this panel.
15
58
  if (origin === 'null')
16
59
  return false;
17
- // Reads are let through without an Origin: they return the document the panel is already
18
- // showing, and refusing them would only break hand-inspection of a file that sits readable
19
- // on disk anyway. Writes are not: a browser always sends `Origin` on one.
20
- if (origin === '')
21
- return !writing;
22
60
  try {
23
- return new URL(origin).host.toLowerCase() === String(request.headers.host ?? '').trim().toLowerCase();
61
+ const parsed = new URL(origin);
62
+ // The host comparison is for http(s), where a page and its route share one host.
63
+ return parsed.host.toLowerCase() === String(request.headers.host ?? '').trim().toLowerCase();
24
64
  }
25
65
  catch {
26
66
  return false;
@@ -75,18 +75,42 @@ const SETTINGS_WHEN_UNREADABLE = {
75
75
  * @returns a valid channel.
76
76
  */
77
77
  function resolveChannel(value) {
78
- return value === 'chrome' || value === 'edge' || value === 'auto' || value === 'bundled'
78
+ return value === 'chrome' || value === 'edge' || value === 'auto' || value === 'bundled' || value === 'sidebar'
79
79
  ? value
80
80
  : DEFAULT_SETTINGS.browser.channel;
81
81
  }
82
82
  /** Coerce one positive-integer field, keeping the default when invalid. */
83
83
  function count(value, fallback) {
84
- return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
84
+ if (typeof value !== 'number' || !Number.isFinite(value))
85
+ return fallback;
86
+ const floored = Math.floor(value);
87
+ // A fractional value below 1 floors to 0, and 0 is not "very few" in either place these are
88
+ // used: as a history entry cap it means `slice(len - 0)`, i.e. keep nothing, so a hand-typed
89
+ // `0.5` silently wiped the history file on the next rewrite. Anything that survives the
90
+ // floor has to still be a usable limit.
91
+ return floored > 0 ? floored : fallback;
85
92
  }
86
93
  /** Read one nested section of an unknown document, or an empty object. */
94
+ /**
95
+ * Read one section of the document, distinguishing ABSENT from UNREADABLE.
96
+ *
97
+ * Absent (the key is not there at all) is a document written before the section existed, and
98
+ * the defaults apply — that is the documented behaviour. Present-but-not-an-object
99
+ * (`"actions": false`, `null` from a truncated write, a stray string) is a document this code
100
+ * cannot read, and returning `{}` for it made every gate inside look absent, so each one took
101
+ * its permissive default. A whole section mistyped is exactly the shape a careless hand edit
102
+ * produces, and it opened every capability the section was there to bound.
103
+ * @param source - the parsed document.
104
+ * @param key - the section name.
105
+ * @returns the section, or undefined when it is present but not an object.
106
+ */
87
107
  function section(source, key) {
88
108
  const value = source[key];
89
- return typeof value === 'object' && value !== null ? value : {};
109
+ if (value === undefined)
110
+ return {};
111
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
112
+ ? value
113
+ : undefined;
90
114
  }
91
115
  /**
92
116
  * Resolve a raw settings document (already parsed, shape unknown) into the full
@@ -104,25 +128,42 @@ export function resolveSettings(raw) {
104
128
  const credentials = section(source, 'credentials');
105
129
  const browser = section(source, 'browser');
106
130
  const actions = section(source, 'actions');
131
+ // A section that is present but unreadable refuses its gates rather than defaulting them
132
+ // open. The rest of a broken section keeps its defaults: refusing history or presentation
133
+ // would punish the user for a bad file without protecting anything.
134
+ const credentialsUsable = credentials ?? {};
135
+ const actionsUsable = actions ?? {};
107
136
  return {
108
137
  history: {
109
- enabled: bool(history.enabled, DEFAULT_SETTINGS.history.enabled),
110
- maxEntries: count(history.maxEntries, DEFAULT_SETTINGS.history.maxEntries),
111
- maxAgeDays: count(history.maxAgeDays, DEFAULT_SETTINGS.history.maxAgeDays),
138
+ enabled: bool(history?.enabled, DEFAULT_SETTINGS.history.enabled),
139
+ maxEntries: count(history?.maxEntries, DEFAULT_SETTINGS.history.maxEntries),
140
+ maxAgeDays: count(history?.maxAgeDays, DEFAULT_SETTINGS.history.maxAgeDays),
112
141
  },
113
- cookies: { persist: bool(cookies.persist, DEFAULT_SETTINGS.cookies.persist) },
142
+ cookies: { persist: bool(cookies?.persist, DEFAULT_SETTINGS.cookies.persist) },
114
143
  ui: {
115
- autoExpandOnce: bool(ui.autoExpandOnce, DEFAULT_SETTINGS.ui.autoExpandOnce),
116
- closeWithSession: bool(ui.closeWithSession, DEFAULT_SETTINGS.ui.closeWithSession),
117
- virtualCursor: bool(ui.virtualCursor, DEFAULT_SETTINGS.ui.virtualCursor),
144
+ autoExpandOnce: bool(ui?.autoExpandOnce, DEFAULT_SETTINGS.ui.autoExpandOnce),
145
+ closeWithSession: bool(ui?.closeWithSession, DEFAULT_SETTINGS.ui.closeWithSession),
146
+ virtualCursor: bool(ui?.virtualCursor, DEFAULT_SETTINGS.ui.virtualCursor),
147
+ },
148
+ vision: { strategy: vision?.strategy === 'nonVisual' ? 'nonVisual' : DEFAULT_SETTINGS.vision.strategy },
149
+ browser: { channel: resolveChannel(browser?.channel) },
150
+ credentials: {
151
+ // `gate` returns false for anything present-and-not-a-boolean, so an unreadable section
152
+ // is refused here for free: the section itself is the "present but wrong" value.
153
+ allowRead: credentials === undefined
154
+ ? false
155
+ : gate(credentialsUsable.allowRead, DEFAULT_SETTINGS.credentials.allowRead),
118
156
  },
119
- vision: { strategy: vision.strategy === 'nonVisual' ? 'nonVisual' : DEFAULT_SETTINGS.vision.strategy },
120
- browser: { channel: resolveChannel(browser.channel) },
121
- credentials: { allowRead: gate(credentials.allowRead, DEFAULT_SETTINGS.credentials.allowRead) },
122
157
  actions: {
123
- allowExecute: gate(actions.allowExecute, DEFAULT_SETTINGS.actions.allowExecute),
124
- allowDownload: gate(actions.allowDownload, DEFAULT_SETTINGS.actions.allowDownload),
125
- allowCredentialWrite: gate(actions.allowCredentialWrite, DEFAULT_SETTINGS.actions.allowCredentialWrite),
158
+ allowExecute: actions === undefined
159
+ ? false
160
+ : gate(actionsUsable.allowExecute, DEFAULT_SETTINGS.actions.allowExecute),
161
+ allowDownload: actions === undefined
162
+ ? false
163
+ : gate(actionsUsable.allowDownload, DEFAULT_SETTINGS.actions.allowDownload),
164
+ allowCredentialWrite: actions === undefined
165
+ ? false
166
+ : gate(actionsUsable.allowCredentialWrite, DEFAULT_SETTINGS.actions.allowCredentialWrite),
126
167
  },
127
168
  };
128
169
  }
@@ -37,7 +37,7 @@ export type BrowserLauncher = (path: string, args: readonly string[]) => ReturnT
37
37
  import { spawn } from 'node:child_process';
38
38
  import type { ElectronBrowserViewHost, ElectronViewHandle } from './provider.js';
39
39
  /** Which browser the user asked for. */
40
- export type BrowserChannel = 'bundled' | 'chrome' | 'edge' | 'auto';
40
+ export type BrowserChannel = 'bundled' | 'chrome' | 'edge' | 'auto' | 'sidebar';
41
41
  /** A resolved browser installation. */
42
42
  export interface DetectedBrowser {
43
43
  /** Which product this is. */
@@ -157,6 +157,15 @@ export declare class SystemBrowserViewHost implements ElectronBrowserViewHost {
157
157
  * @returns true when it answered.
158
158
  */
159
159
  private probeClient;
160
+ /**
161
+ * Forget which browser issued what, after that browser is gone.
162
+ *
163
+ * The two maps must move together: `views` maps a view id to a session id and `sessions`
164
+ * maps a session id to a target id, so clearing one alone leaves a mapping the next command
165
+ * will act on. Every path that abandons a browser calls this rather than spelling the pair
166
+ * out — four separate copies is how the third one came to be missing.
167
+ */
168
+ private forgetBrowserState;
160
169
  private ensureClient;
161
170
  /**
162
171
  * Launch the browser with a private profile and connect over CDP.
@@ -179,10 +188,23 @@ export declare class SystemBrowserViewHost implements ElectronBrowserViewHost {
179
188
  destroyView(handle: ElectronViewHandle): void;
180
189
  /** Nothing to show: the browser owns its own windows. */
181
190
  showView(): void;
182
- /** Same as {@link showView}. */
183
- presentView(): Promise<void>;
184
- /** The browser owns window grouping. */
185
- groupView(): void;
191
+ /**
192
+ * Bring the target's page to the front, then report success.
193
+ *
194
+ * The comment on `focus` below used to say this was impossible — that raising the page needs
195
+ * `Page.bringToFront` on a target session, which is a view-level concern the host cannot
196
+ * reach. The handle this method is handed IS that view, and it can send commands, so the
197
+ * capability was here all along; it just was not used. Without it `presentView` was an empty
198
+ * async method, so the provider's whole presentation barrier — the one that stops
199
+ * `Input.*` from being silently dropped for a view with no display surface, and reports
200
+ * `BROWSER_VIEW_NOT_PRESENTED` when it cannot present — never ran on this carrier.
201
+ *
202
+ * A failure is swallowed rather than propagated: a real Chrome window that refuses to come
203
+ * forward is not a reason to refuse the click that follows. The barrier exists to order the
204
+ * two, and the order is honoured either way.
205
+ * @param handle - the view to present.
206
+ */
207
+ presentView(handle: ElectronViewHandle): Promise<void>;
186
208
  /**
187
209
  * Best effort at bringing the browser's window forward.
188
210
  *
@@ -196,10 +218,6 @@ export declare class SystemBrowserViewHost implements ElectronBrowserViewHost {
196
218
  * view-level concern and not available to the host.
197
219
  */
198
220
  focus(): Promise<void>;
199
- /** The browser reports its own window lifecycle. */
200
- onUserAction(): void;
201
- /** The browser reports its own window lifecycle. */
202
- onViewClosed(): void;
203
221
  /**
204
222
  * Close the browser we launched, if we ever launched one.
205
223
  *
@@ -216,6 +234,10 @@ export declare class SystemBrowserViewHost implements ElectronBrowserViewHost {
216
234
  * a second copy of that rule.
217
235
  */
218
236
  private ensureSession;
237
+ /** Create and attach the target for one view. Serialised by {@link ensureSession}. */
238
+ private buildSession;
239
+ /** Views whose session is being created right now, so a second caller waits for the first. */
240
+ private readonly sessionBuilding;
219
241
  /** sessionId -> targetId, so a destroyed view can close the right page. */
220
242
  private readonly sessions;
221
243
  }