dsh-builtin-browser 0.3.1 → 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.
- package/README.en.md +75 -30
- package/README.md +70 -30
- package/cordis.patch.yml +13 -7
- package/desktop-bridge/install.mjs +274 -12
- package/desktop-bridge/plugin-browser-bridge.js +55 -15
- package/lib/browser-electron/desktop-bridge-host.d.ts +10 -1
- package/lib/browser-electron/desktop-bridge-host.js +10 -2
- package/lib/browser-electron/entry.js +22 -2
- package/lib/browser-electron/history-store.js +5 -1
- package/lib/browser-electron/missing-system-browser.d.ts +54 -0
- package/lib/browser-electron/missing-system-browser.js +89 -0
- package/lib/browser-electron/provider.d.ts +54 -1
- package/lib/browser-electron/provider.js +241 -37
- package/lib/browser-electron/remote-host.js +6 -3
- package/lib/browser-electron/settings-store.js +10 -4
- package/lib/browser-electron/system-browser.d.ts +27 -6
- package/lib/browser-electron/system-browser.js +222 -31
- package/lib/tool-browser/index.d.ts +14 -0
- package/lib/tool-browser/index.js +142 -57
- package/package.json +1 -1
- package/src/browser-electron/desktop-bridge-host.ts +11 -2
- package/src/browser-electron/entry.ts +22 -2
- package/src/browser-electron/history-store.ts +5 -1
- package/src/browser-electron/missing-system-browser.ts +113 -0
- package/src/browser-electron/provider.ts +267 -36
- package/src/browser-electron/remote-host.ts +6 -3
- package/src/browser-electron/settings-store.ts +11 -4
- package/src/browser-electron/system-browser.ts +210 -30
- package/src/tool-browser/index.ts +148 -62
|
@@ -30,34 +30,121 @@ import { join } from 'node:path';
|
|
|
30
30
|
import { spawn } from 'node:child_process';
|
|
31
31
|
import { randomUUID } from 'node:crypto';
|
|
32
32
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
33
|
+
* Launcher names to look for on PATH, per product and per platform.
|
|
34
|
+
*
|
|
35
|
+
* The names genuinely differ: Linux distributions package the launcher under a
|
|
36
|
+
* versioned name (`microsoft-edge-stable`, `google-chrome-stable`), Windows puts
|
|
37
|
+
* `chrome.exe` in an Application directory that may or may not be on PATH, and macOS
|
|
38
|
+
* ships the executable inside the .app bundle. PATH is still consulted first on every
|
|
39
|
+
* platform — it is what reflects how the user actually installed the browser.
|
|
40
|
+
*/
|
|
41
|
+
const PATH_NAMES = {
|
|
42
|
+
chrome: {
|
|
43
|
+
win32: ['chrome'],
|
|
44
|
+
darwin: ['Google Chrome', 'Chromium'],
|
|
45
|
+
linux: ['google-chrome-stable', 'google-chrome', 'chromium', 'chromium-browser'],
|
|
46
|
+
},
|
|
47
|
+
edge: {
|
|
48
|
+
win32: ['msedge'],
|
|
49
|
+
darwin: ['Microsoft Edge'],
|
|
50
|
+
linux: ['microsoft-edge-stable', 'microsoft-edge', 'microsoft-edge-dev'],
|
|
51
|
+
},
|
|
52
|
+
brave: {
|
|
53
|
+
win32: ['brave'],
|
|
54
|
+
darwin: ['Brave Browser'],
|
|
55
|
+
linux: ['brave-browser', 'brave'],
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* Where each product installs when its launcher is not on PATH.
|
|
60
|
+
*
|
|
61
|
+
* Windows keeps the executable inside a version-less Application directory; macOS
|
|
62
|
+
* keeps it inside the .app bundle; Linux distributions that do not use a launcher
|
|
63
|
+
* put it under /usr/bin or /opt. All are checked, and anything missed can be named
|
|
64
|
+
* explicitly with DSH_BROWSER_<KIND>_PATH.
|
|
36
65
|
*/
|
|
37
|
-
const
|
|
38
|
-
chrome:
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
66
|
+
const INSTALL_LOCATIONS = {
|
|
67
|
+
chrome: {
|
|
68
|
+
win32: [
|
|
69
|
+
String.raw `C:\Program Files\Google\Chrome\Application\chrome.exe`,
|
|
70
|
+
String.raw `C:\Program Files (x86)\Google\Chrome\Application\chrome.exe`,
|
|
71
|
+
],
|
|
72
|
+
darwin: [
|
|
73
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
74
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
75
|
+
],
|
|
76
|
+
linux: [
|
|
77
|
+
'/usr/bin/google-chrome-stable',
|
|
78
|
+
'/usr/bin/google-chrome',
|
|
79
|
+
'/usr/bin/chromium',
|
|
80
|
+
'/opt/google/chrome/chrome',
|
|
81
|
+
'/snap/bin/chromium',
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
edge: {
|
|
85
|
+
win32: [
|
|
86
|
+
String.raw `C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`,
|
|
87
|
+
String.raw `C:\Program Files\Microsoft\Edge\Application\msedge.exe`,
|
|
88
|
+
],
|
|
89
|
+
darwin: ['/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge'],
|
|
90
|
+
linux: [
|
|
91
|
+
'/usr/bin/microsoft-edge-stable',
|
|
92
|
+
'/usr/bin/microsoft-edge',
|
|
93
|
+
'/usr/bin/microsoft-edge-dev',
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
brave: {
|
|
97
|
+
win32: [
|
|
98
|
+
String.raw `C:\Program Files\BraveSoftware\Brave-Browser\Application\brave.exe`,
|
|
99
|
+
String.raw `C:\Program Files (x86)\BraveSoftware\Brave-Browser\Application\brave.exe`,
|
|
100
|
+
],
|
|
101
|
+
darwin: ['/Applications/Brave Browser.app/Contents/MacOS/Brave Browser'],
|
|
102
|
+
linux: ['/usr/bin/brave-browser', '/usr/bin/brave', '/snap/bin/brave'],
|
|
103
|
+
},
|
|
50
104
|
};
|
|
105
|
+
/**
|
|
106
|
+
* Find an executable by name on PATH.
|
|
107
|
+
*
|
|
108
|
+
* PATH is read from the supplied environment rather than the process's own, so the
|
|
109
|
+
* detection stays testable. On Windows a bare name needs its extension; elsewhere
|
|
110
|
+
* the launcher is the file itself.
|
|
111
|
+
* @param names - candidate launcher names, in preference order.
|
|
112
|
+
* @param env - the environment to read PATH from.
|
|
113
|
+
* @param platform - the platform whose PATH and extension conventions apply.
|
|
114
|
+
* @returns the first existing absolute path, or undefined.
|
|
115
|
+
*/
|
|
116
|
+
function findOnPath(names, env, platform) {
|
|
117
|
+
// Windows spells it Path as often as PATH; the separator differs too.
|
|
118
|
+
const raw = env.PATH ?? env.Path ?? env.path ?? '';
|
|
119
|
+
const separator = platform === 'win32' ? ';' : ':';
|
|
120
|
+
const directories = raw.split(separator).map(entry => entry.trim()).filter(entry => entry !== '');
|
|
121
|
+
const suffixes = platform === 'win32' ? ['.exe', '.cmd', ''] : [''];
|
|
122
|
+
for (const name of names) {
|
|
123
|
+
for (const directory of directories) {
|
|
124
|
+
for (const suffix of suffixes) {
|
|
125
|
+
const candidate = join(directory, `${name}${suffix}`);
|
|
126
|
+
if (existsSync(candidate))
|
|
127
|
+
return candidate;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
51
133
|
/**
|
|
52
134
|
* Find an installed Chromium browser.
|
|
53
135
|
*
|
|
54
136
|
* `auto` prefers Chrome, then Edge, then Brave — the order reflects how likely each
|
|
55
|
-
* is to be the browser a user actually chose rather than one
|
|
137
|
+
* is to be the browser a user actually chose rather than one the system shipped.
|
|
138
|
+
* Each candidate is looked up in three places, most specific first: an explicit
|
|
139
|
+
* environment override, then PATH, then the platform's conventional install
|
|
140
|
+
* locations. PATH comes before the fixed paths because it is what actually reflects
|
|
141
|
+
* how the browser was installed.
|
|
56
142
|
* @param channel - the configured choice; `bundled` never resolves to a system browser.
|
|
57
143
|
* @param env - environment lookup, injected so tests need no real machine.
|
|
144
|
+
* @param platform - the platform to use conventions for; injected for the same reason.
|
|
58
145
|
* @returns the detected browser, or undefined when the choice is unavailable.
|
|
59
146
|
*/
|
|
60
|
-
export function detectBrowser(channel, env = process.env) {
|
|
147
|
+
export function detectBrowser(channel, env = process.env, platform = process.platform) {
|
|
61
148
|
if (channel === 'bundled')
|
|
62
149
|
return undefined;
|
|
63
150
|
const order = channel === 'auto' ? ['chrome', 'edge', 'brave'] : [channel];
|
|
@@ -65,16 +152,33 @@ export function detectBrowser(channel, env = process.env) {
|
|
|
65
152
|
const fromEnv = (kind === 'chrome' ? env.DSH_BROWSER_CHROME_PATH : kind === 'edge' ? env.DSH_BROWSER_EDGE_PATH : undefined);
|
|
66
153
|
if (fromEnv !== undefined && fromEnv !== '' && existsSync(fromEnv))
|
|
67
154
|
return { kind, path: fromEnv };
|
|
68
|
-
|
|
155
|
+
const onPath = findOnPath(PATH_NAMES[kind]?.[platform] ?? [], env, platform);
|
|
156
|
+
if (onPath !== undefined)
|
|
157
|
+
return { kind, path: onPath };
|
|
158
|
+
for (const candidate of INSTALL_LOCATIONS[kind]?.[platform] ?? []) {
|
|
69
159
|
if (existsSync(candidate))
|
|
70
160
|
return { kind, path: candidate };
|
|
71
161
|
}
|
|
72
162
|
}
|
|
73
163
|
return undefined;
|
|
74
164
|
}
|
|
165
|
+
/**
|
|
166
|
+
* Everything that was checked for one product, for reporting a miss.
|
|
167
|
+
*
|
|
168
|
+
* Detection returns undefined with no explanation, which left the caller able to say
|
|
169
|
+
* only "not found". This gives the user the actual list so they can see whether the
|
|
170
|
+
* plugin looked somewhere their browser is not.
|
|
171
|
+
* @param kind - the product to describe.
|
|
172
|
+
* @param platform - the platform whose names and locations apply.
|
|
173
|
+
* @returns the launcher names (as PATH lookups) and the fixed locations checked.
|
|
174
|
+
*/
|
|
175
|
+
export function searchSummary(kind, platform = process.platform) {
|
|
176
|
+
const onPath = (PATH_NAMES[kind]?.[platform] ?? []).map(name => `${name} (on PATH)`);
|
|
177
|
+
const located = INSTALL_LOCATIONS[kind]?.[platform] ?? [];
|
|
178
|
+
return [...onPath, ...located];
|
|
179
|
+
}
|
|
75
180
|
/** One CDP connection to a browser-level WebSocket, with flattened sessions. */
|
|
76
181
|
class CdpClient {
|
|
77
|
-
url;
|
|
78
182
|
nextId = 1;
|
|
79
183
|
pending = new Map();
|
|
80
184
|
ready;
|
|
@@ -82,7 +186,6 @@ class CdpClient {
|
|
|
82
186
|
* @param url - the browser's `webSocketDebuggerUrl`.
|
|
83
187
|
*/
|
|
84
188
|
constructor(url) {
|
|
85
|
-
this.url = url;
|
|
86
189
|
// Node 22 provides WebSocket globally; no dependency is added for this.
|
|
87
190
|
const socket = new WebSocket(url);
|
|
88
191
|
this.socket = socket;
|
|
@@ -94,9 +197,30 @@ class CdpClient {
|
|
|
94
197
|
socket.addEventListener('close', () => this.fail(new Error('dsh-builtin-browser: the browser closed the CDP connection')));
|
|
95
198
|
}
|
|
96
199
|
socket;
|
|
97
|
-
/**
|
|
200
|
+
/**
|
|
201
|
+
* Resolve once the socket is open.
|
|
202
|
+
*
|
|
203
|
+
* Bounded on purpose. `ready` settles on the socket's own `open` or `error` events, and
|
|
204
|
+
* a connection that is dropped by something in between may deliver neither — in which
|
|
205
|
+
* case an unbounded await here would hang the tool call forever, because the caller's
|
|
206
|
+
* own deadline is only checked between polls and this await happens inside one of them.
|
|
207
|
+
* @returns when the handshake completed.
|
|
208
|
+
*/
|
|
98
209
|
async whenReady() {
|
|
99
|
-
|
|
210
|
+
const timeoutMs = 30_000;
|
|
211
|
+
let timer;
|
|
212
|
+
try {
|
|
213
|
+
await Promise.race([
|
|
214
|
+
this.ready,
|
|
215
|
+
new Promise((_resolve, reject) => {
|
|
216
|
+
timer = setTimeout(() => reject(new Error(`dsh-builtin-browser: the browser did not complete the CDP handshake within ${timeoutMs / 1000}s`)), timeoutMs);
|
|
217
|
+
}),
|
|
218
|
+
]);
|
|
219
|
+
}
|
|
220
|
+
finally {
|
|
221
|
+
if (timer !== undefined)
|
|
222
|
+
clearTimeout(timer);
|
|
223
|
+
}
|
|
100
224
|
}
|
|
101
225
|
/**
|
|
102
226
|
* Send one CDP command, optionally on an attached session.
|
|
@@ -198,6 +322,10 @@ export class SystemBrowserViewHost {
|
|
|
198
322
|
* @returns the connected client.
|
|
199
323
|
*/
|
|
200
324
|
async ensureClient() {
|
|
325
|
+
// A disposed host must never start a browser: doing so would spawn a process
|
|
326
|
+
// nobody owns and nobody will kill.
|
|
327
|
+
if (this.disposed)
|
|
328
|
+
throw new Error('dsh-builtin-browser: this browser host was released');
|
|
201
329
|
if (this.client !== undefined)
|
|
202
330
|
return this.client;
|
|
203
331
|
// Concurrent first calls share one startup rather than racing two browsers.
|
|
@@ -228,16 +356,52 @@ export class SystemBrowserViewHost {
|
|
|
228
356
|
];
|
|
229
357
|
const child = spawn(this.browser.path, args, { stdio: ['ignore', 'ignore', 'ignore'], windowsHide: false });
|
|
230
358
|
this.child = child;
|
|
359
|
+
/**
|
|
360
|
+
* A launch that fails (ENOENT, no permission, a broken binary) is reported by an
|
|
361
|
+
* asynchronous 'error' event on the child, NOT by a throw here and NOT by exitCode.
|
|
362
|
+
* Without this listener Node treats it as unhandled and takes the whole host process
|
|
363
|
+
* down — one bad browser path would kill DSH, not just fail a tool call.
|
|
364
|
+
*/
|
|
365
|
+
let launchError;
|
|
366
|
+
child.on('error', error => { launchError = error; });
|
|
231
367
|
const portFile = join(this.profileDir, 'DevToolsActivePort');
|
|
232
368
|
const deadline = Date.now() + 30_000;
|
|
369
|
+
/**
|
|
370
|
+
* Why the loop stopped, so the failure can say what actually happened. Reporting
|
|
371
|
+
* every ending as "did not expose CDP within 30s" was wrong three ways: the cause,
|
|
372
|
+
* the words, and the elapsed time — a browser that cannot be launched says so in
|
|
373
|
+
* half a second, and the caller then looks for a CDP problem that does not exist.
|
|
374
|
+
*/
|
|
375
|
+
let stopped = 'timeout';
|
|
233
376
|
for (;;) {
|
|
377
|
+
// Released while we were starting: stop here rather than keep polling for a
|
|
378
|
+
// browser that dispose() has already killed, which would leave a connection
|
|
379
|
+
// to a dead process behind. The child is killed here too — dispose() may have
|
|
380
|
+
// run before this child was recorded, leaving nobody else to do it.
|
|
381
|
+
if (this.disposed) {
|
|
382
|
+
child.kill();
|
|
383
|
+
if (this.child === child)
|
|
384
|
+
this.child = undefined;
|
|
385
|
+
stopped = 'released';
|
|
386
|
+
break;
|
|
387
|
+
}
|
|
388
|
+
if (launchError !== undefined) {
|
|
389
|
+
if (this.child === child)
|
|
390
|
+
this.child = undefined;
|
|
391
|
+
stopped = 'launch-failed';
|
|
392
|
+
break;
|
|
393
|
+
}
|
|
234
394
|
if (Date.now() > deadline) {
|
|
235
395
|
child.kill();
|
|
236
|
-
this.child
|
|
396
|
+
if (this.child === child)
|
|
397
|
+
this.child = undefined;
|
|
398
|
+
stopped = 'timeout';
|
|
237
399
|
break;
|
|
238
400
|
}
|
|
239
401
|
if (child.exitCode !== null) {
|
|
240
|
-
this.child
|
|
402
|
+
if (this.child === child)
|
|
403
|
+
this.child = undefined;
|
|
404
|
+
stopped = 'exited';
|
|
241
405
|
break;
|
|
242
406
|
}
|
|
243
407
|
if (existsSync(portFile)) {
|
|
@@ -248,6 +412,12 @@ export class SystemBrowserViewHost {
|
|
|
248
412
|
if (typeof version.webSocketDebuggerUrl === 'string') {
|
|
249
413
|
const client = new CdpClient(version.webSocketDebuggerUrl);
|
|
250
414
|
await client.whenReady();
|
|
415
|
+
// Still wanted? A dispose() during the handshake has already killed the
|
|
416
|
+
// process, so publishing the client would leave a dangling connection.
|
|
417
|
+
if (this.disposed) {
|
|
418
|
+
client.close();
|
|
419
|
+
break;
|
|
420
|
+
}
|
|
251
421
|
this.client = client;
|
|
252
422
|
return client;
|
|
253
423
|
}
|
|
@@ -259,7 +429,20 @@ export class SystemBrowserViewHost {
|
|
|
259
429
|
}
|
|
260
430
|
await new Promise(resolve => setTimeout(resolve, 250));
|
|
261
431
|
}
|
|
262
|
-
|
|
432
|
+
// Each ending gets its own message. The old single message blamed a CDP timeout for
|
|
433
|
+
// all of them, which sent the reader looking for a port problem when the browser had
|
|
434
|
+
// simply failed to launch.
|
|
435
|
+
const target = `${this.browser.kind} (${this.browser.path})`;
|
|
436
|
+
if (stopped === 'launch-failed') {
|
|
437
|
+
throw new Error(`dsh-builtin-browser: could not launch ${target}: ${launchError?.message ?? 'unknown error'}`);
|
|
438
|
+
}
|
|
439
|
+
if (stopped === 'exited') {
|
|
440
|
+
throw new Error(`dsh-builtin-browser: ${target} exited immediately after launch (exit code ${child.exitCode}); check that the path is a runnable browser`);
|
|
441
|
+
}
|
|
442
|
+
if (stopped === 'released') {
|
|
443
|
+
throw new Error(`dsh-builtin-browser: ${target} was released while it was starting`);
|
|
444
|
+
}
|
|
445
|
+
throw new Error(`dsh-builtin-browser: ${target} did not expose CDP within 30s`);
|
|
263
446
|
}
|
|
264
447
|
/**
|
|
265
448
|
* Whether this host can back views.
|
|
@@ -275,6 +458,9 @@ export class SystemBrowserViewHost {
|
|
|
275
458
|
const viewId = randomUUID();
|
|
276
459
|
return {
|
|
277
460
|
id: viewId,
|
|
461
|
+
// This is the real browser, not Electron: CDP's JPEG encoder works here, so the
|
|
462
|
+
// provider may pass format and quality through instead of falling back to PNG.
|
|
463
|
+
supportsCdpJpeg: true,
|
|
278
464
|
sendCommand: async (method, params) => {
|
|
279
465
|
const client = await this.ensureClient();
|
|
280
466
|
const session = this.views.get(viewId) ?? await this.ensureSession(viewId);
|
|
@@ -305,17 +491,22 @@ export class SystemBrowserViewHost {
|
|
|
305
491
|
/** The browser owns window grouping. */
|
|
306
492
|
groupView() { }
|
|
307
493
|
/**
|
|
308
|
-
*
|
|
494
|
+
* Best effort at bringing the browser's window forward.
|
|
309
495
|
*
|
|
310
|
-
* The browser is a separate application, so there is no view to raise
|
|
311
|
-
*
|
|
312
|
-
*
|
|
496
|
+
* The browser is a separate application, so there is no view to raise. What this can
|
|
497
|
+
* actually do is ask its process to continue from a stopped state — which is a no-op on
|
|
498
|
+
* Windows, where `SIGCONT` is not a supported signal and the call throws. It is kept
|
|
499
|
+
* because it costs nothing and does help on POSIX (a process stopped with SIGSTOP
|
|
500
|
+
* resumes), but it must not be read as a promise that the window comes to the front;
|
|
501
|
+
* importantly, it does NOT raise a window that is merely behind another. Raising the
|
|
502
|
+
* page properly would need `Page.bringToFront` on a specific target session, which is a
|
|
503
|
+
* view-level concern and not available to the host.
|
|
313
504
|
*/
|
|
314
505
|
async focus() {
|
|
315
506
|
try {
|
|
316
507
|
this.child?.kill('SIGCONT');
|
|
317
508
|
}
|
|
318
|
-
catch { /*
|
|
509
|
+
catch { /* Windows has no SIGCONT; nothing to do */ }
|
|
319
510
|
}
|
|
320
511
|
/** The browser reports its own window lifecycle. */
|
|
321
512
|
onUserAction() { }
|
|
@@ -28,6 +28,20 @@ export interface Config {
|
|
|
28
28
|
/** Optional initial allow-list of browser tool names; other tools are refused. */
|
|
29
29
|
readonly allowedActions?: readonly string[];
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Guard one browser tool call against the active restriction. Refuses calls
|
|
33
|
+
* not on the allow-list when a restriction is in effect.
|
|
34
|
+
* @param state - the calling context's tool state.
|
|
35
|
+
* @param toolName - the browser tool about to run.
|
|
36
|
+
*/
|
|
37
|
+
/**
|
|
38
|
+
* Tools that only observe, plus the ones that undo a restriction.
|
|
39
|
+
*
|
|
40
|
+
* Never subject to an allow-list: restricting what the agent may *do* must not also
|
|
41
|
+
* blind it or trap it, and the tool descriptions promise as much. browser_restrict
|
|
42
|
+
* is in here so a task that restricted everything can still lift it.
|
|
43
|
+
*/
|
|
44
|
+
export declare const READ_ONLY_TOOLS: ReadonlySet<string>;
|
|
31
45
|
/** Register all browser tools with `ctx.tools`. */
|
|
32
46
|
export declare function apply(ctx: Context, config?: Config): void;
|
|
33
47
|
/** Test hook: inspect and reset session mappings across every live plugin apply. */
|