dsh-builtin-browser 0.3.0 → 0.4.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.
@@ -0,0 +1,54 @@
1
+ /**
2
+ * A host that exists only to explain why the browser you asked for is not here.
3
+ *
4
+ * WHY THIS EXISTS
5
+ * When the browser setting names a browser explicitly (Chrome or Edge rather than
6
+ * "bundled" or "automatic"), the plugin used to log a warning and quietly fall back
7
+ * to its own Electron window. That is worse than failing: you asked for Chrome, you
8
+ * got an Electron window, and the error that eventually surfaced talked about
9
+ * Electron — pointing at something you never chose and never wanted to install. The
10
+ * actual problem ("no Chrome on this machine") never reached you.
11
+ *
12
+ * So the two choices are treated as the different requests they are:
13
+ * - `auto` means "any browser will do", and falling back to the bundled one is the
14
+ * behaviour it asks for;
15
+ * - `chrome` / `edge` are explicit, and an explicit request that cannot be met is
16
+ * reported as such, with the ways out of it.
17
+ *
18
+ * `available()` reports true on purpose. Reporting false would make the provider pick
19
+ * a different carrier and hide the problem again, which is exactly the bug.
20
+ */
21
+ import type { ElectronBrowserViewHost, ElectronViewHandle } from './provider.js';
22
+ import type { BrowserChannel } from './system-browser.js';
23
+ /**
24
+ * A carrier that cannot carry anything: every command explains the real problem.
25
+ *
26
+ * The message is written to be acted on, not just read: it says which browser was
27
+ * requested, where the plugin looked, and the three concrete ways to resolve it.
28
+ */
29
+ export declare class MissingSystemBrowserHost implements ElectronBrowserViewHost {
30
+ private readonly kind;
31
+ private readonly searched;
32
+ /**
33
+ * @param kind - the browser the user asked for.
34
+ * @param searched - the names and locations that were checked, for the report.
35
+ */
36
+ constructor(kind: Exclude<BrowserChannel, 'bundled' | 'auto'>, searched: readonly string[]);
37
+ /** @returns always true: see the module comment — reporting false would hide this. */
38
+ available(): boolean;
39
+ /**
40
+ * The message every command fails with.
41
+ * @returns a multi-line explanation with the fixes.
42
+ */
43
+ private explain;
44
+ /**
45
+ * Create a handle whose every command fails with the explanation.
46
+ * @returns a handle that never reaches a page.
47
+ */
48
+ createView(): ElectronViewHandle;
49
+ /**
50
+ * Accept a handle for destruction. Nothing was created, so nothing is released.
51
+ * @param handle - the handle returned by {@link createView}.
52
+ */
53
+ destroyView(handle: ElectronViewHandle): void;
54
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * How each product is named in the message, and what to install.
3
+ *
4
+ * Only chrome and edge can appear: the constructor's type excludes `bundled` and `auto`,
5
+ * and `BrowserChannel` has no `brave`. A brave entry here would be unreachable code.
6
+ */
7
+ const PRODUCT = {
8
+ chrome: {
9
+ label: 'Google Chrome',
10
+ install: 'install Google Chrome',
11
+ variable: 'DSH_BROWSER_CHROME_PATH',
12
+ },
13
+ edge: {
14
+ label: 'Microsoft Edge',
15
+ install: 'install Microsoft Edge',
16
+ variable: 'DSH_BROWSER_EDGE_PATH',
17
+ },
18
+ };
19
+ /** How the setting is named to the user. */
20
+ const SETTING_LABEL = {
21
+ chrome: '本机 Chrome / installed Chrome',
22
+ edge: '本机 Edge / installed Edge',
23
+ auto: '自动 / automatic',
24
+ bundled: '内置 / bundled',
25
+ };
26
+ /**
27
+ * A carrier that cannot carry anything: every command explains the real problem.
28
+ *
29
+ * The message is written to be acted on, not just read: it says which browser was
30
+ * requested, where the plugin looked, and the three concrete ways to resolve it.
31
+ */
32
+ export class MissingSystemBrowserHost {
33
+ kind;
34
+ searched;
35
+ /**
36
+ * @param kind - the browser the user asked for.
37
+ * @param searched - the names and locations that were checked, for the report.
38
+ */
39
+ constructor(kind, searched) {
40
+ this.kind = kind;
41
+ this.searched = searched;
42
+ }
43
+ /** @returns always true: see the module comment — reporting false would hide this. */
44
+ available() {
45
+ return true;
46
+ }
47
+ /**
48
+ * The message every command fails with.
49
+ * @returns a multi-line explanation with the fixes.
50
+ */
51
+ explain() {
52
+ const product = PRODUCT[this.kind] ?? { label: this.kind, install: `install ${this.kind}`, variable: 'DSH_BROWSER_<KIND>_PATH' };
53
+ const searched = this.searched.length > 0 ? this.searched.join(', ') : 'PATH and the usual install locations';
54
+ return [
55
+ `dsh-builtin-browser: the browser you selected is not installed — no ${product.label} was found.`,
56
+ ``,
57
+ ` Setting: browser carrier = "${SETTING_LABEL[this.kind] ?? this.kind}"`,
58
+ ` Looked for: ${searched}`,
59
+ ``,
60
+ `Nothing else is wrong: the plugin falls back to its own bundled browser only when the`,
61
+ `setting is "bundled" or "automatic". Because this one names a browser explicitly, it`,
62
+ `reports the missing browser instead of silently using a different one.`,
63
+ ``,
64
+ `To fix it, any ONE of these is enough:`,
65
+ ` 1. ${product.install}, then restart DSH;`,
66
+ ` 2. point ${product.variable} at its executable;`,
67
+ ` 3. set the browser carrier back to "内置 / bundled" (or "自动 / automatic" to let it`,
68
+ ` fall back on its own next time).`,
69
+ ].join('\n');
70
+ }
71
+ /**
72
+ * Create a handle whose every command fails with the explanation.
73
+ * @returns a handle that never reaches a page.
74
+ */
75
+ createView() {
76
+ const message = this.explain();
77
+ return {
78
+ id: `missing-${this.kind}`,
79
+ sendCommand: async () => { throw new Error(message); },
80
+ };
81
+ }
82
+ /**
83
+ * Accept a handle for destruction. Nothing was created, so nothing is released.
84
+ * @param handle - the handle returned by {@link createView}.
85
+ */
86
+ destroyView(handle) {
87
+ void handle;
88
+ }
89
+ }
@@ -160,6 +160,15 @@ export interface ElectronViewHandle {
160
160
  * @returns the CDP `result` object.
161
161
  */
162
162
  sendCommand(method: string, params?: Record<string, unknown>): Promise<Record<string, unknown>>;
163
+ /**
164
+ * Whether this view's CDP can encode JPEG and honour a quality setting.
165
+ *
166
+ * Electron 43's CDP hangs on `Page.captureScreenshot` with `format: 'jpeg'`, so a view
167
+ * backed by Electron must leave this unset and the provider keeps to PNG. A view backed
168
+ * by an installed Chrome or Edge is the real browser, where JPEG works, and sets it so
169
+ * the requested format is not silently downgraded.
170
+ */
171
+ readonly supportsCdpJpeg?: boolean;
163
172
  /**
164
173
  * Give the backing view web focus so keyboard input reaches its page.
165
174
  * Optional: an adapter that cannot focus a view omits it, and the provider
@@ -236,6 +245,40 @@ export interface CdpEvaluateParams {
236
245
  }
237
246
  /** CDP method for a full-page screenshot capture. */
238
247
  export declare const CDP_PAGE_CAPTURE_SCREENSHOT = "Page.captureScreenshot";
248
+ /**
249
+ * The document size out of a `Page.getLayoutMetrics` response.
250
+ *
251
+ * CDP returns `cssContentSize` (the whole document) and `cssLayoutViewport` (what is
252
+ * visible); the newer spellings `contentSize` / `layoutViewport` appear on some
253
+ * versions, so both are accepted. Returns undefined when neither is usable, leaving the
254
+ * caller to capture unscaled rather than guess.
255
+ * @param metrics - the raw CDP response.
256
+ * @returns the document and viewport size in CSS pixels.
257
+ */
258
+ export declare function layoutSize(metrics: Record<string, unknown>): {
259
+ width: number;
260
+ height: number;
261
+ viewportHeight: number;
262
+ } | undefined;
263
+ /**
264
+ * Map CDP cookies onto the shape `browser_auth` exports.
265
+ *
266
+ * CDP reports a cookie as domain + path rather than a URL, and in seconds rather than
267
+ * milliseconds, so both are converted. Entries missing a name, value or domain are
268
+ * dropped rather than exported as broken cookies the caller cannot restore.
269
+ * @param raw - the `cookies` array from a CDP response, of unknown shape.
270
+ * @returns the mappable cookies.
271
+ */
272
+ export declare function toExportedCookies(raw: unknown): ExportedCookie[];
273
+ /**
274
+ * Map an exported cookie onto the fields `Storage.setCookies` expects.
275
+ *
276
+ * An exported cookie may carry only a URL, so the domain and path are recovered from it
277
+ * when they are absent; without either, CDP cannot place the cookie and it is skipped.
278
+ * @param cookie - the cookie to convert.
279
+ * @returns the CDP cookie, or undefined when it lacks a usable domain.
280
+ */
281
+ export declare function toCdpCookie(cookie: ExportedCookie): Record<string, unknown> | undefined;
239
282
  /** Native capture options the self-hosted view handle understands. */
240
283
  export interface ScreenshotOptions {
241
284
  readonly format?: 'png' | 'jpeg';
@@ -595,7 +638,17 @@ export declare class ElectronBrowserProvider implements BrowserProvider {
595
638
  * diagnostics: it can never be allowed to become the failure.
596
639
  */
597
640
  private notifyUserActionError;
598
- /** Ask the host to show the active tab's view, carrying the session label. */
641
+ /**
642
+ * Ask the host to show the active tab's view, carrying the session label.
643
+ *
644
+ * The two halves are sequential, not alternatives. `showView` makes the view the
645
+ * visible one — which is what keeps what the human sees in step with what the agent
646
+ * drives, and what lets `capturePage` return an image at all (a hidden view captures
647
+ * empty) — while `collapse` folds the carrier's own chrome away when the user asked
648
+ * for no auto-expand. Making them exclusive meant that with auto-expand off this
649
+ * method did nothing at all on carriers without `collapse` (the self-hosted one),
650
+ * silently desynchronising the two and failing every screenshot.
651
+ */
599
652
  private showActive;
600
653
  /** Read the current URL of a view through CDP. */
601
654
  private currentUrl;
@@ -70,6 +70,109 @@ const CHALLENGE_DETECT_EXPRESSION = `(() => {
70
70
  export const ELECTRON_BROWSER_PROVIDER_ID = 'electron';
71
71
  /** CDP method for a full-page screenshot capture. */
72
72
  export const CDP_PAGE_CAPTURE_SCREENSHOT = 'Page.captureScreenshot';
73
+ /**
74
+ * The document size out of a `Page.getLayoutMetrics` response.
75
+ *
76
+ * CDP returns `cssContentSize` (the whole document) and `cssLayoutViewport` (what is
77
+ * visible); the newer spellings `contentSize` / `layoutViewport` appear on some
78
+ * versions, so both are accepted. Returns undefined when neither is usable, leaving the
79
+ * caller to capture unscaled rather than guess.
80
+ * @param metrics - the raw CDP response.
81
+ * @returns the document and viewport size in CSS pixels.
82
+ */
83
+ export function layoutSize(metrics) {
84
+ const pick = (candidates) => {
85
+ for (const candidate of candidates) {
86
+ if (typeof candidate !== 'object' || candidate === null)
87
+ continue;
88
+ const box = candidate;
89
+ if (typeof box.width === 'number' && typeof box.height === 'number' && box.width > 0 && box.height > 0) {
90
+ return { width: box.width, height: box.height };
91
+ }
92
+ }
93
+ return undefined;
94
+ };
95
+ const content = pick([metrics.cssContentSize, metrics.contentSize]);
96
+ const viewport = pick([metrics.cssLayoutViewport, metrics.layoutViewport]);
97
+ if (content === undefined)
98
+ return undefined;
99
+ return { width: content.width, height: content.height, viewportHeight: viewport?.height ?? content.height };
100
+ }
101
+ /**
102
+ * Map CDP cookies onto the shape `browser_auth` exports.
103
+ *
104
+ * CDP reports a cookie as domain + path rather than a URL, and in seconds rather than
105
+ * milliseconds, so both are converted. Entries missing a name, value or domain are
106
+ * dropped rather than exported as broken cookies the caller cannot restore.
107
+ * @param raw - the `cookies` array from a CDP response, of unknown shape.
108
+ * @returns the mappable cookies.
109
+ */
110
+ export function toExportedCookies(raw) {
111
+ if (!Array.isArray(raw))
112
+ return [];
113
+ const out = [];
114
+ for (const entry of raw) {
115
+ if (typeof entry !== 'object' || entry === null)
116
+ continue;
117
+ const cookie = entry;
118
+ // An empty domain is as unusable as a missing one: it cannot form a cookie URL, so
119
+ // exporting it would hand the caller something that fails to restore.
120
+ if (typeof cookie.name !== 'string' || cookie.name === '')
121
+ continue;
122
+ if (typeof cookie.value !== 'string')
123
+ continue;
124
+ if (typeof cookie.domain !== 'string' || cookie.domain === '')
125
+ continue;
126
+ const path = typeof cookie.path === 'string' && cookie.path !== '' ? cookie.path : '/';
127
+ // A leading dot means "this domain and its subdomains"; a cookie URL must not keep it.
128
+ const host = cookie.domain.replace(/^\./, '');
129
+ out.push({
130
+ url: `${cookie.secure === true ? 'https' : 'http'}://${host}${path}`,
131
+ name: cookie.name,
132
+ value: cookie.value,
133
+ domain: cookie.domain,
134
+ path,
135
+ ...cookie.secure === true ? { secure: true } : {},
136
+ ...cookie.httpOnly === true ? { httpOnly: true } : {},
137
+ ...typeof cookie.expires === 'number' && cookie.expires > 0 ? { expirationDate: cookie.expires } : {},
138
+ });
139
+ }
140
+ return out;
141
+ }
142
+ /**
143
+ * Map an exported cookie onto the fields `Storage.setCookies` expects.
144
+ *
145
+ * An exported cookie may carry only a URL, so the domain and path are recovered from it
146
+ * when they are absent; without either, CDP cannot place the cookie and it is skipped.
147
+ * @param cookie - the cookie to convert.
148
+ * @returns the CDP cookie, or undefined when it lacks a usable domain.
149
+ */
150
+ export function toCdpCookie(cookie) {
151
+ let domain = cookie.domain;
152
+ let path = cookie.path;
153
+ if (domain === undefined || domain === '') {
154
+ try {
155
+ const parsed = new URL(cookie.url);
156
+ domain = parsed.hostname;
157
+ if (path === undefined || path === '')
158
+ path = parsed.pathname === '' ? '/' : parsed.pathname;
159
+ }
160
+ catch {
161
+ return undefined;
162
+ }
163
+ }
164
+ if (typeof domain !== 'string' || domain === '')
165
+ return undefined;
166
+ return {
167
+ name: cookie.name,
168
+ value: cookie.value,
169
+ domain,
170
+ path: path === undefined || path === '' ? '/' : path,
171
+ ...cookie.secure === true ? { secure: true } : {},
172
+ ...cookie.httpOnly === true ? { httpOnly: true } : {},
173
+ ...typeof cookie.expirationDate === 'number' ? { expires: cookie.expirationDate } : {},
174
+ };
175
+ }
73
176
  /** CDP method for runtime evaluation (the execute path). */
74
177
  export const CDP_RUNTIME_EVALUATE = 'Runtime.evaluate';
75
178
  /** CDP method for navigation. */
@@ -275,11 +378,11 @@ export class ElectronBrowserProvider {
275
378
  const own = s.tabs.findIndex(tab => stripTabPrefix(tab.id) === wanted);
276
379
  if (own >= 0)
277
380
  return { s, index: own };
278
- for (const other of this.sessions.values()) {
279
- const index = other.tabs.findIndex(tab => stripTabPrefix(tab.id) === wanted);
280
- if (index >= 0)
281
- return { s: other, index };
282
- }
381
+ // Deliberately NOT searched across other sessions. Accepting a bare uuid and
382
+ // searching elsewhere are different things: ids are globally unique, so the first is
383
+ // harmless convenience, but the second lets a stale id close or switch a tab that
384
+ // belongs to another task — or to the human — while reporting success, which
385
+ // contradicts the isolation the tools promise.
283
386
  const ownIds = s.tabs.map(t => t.id).join(', ') || '(none)';
284
387
  throw new BrowserError(`browser: tab "${tabId}" is not open in this session (session tabs: ${ownIds})`, 'BROWSER_TAB_UNKNOWN');
285
388
  }
@@ -332,6 +435,11 @@ export class ElectronBrowserProvider {
332
435
  // Record as soon as the navigation has committed: history bookkeeping must
333
436
  // not wait on the best-effort settle below (which only delays what the
334
437
  // NEXT observation sees, not whether this action happened).
438
+ // The page has already moved by now, so a caller that aborted must be told that rather
439
+ // than handed a success: settleDocument returns quietly on abort by design, so without
440
+ // this the whole path completes exception-free — success reported, success recorded, and
441
+ // the visit written to the persistent history for a navigation nobody awaits any more.
442
+ signal?.throwIfAborted();
335
443
  this.record(s, 'navigate', { url }, true);
336
444
  await this.settleDocument(handle, before, signal);
337
445
  this.recordVisit(s, handle);
@@ -460,7 +568,11 @@ export class ElectronBrowserProvider {
460
568
  if (handle.focus === undefined)
461
569
  return;
462
570
  try {
463
- await handle.focus();
571
+ // Bounded, unlike the rest of this call: the child RPC has no timeout of its own,
572
+ // and this runs before typing, so an unresponsive child would hang browser_key for
573
+ // good. A timeout is caught below exactly like any other focus failure, so the
574
+ // best-effort contract is unchanged.
575
+ await withTimeout(handle.focus(), 5_000, undefined, 'browser: focusing the view timed out');
464
576
  }
465
577
  catch {
466
578
  // Best-effort: a view that refuses focus must not fail the caller's key
@@ -515,6 +627,12 @@ export class ElectronBrowserProvider {
515
627
  return { ok: true, value };
516
628
  }
517
629
  catch (error) {
630
+ // A caller that aborted is not a failure of the page: report its own reason, or the
631
+ // cancellation surfaces as "execute failed: AbortError" and — if the caller's reason
632
+ // happens to be named TimeoutError — as "timed out after 30000ms", quoting a budget
633
+ // that belongs to the caller rather than to this call.
634
+ if (signal?.aborted)
635
+ throw signal.reason;
518
636
  if (error instanceof Error && error.name === 'TimeoutError') {
519
637
  throw new BrowserError(`browser: execute timed out after ${request.timeoutMs ?? 30_000}ms`, 'BROWSER_EXECUTE_TIMEOUT', { cause: error });
520
638
  }
@@ -585,10 +703,9 @@ export class ElectronBrowserProvider {
585
703
  return { ready: false, reason: misses.join('; ') };
586
704
  }
587
705
  // Sleep between polls, abortable by the caller.
588
- await new Promise((resolve, reject) => {
589
- const timer = setTimeout(resolve, 200);
590
- signal?.addEventListener('abort', () => { clearTimeout(timer); reject(signal.reason ?? new Error('aborted')); }, { once: true });
591
- });
706
+ // Sleep between polls; delay() removes its abort listener on both paths, which the
707
+ // hand-rolled version here did not — a 30s wait leaked about 150 of them.
708
+ await delay(200, signal);
592
709
  }
593
710
  }
594
711
  /** Produce an AI-friendly snapshot of the active tab. */
@@ -1259,7 +1376,31 @@ export class ElectronBrowserProvider {
1259
1376
  continue
1260
1377
  }
1261
1378
  if (els.length === 0) { out.push({ ok: false, error: 'field not found', target: describe(spec) }); continue }
1262
- const el = els[0]
1379
+ let el = els[0]
1380
+ // The caller may name a kind. Honor it: the branches below read the element's own
1381
+ // tag/type, so without this a selector matching two different controls acted on
1382
+ // whichever came first — usually right, which is what hid the mistake.
1383
+ if (spec.kind) {
1384
+ const matchesKind = (node) => {
1385
+ const nodeTag = node.tagName
1386
+ const nodeType = (node.type || '').toLowerCase()
1387
+ if (spec.kind === 'select') return nodeTag === 'SELECT'
1388
+ if (spec.kind === 'textarea') return nodeTag === 'TEXTAREA'
1389
+ if (spec.kind === 'checkbox' || spec.kind === 'radio') return nodeType === spec.kind
1390
+ return nodeTag === 'INPUT' && nodeType !== 'checkbox' && nodeType !== 'radio' && nodeType !== 'file'
1391
+ }
1392
+ const wanted = els.find(matchesKind)
1393
+ if (wanted === undefined) {
1394
+ out.push({ ok: false, error: 'no "' + spec.kind + '" control matched', target: describe(spec) })
1395
+ continue
1396
+ }
1397
+ els.length = 0
1398
+ els.push(wanted)
1399
+ // el was bound before the filter narrowed els, so the branches below would still
1400
+ // read the first match — the very silent wrong-element behaviour this filter exists
1401
+ // to remove.
1402
+ el = els[0]
1403
+ }
1263
1404
  const tag = el.tagName
1264
1405
  const type = (el.type || '').toLowerCase()
1265
1406
  try {
@@ -1652,7 +1793,7 @@ export class ElectronBrowserProvider {
1652
1793
  }
1653
1794
  return { ok: true, count: out.length, items: out }
1654
1795
  })()`;
1655
- const result = await withTimeout(handleSendEvaluate(tab.handle, script), timeoutMs, signal, `browser: scrape timed out after ${timeoutMs}ms`, () => terminatePage(tab.handle));
1796
+ const result = await withTimeout(handleSendEvaluate(tab.handle, script), timeoutMs + TARGET_SCRIPT_GRACE_MS, signal, `browser: scrape timed out after ${timeoutMs}ms`, () => terminatePage(tab.handle));
1656
1797
  if (!result.ok)
1657
1798
  throw new BrowserError(`browser: scrape evaluation failed: ${result.exception}`, 'BROWSER_SCRAPE_FAILED');
1658
1799
  const value = result.value;
@@ -1751,28 +1892,54 @@ export class ElectronBrowserProvider {
1751
1892
  * Self-hosted only; the desktop shell's embedded views use the real profile.
1752
1893
  */
1753
1894
  async flushAuth(session) {
1895
+ // The settings panel offers "allow the agent to read cookies / export login state".
1896
+ // Until now nothing read that switch, so turning it off changed nothing at all —
1897
+ // the tool exported every cookie regardless.
1898
+ if (this.settingsSource?.().credentials.allowRead === false) {
1899
+ throw new BrowserError('browser: reading cookies is switched off in settings (Browser → credentials)', 'BROWSER_AUTH_DISABLED');
1900
+ }
1754
1901
  const s = this.session(session);
1755
1902
  const { handle } = this.activeTab(s);
1756
- const host = handle;
1757
- if (typeof host.flushAuth !== 'function') {
1758
- throw new BrowserError('browser: auth export is only available on the self-hosted browser', 'BROWSER_AUTH_UNSUPPORTED');
1759
- }
1760
1903
  const timeoutMs = 30_000;
1761
- const cookies = await withTimeout(host.flushAuth(), timeoutMs, undefined, `browser: auth export timed out after ${timeoutMs}ms`);
1762
- this.record(s, 'flushAuth', {}, true, { result: `${cookies.length} cookies` });
1904
+ // The self-hosted carrier has a native path (the child reads its own session), which
1905
+ // is worth preferring: it reports exactly what the browser persisted.
1906
+ const native = handle;
1907
+ if (typeof native.flushAuth === 'function') {
1908
+ const cookies = await withTimeout(native.flushAuth(), timeoutMs, undefined, `browser: auth export timed out after ${timeoutMs}ms`);
1909
+ this.record(s, 'flushAuth', {}, true, { result: `${cookies.length} cookies` });
1910
+ return cookies;
1911
+ }
1912
+ // Every other carrier drives a real browser over CDP, and cookies are part of CDP —
1913
+ // the sidebar goes through the shell's webContents.debugger, an installed Chrome or
1914
+ // Edge is the browser itself. So this works everywhere rather than only self-hosted.
1915
+ const result = await withTimeout(handle.sendCommand('Storage.getCookies', {}), timeoutMs, undefined, `browser: auth export timed out after ${timeoutMs}ms`);
1916
+ const cookies = toExportedCookies(result.cookies);
1917
+ this.record(s, 'flushAuth', {}, true, { result: `${cookies.length} cookies (CDP)` });
1763
1918
  return cookies;
1764
1919
  }
1765
1920
  /** Import cookies into the session (restore login state). Self-hosted only. */
1766
1921
  async restoreAuth(session, cookies) {
1922
+ // The settings panel offers "allow the agent to read cookies / export login state".
1923
+ // Until now nothing read that switch, so turning it off changed nothing at all —
1924
+ // the tool exported every cookie regardless.
1925
+ if (this.settingsSource?.().credentials.allowRead === false) {
1926
+ throw new BrowserError('browser: reading cookies is switched off in settings (Browser → credentials)', 'BROWSER_AUTH_DISABLED');
1927
+ }
1767
1928
  const s = this.session(session);
1768
1929
  const { handle } = this.activeTab(s);
1769
- const host = handle;
1770
- if (typeof host.restoreAuth !== 'function') {
1771
- throw new BrowserError('browser: auth restore is only available on the self-hosted browser', 'BROWSER_AUTH_UNSUPPORTED');
1772
- }
1773
1930
  const timeoutMs = 30_000;
1774
- const restored = await withTimeout(host.restoreAuth(cookies), timeoutMs, undefined, `browser: auth restore timed out after ${timeoutMs}ms`);
1775
- this.record(s, 'restoreAuth', { count: cookies.length }, true, { result: `${restored} cookies` });
1931
+ const native = handle;
1932
+ if (typeof native.restoreAuth === 'function') {
1933
+ const restored = await withTimeout(native.restoreAuth(cookies), timeoutMs, undefined, `browser: auth restore timed out after ${timeoutMs}ms`);
1934
+ this.record(s, 'restoreAuth', { count: cookies.length }, true, { result: `${restored} cookies` });
1935
+ return restored;
1936
+ }
1937
+ // CDP again, for the same reason as the export: any carrier that drives a real
1938
+ // browser can set cookies on it. Storage.setCookies needs the cookie fields spelled
1939
+ // the CDP way, including the sameSite spelling and seconds rather than milliseconds.
1940
+ const result = await withTimeout(handle.sendCommand('Storage.setCookies', { cookies: cookies.map(toCdpCookie) }), timeoutMs, undefined, `browser: auth restore timed out after ${timeoutMs}ms`);
1941
+ const restored = toExportedCookies(result.cookies).length;
1942
+ this.record(s, 'restoreAuth', { count: cookies.length }, true, { result: `${restored} cookies (CDP)` });
1776
1943
  return restored;
1777
1944
  }
1778
1945
  /** Capture the current page, optionally full-page, PNG or JPEG, scalable. */
@@ -1802,16 +1969,46 @@ export class ElectronBrowserProvider {
1802
1969
  }
1803
1970
  return this.saveScreenshot(shot.base64, request?.savePath, shot.mime ?? 'image/png');
1804
1971
  }
1805
- // Fallback: a desktop-shell handle (no capture()) or a full-page capture
1806
- // uses CDP; full-page needs `captureBeyondViewport` which capturePage
1807
- // lacks. PNG only — the JPEG hang is a CDP-on-Electron-43 defect.
1972
+ // Fallback: a handle without a native capture() (the sidebar, or an installed Chrome
1973
+ // or Edge) uses CDP; full-page needs `captureBeyondViewport`, which capturePage lacks.
1974
+ //
1975
+ // JPEG: Electron 43's CDP hangs on it, which is why the sidebar stays PNG. An
1976
+ // installed Chrome or Edge is the real browser and has no such defect, so it says so
1977
+ // through `supportsCdpJpeg` and gets the format it asked for, and the same for
1978
+ // downscaling, which CDP performs through a full-document clip.
1979
+ const timeoutMs = 30_000;
1808
1980
  const params = {};
1809
1981
  if (request?.fullPage === true) {
1810
1982
  // `captureBeyondViewport` captures the full scrollable content; without
1811
1983
  // a clip this yields the full-page image (CDP default is the viewport).
1812
1984
  params.captureBeyondViewport = true;
1813
1985
  }
1814
- const timeoutMs = 30_000;
1986
+ const supportsJpeg = handle.supportsCdpJpeg === true;
1987
+ if (format === 'jpeg' && supportsJpeg) {
1988
+ params.format = 'jpeg';
1989
+ // CDP range is 0-100 and defaults to 80 when omitted.
1990
+ if (request?.quality !== undefined)
1991
+ params.quality = request.quality;
1992
+ }
1993
+ // Downscaling on this path: CDP scales through `clip.scale`, and a clip only applies
1994
+ // to the region it names, so it has to cover the whole document. The page's own size
1995
+ // is read first; when that is unavailable the capture proceeds unscaled rather than
1996
+ // failing — an image the caller can shrink itself beats no image at all.
1997
+ if (request?.maxWidth !== undefined || request?.maxHeight !== undefined) {
1998
+ const metrics = await withTimeout(handle.sendCommand('Page.getLayoutMetrics', {}), timeoutMs, signal, `browser: screenshot timed out after ${timeoutMs}ms`);
1999
+ const size = layoutSize(metrics);
2000
+ if (size !== undefined) {
2001
+ const height = request.fullPage === true ? size.height : size.viewportHeight;
2002
+ const byWidth = request.maxWidth !== undefined && size.width > 0 ? request.maxWidth / size.width : 1;
2003
+ const byHeight = request.maxHeight !== undefined && height > 0 ? request.maxHeight / height : 1;
2004
+ const scale = Math.min(1, byWidth, byHeight);
2005
+ if (scale < 1) {
2006
+ params.clip = { x: 0, y: 0, width: size.width, height, scale };
2007
+ // The clip defines the captured area; captureBeyondViewport must not also apply.
2008
+ delete params.captureBeyondViewport;
2009
+ }
2010
+ }
2011
+ }
1815
2012
  const result = await withTimeout(handle.sendCommand(CDP_PAGE_CAPTURE_SCREENSHOT, params), timeoutMs, signal, `browser: screenshot timed out after ${timeoutMs}ms`);
1816
2013
  const data = result.data;
1817
2014
  if (typeof data !== 'string') {
@@ -2151,17 +2348,24 @@ export class ElectronBrowserProvider {
2151
2348
  if (!reported)
2152
2349
  process.stderr.write(`[dsh-browser] user action failed: ${message}\n`);
2153
2350
  }
2154
- /** Ask the host to show the active tab's view, carrying the session label. */
2351
+ /**
2352
+ * Ask the host to show the active tab's view, carrying the session label.
2353
+ *
2354
+ * The two halves are sequential, not alternatives. `showView` makes the view the
2355
+ * visible one — which is what keeps what the human sees in step with what the agent
2356
+ * drives, and what lets `capturePage` return an image at all (a hidden view captures
2357
+ * empty) — while `collapse` folds the carrier's own chrome away when the user asked
2358
+ * for no auto-expand. Making them exclusive meant that with auto-expand off this
2359
+ * method did nothing at all on carriers without `collapse` (the self-hosted one),
2360
+ * silently desynchronising the two and failing every screenshot.
2361
+ */
2155
2362
  showActive(s) {
2156
- // §3: whether the agent's page takes over the screen is the user's choice, not
2157
- // a fixed behaviour. With auto-expand off the page still exists and is still
2158
- // driven — it is simply folded away, so the agent works without claiming the
2159
- // sidebar. Carriers that own no presentation (self-hosted) ignore the call.
2160
- if (this.settingsSource !== undefined && !this.settingsSource().ui.autoExpandOnce) {
2161
- void this.host.collapse?.().catch(() => undefined);
2162
- return;
2163
- }
2164
2363
  this.host.showView?.(this.activeTab(s).handle, s.label);
2364
+ const autoExpand = this.settingsSource === undefined || this.settingsSource().ui.autoExpandOnce;
2365
+ if (autoExpand)
2366
+ return;
2367
+ // Best effort: a carrier that owns no presentation (self-hosted) simply has none.
2368
+ void this.host.collapse?.().catch(() => undefined);
2165
2369
  }
2166
2370
  /** Read the current URL of a view through CDP. */
2167
2371
  async currentUrl(handle) {
@@ -868,9 +868,12 @@ export class RemoteElectronViewHost {
868
868
  }
869
869
  /** The child died: tear down so the next use starts a fresh child. */
870
870
  onChildExit() {
871
- if (this.disposed)
872
- return;
873
- process.stderr.write('[dsh-browser host] browser host gone; will restart on next use\n');
871
+ // A disposed host still has to drop these: returning here left a child that had
872
+ // already been replaced during a dispose race in no map at all, so nothing would
873
+ // ever kill it. Only the restart is suppressed, not the cleanup.
874
+ const wasDisposed = this.disposed;
875
+ if (!wasDisposed)
876
+ process.stderr.write('[dsh-browser host] browser host gone; will restart on next use\n');
874
877
  this.client = undefined;
875
878
  this.server?.close();
876
879
  this.server = undefined;
@@ -157,11 +157,17 @@ export class SettingsStore {
157
157
  mkdirSync(dirname(this.file), { recursive: true });
158
158
  writeFileSync(this.file, `${JSON.stringify(merged, null, 2)}\n`);
159
159
  }
160
- catch {
161
- // Keep the merged value in memory even when the write fails.
160
+ catch (error) {
161
+ // Do NOT publish the new value as cached, and do not pretend the file changed: a
162
+ // reader compares the file's mtime against this stamp, so stamping "now" on a failed
163
+ // write made the next get() re-read the file and silently revert the user's change.
164
+ // Clear the cache before failing: the value on disk is still the old one, so leaving
165
+ // the merged document cached would hand the next reader something that does not exist —
166
+ // and the stamp would make it look fresh until the mtime comparison rolled it back.
167
+ this.cached = undefined;
168
+ this.cachedMtimeMs = -1;
169
+ throw new Error(`dsh-builtin-browser: settings could not be saved: ${error instanceof Error ? error.message : String(error)}`);
162
170
  }
163
- this.cached = merged;
164
- this.cachedMtimeMs = Date.now();
165
171
  return merged;
166
172
  }
167
173
  }