bwb-browser 4.0.0 → 4.1.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/AGENTS.md +43 -17
- package/README.md +88 -40
- package/lib/act.mjs +474 -215
- package/lib/browser.mjs +189 -32
- package/lib/config.mjs +180 -0
- package/lib/diagnose.mjs +13 -11
- package/lib/fetch.mjs +187 -75
- package/lib/fingerprint.mjs +73 -28
- package/lib/helpers.mjs +86 -39
- package/lib/session.mjs +34 -7
- package/lib/setup.mjs +94 -13
- package/lib/tabs.mjs +127 -15
- package/lib/urlpolicy.mjs +137 -0
- package/lib/vigil.mjs +44 -21
- package/package.json +15 -7
- package/server.mjs +433 -213
package/lib/browser.mjs
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
* restart, orphan cleanup, and screenshot persistence.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { spawn, execSync } from "child_process";
|
|
8
|
+
import { spawn, execSync, execFileSync } from "child_process";
|
|
9
9
|
import { homedir, platform } from "os";
|
|
10
|
-
import { existsSync, mkdirSync, writeFileSync } from "fs";
|
|
10
|
+
import { existsSync, mkdirSync, writeFileSync, readdirSync, statSync, unlinkSync, readFileSync } from "fs";
|
|
11
11
|
import { join } from "path";
|
|
12
12
|
import CDP from "chrome-remote-interface";
|
|
13
13
|
|
|
@@ -35,8 +35,23 @@ export const cfg = {
|
|
|
35
35
|
nuclear: false, // --single-process: max RAM saving, min stability. Opt-in only.
|
|
36
36
|
idleMs: null, // null = auto (5min lean, off desktop); 0 = never teardown
|
|
37
37
|
tabMax: null, // null = auto (3 lean, unlimited desktop); 0 = unlimited
|
|
38
|
+
attachPort: 0, // 0 = off (spawn own browser). N = attach to existing
|
|
39
|
+
// browser's CDP port (e.g. 9222) — never spawns, never kills.
|
|
40
|
+
noSandbox: false, // disable the Chromium sandbox (auto on Termux / as root)
|
|
41
|
+
allowPrivate: false, // allow static fetches to loopback/private addresses
|
|
42
|
+
allowDomains: null, // optional host allowlist for navigation
|
|
43
|
+
readonly: false, // refuse every state-changing tool
|
|
44
|
+
confirmDestructive: true, // act-clicks on destructive labels need confirmation
|
|
45
|
+
alwaysBrowser: false, // skip the static rung entirely
|
|
46
|
+
journalFull: false, // journal full URLs (with query strings) and auto-restore
|
|
47
|
+
shotKeep: 50, // screenshots retained on disk
|
|
38
48
|
};
|
|
39
49
|
|
|
50
|
+
// True when connected to a foreign (user-owned) browser via --attach-port.
|
|
51
|
+
// Attached browsers are never spawned, killed, or journal-restored —
|
|
52
|
+
// bwb is a guest there. Tab hibernation still works but closes REAL tabs.
|
|
53
|
+
export let attached = false;
|
|
54
|
+
|
|
40
55
|
// ─── Environment ─────────────────────────────────────────────────────────────
|
|
41
56
|
|
|
42
57
|
export function isTermux() {
|
|
@@ -77,24 +92,81 @@ export function pokeActivity() {
|
|
|
77
92
|
|
|
78
93
|
// `fuser -k` and `lsof` can't read /proc/net/tcp on Termux/Android (permission denied).
|
|
79
94
|
// Instead, kill by PID from `ps` — works on every platform.
|
|
80
|
-
// Only kills bwb's headless Chrome instances (marked by
|
|
81
|
-
// NOT the user's normal Chrome browser.
|
|
95
|
+
// Only kills bwb's own headless Chrome instances (marked by remote-debugging-port
|
|
96
|
+
// AND our user-data-dir), NOT the user's normal Chrome browser.
|
|
97
|
+
//
|
|
98
|
+
// The process list is read with execFile and filtered in JS: the previous
|
|
99
|
+
// `ps aux | grep … | xargs kill` pipeline put cfg.userDataDir into a shell
|
|
100
|
+
// string with only `"` and `\` escaped, and it does not exist on Windows.
|
|
82
101
|
export function killOrphanedChrome() {
|
|
102
|
+
if (platform() === "win32") return; // no ps/grep/awk/xargs; use the CDP port instead
|
|
103
|
+
let out;
|
|
83
104
|
try {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
);
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
);
|
|
105
|
+
out = execFileSync("ps", ["-eo", "pid=,args="], { encoding: "utf8", timeout: 5000 });
|
|
106
|
+
} catch {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const scope = cfg.userDataDir ? String(cfg.userDataDir) : null;
|
|
110
|
+
const pids = [];
|
|
111
|
+
for (const line of out.split("\n")) {
|
|
112
|
+
const m = /^\s*(\d+)\s+(.*)$/.exec(line);
|
|
113
|
+
if (!m) continue;
|
|
114
|
+
const [, pid, args] = m;
|
|
115
|
+
if (!args.includes("remote-debugging-port")) continue;
|
|
116
|
+
if (args.includes("--type=")) continue; // renderers/zygotes die with the parent
|
|
117
|
+
if (scope && !args.includes(scope)) continue;
|
|
118
|
+
pids.push(Number(pid));
|
|
119
|
+
}
|
|
120
|
+
if (!pids.length) return;
|
|
121
|
+
for (const pid of pids) { try { process.kill(pid, "SIGTERM"); } catch {} }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ─── Profile Lock (two agents, one profile) ───────────────────────────────────
|
|
125
|
+
// Chrome locks its user-data-dir, but bwb's killOrphanedChrome runs first and
|
|
126
|
+
// would SIGTERM the OTHER agent's browser. A tiny owner file turns that from
|
|
127
|
+
// "one agent silently kills the other mid-task" into a clear error.
|
|
128
|
+
|
|
129
|
+
function ownerLockPath() {
|
|
130
|
+
return cfg.userDataDir ? join(cfg.userDataDir, "bwb-owner.json") : null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function processAlive(pid) {
|
|
134
|
+
try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function checkProfileLock() {
|
|
138
|
+
const path = ownerLockPath();
|
|
139
|
+
if (!path) return;
|
|
140
|
+
try {
|
|
141
|
+
const owner = JSON.parse(readFileSync(path, "utf8"));
|
|
142
|
+
if (owner.pid && owner.pid !== process.pid && processAlive(owner.pid)) {
|
|
143
|
+
throw new Error(
|
|
144
|
+
`Another bwb process (pid ${owner.pid}) is already using ${cfg.userDataDir}. ` +
|
|
145
|
+
`Sharing a profile makes them kill each other's browser. Use a different ` +
|
|
146
|
+
`--user-data-dir or --port, or stop that process.`
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
} catch (err) {
|
|
150
|
+
if (err instanceof Error && err.message.startsWith("Another bwb process")) throw err;
|
|
151
|
+
// No lock, or unreadable/corrupt: not an error.
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function writeProfileLock() {
|
|
156
|
+
const path = ownerLockPath();
|
|
157
|
+
if (!path) return;
|
|
158
|
+
try {
|
|
159
|
+
mkdirSync(cfg.userDataDir, { recursive: true, mode: 0o700 });
|
|
160
|
+
writeFileSync(path, JSON.stringify({ pid: process.pid, port: cfg.port, startedAt: Date.now() }), { mode: 0o600 });
|
|
161
|
+
} catch {}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function clearProfileLock() {
|
|
165
|
+
const path = ownerLockPath();
|
|
166
|
+
if (!path) return;
|
|
167
|
+
try {
|
|
168
|
+
const owner = JSON.parse(readFileSync(path, "utf8"));
|
|
169
|
+
if (owner.pid === process.pid) unlinkSync(path);
|
|
98
170
|
} catch {}
|
|
99
171
|
}
|
|
100
172
|
|
|
@@ -107,6 +179,7 @@ export function findBrowserPath(cliPath) {
|
|
|
107
179
|
|
|
108
180
|
const os = platform();
|
|
109
181
|
const home = homedir();
|
|
182
|
+
const lookup = platform() === "win32" ? "where" : "which";
|
|
110
183
|
|
|
111
184
|
const candidates = {
|
|
112
185
|
android: [
|
|
@@ -119,8 +192,11 @@ export function findBrowserPath(cliPath) {
|
|
|
119
192
|
"chromium-browser",
|
|
120
193
|
"chromium",
|
|
121
194
|
"google-chrome-stable",
|
|
195
|
+
"brave-browser",
|
|
196
|
+
"brave",
|
|
122
197
|
"/usr/bin/google-chrome",
|
|
123
198
|
"/usr/bin/chromium-browser",
|
|
199
|
+
"/usr/bin/brave-browser",
|
|
124
200
|
"/snap/bin/chromium",
|
|
125
201
|
],
|
|
126
202
|
darwin: [
|
|
@@ -141,13 +217,31 @@ export function findBrowserPath(cliPath) {
|
|
|
141
217
|
continue;
|
|
142
218
|
}
|
|
143
219
|
try {
|
|
144
|
-
|
|
145
|
-
|
|
220
|
+
// No shell: the candidate list is data, never a command string.
|
|
221
|
+
const out = execFileSync(lookup, [bin], { encoding: "utf8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] });
|
|
222
|
+
const path = String(out).split(/\r?\n/).map((l) => l.trim()).filter(Boolean)[0];
|
|
223
|
+
if (path) return path;
|
|
146
224
|
} catch { /* try next */ }
|
|
147
225
|
}
|
|
148
226
|
return null;
|
|
149
227
|
}
|
|
150
228
|
|
|
229
|
+
/**
|
|
230
|
+
* Does Chromium's setuid/namespace sandbox work here?
|
|
231
|
+
*
|
|
232
|
+
* It does NOT on Termux (no user namespaces) and not when running as root or
|
|
233
|
+
* inside most Docker images — and there bwb genuinely cannot start without
|
|
234
|
+
* --no-sandbox. Everywhere else the sandbox is the main containment for a
|
|
235
|
+
* browser that an LLM is pointing at pages chosen by untrusted content, so it
|
|
236
|
+
* stays on. BWB_NO_SANDBOX=1 forces it off.
|
|
237
|
+
*/
|
|
238
|
+
function needsNoSandbox() {
|
|
239
|
+
if (cfg.noSandbox) return true;
|
|
240
|
+
if (isTermux()) return true;
|
|
241
|
+
if (process.platform === "win32") return false;
|
|
242
|
+
return typeof process.getuid === "function" && process.getuid() === 0;
|
|
243
|
+
}
|
|
244
|
+
|
|
151
245
|
function assertBrowserExists(cliPath) {
|
|
152
246
|
const path = findBrowserPath(cliPath);
|
|
153
247
|
if (!path) {
|
|
@@ -179,19 +273,49 @@ export async function ensureBrowser() {
|
|
|
179
273
|
browserStartup = new Promise((res, rej) => { startResolve = res; startReject = rej; });
|
|
180
274
|
browserStartup.catch(() => { browserStartup = null; });
|
|
181
275
|
|
|
276
|
+
// ─── Attach mode: guest on someone else's browser ──────────────────────
|
|
277
|
+
// No spawn, no orphan-kill, no journal restore (never navigate a user's
|
|
278
|
+
// tabs on connect). ensureDefaultTab() registers whatever is already open.
|
|
279
|
+
if (cfg.attachPort) {
|
|
280
|
+
(async () => {
|
|
281
|
+
try {
|
|
282
|
+
const port = cfg.attachPort;
|
|
283
|
+
const cdp = await CDP({ port });
|
|
284
|
+
protocol = cdp;
|
|
285
|
+
browser = null;
|
|
286
|
+
attached = true;
|
|
287
|
+
actualCdpPort = port;
|
|
288
|
+
startResolve(cdp);
|
|
289
|
+
pokeActivity();
|
|
290
|
+
} catch (err) {
|
|
291
|
+
browserStartup = null;
|
|
292
|
+
browserExited = true;
|
|
293
|
+
attached = false;
|
|
294
|
+
startReject(new Error(
|
|
295
|
+
`Attach failed: no browser listening on CDP port ${cfg.attachPort} (${err.message}). ` +
|
|
296
|
+
`Launch one with --remote-debugging-port=${cfg.attachPort} first.`
|
|
297
|
+
));
|
|
298
|
+
}
|
|
299
|
+
})();
|
|
300
|
+
return browserStartup;
|
|
301
|
+
}
|
|
302
|
+
attached = false;
|
|
303
|
+
|
|
182
304
|
(async () => {
|
|
183
305
|
try {
|
|
184
306
|
const browserPath = assertBrowserExists(cfg.browserPath);
|
|
307
|
+
checkProfileLock();
|
|
308
|
+
writeProfileLock();
|
|
185
309
|
killOrphanedChrome();
|
|
186
310
|
await new Promise(r => setTimeout(r, 500));
|
|
187
311
|
|
|
188
312
|
const debugPort = cfg.port || 0;
|
|
313
|
+
const noSandbox = needsNoSandbox();
|
|
189
314
|
const args = [
|
|
190
315
|
"--headless",
|
|
191
|
-
"--no-sandbox",
|
|
316
|
+
...(noSandbox ? ["--no-sandbox", "--disable-setuid-sandbox"] : []),
|
|
192
317
|
"--disable-gpu",
|
|
193
318
|
"--disable-dev-shm-usage",
|
|
194
|
-
"--disable-setuid-sandbox",
|
|
195
319
|
"--disable-software-rasterizer",
|
|
196
320
|
"--remote-debugging-port=" + debugPort,
|
|
197
321
|
"--user-data-dir=" + cfg.userDataDir,
|
|
@@ -202,6 +326,9 @@ export async function ensureBrowser() {
|
|
|
202
326
|
|
|
203
327
|
browser = spawn(browserPath, args, {
|
|
204
328
|
stdio: ["ignore", "pipe", "pipe"],
|
|
329
|
+
// Own process group: a Ctrl-C or an ungraceful exit must take the
|
|
330
|
+
// renderer tree with it, not leave orphans behind.
|
|
331
|
+
detached: true,
|
|
205
332
|
env: { ...process.env, DISPLAY: process.env.DISPLAY || ":0" },
|
|
206
333
|
});
|
|
207
334
|
|
|
@@ -297,7 +424,8 @@ export async function stopBrowser(reason = "manual") {
|
|
|
297
424
|
try { await protocol.close(); } catch {}
|
|
298
425
|
protocol = null;
|
|
299
426
|
}
|
|
300
|
-
|
|
427
|
+
// Attached browsers are foreign — disconnect only, never kill.
|
|
428
|
+
if (browser && !attached) {
|
|
301
429
|
try { browser.kill("SIGTERM"); } catch {}
|
|
302
430
|
await new Promise(r => setTimeout(r, 1500));
|
|
303
431
|
try { browser.kill("SIGKILL"); } catch {}
|
|
@@ -306,6 +434,7 @@ export async function stopBrowser(reason = "manual") {
|
|
|
306
434
|
} catch {}
|
|
307
435
|
browserExited = true; // next ensureBrowser() respawns fresh + restores journal
|
|
308
436
|
actualCdpPort = null;
|
|
437
|
+
attached = false;
|
|
309
438
|
browserStartup = null;
|
|
310
439
|
cleaningUp = false;
|
|
311
440
|
try {
|
|
@@ -318,7 +447,7 @@ export async function stopBrowser(reason = "manual") {
|
|
|
318
447
|
// ─── Restart ──────────────────────────────────────────────────────────────────
|
|
319
448
|
|
|
320
449
|
export async function restartBrowser() {
|
|
321
|
-
if (cleaningUp) return;
|
|
450
|
+
if (cleaningUp) return { status: "busy" };
|
|
322
451
|
cleaningUp = true;
|
|
323
452
|
|
|
324
453
|
try {
|
|
@@ -326,7 +455,9 @@ export async function restartBrowser() {
|
|
|
326
455
|
try { await protocol.close(); } catch {}
|
|
327
456
|
protocol = null;
|
|
328
457
|
}
|
|
329
|
-
|
|
458
|
+
// Attached browsers are foreign — disconnect only, never kill.
|
|
459
|
+
// Next ensureBrowser() re-attaches (no journal restore in attach mode).
|
|
460
|
+
if (browser && !attached) {
|
|
330
461
|
browser.kill("SIGTERM");
|
|
331
462
|
await new Promise(r => setTimeout(r, 2000));
|
|
332
463
|
try { browser.kill("SIGKILL"); } catch {}
|
|
@@ -357,31 +488,57 @@ async function restoreJournalTabs() {
|
|
|
357
488
|
|
|
358
489
|
// ─── Screenshot Helper ────────────────────────────────────────────────────────
|
|
359
490
|
|
|
491
|
+
/**
|
|
492
|
+
* Write a screenshot to disk. The filename carried second-level precision, so
|
|
493
|
+
* two shots in the same second overwrote each other; and nothing ever cleaned
|
|
494
|
+
* up (on Termux this is the public Download folder — a long agent loop fills
|
|
495
|
+
* storage). Now: millisecond + random suffix, and the newest BWB_SHOT_KEEP
|
|
496
|
+
* files are kept.
|
|
497
|
+
*/
|
|
360
498
|
export function saveScreenshot(base64Data) {
|
|
361
|
-
const
|
|
362
|
-
const
|
|
363
|
-
const filename = `bwb-${
|
|
499
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 23);
|
|
500
|
+
const rand = Math.random().toString(36).slice(2, 6);
|
|
501
|
+
const filename = `bwb-${stamp}-${rand}.jpeg`;
|
|
364
502
|
const filepath = join(cfg.screenshotsDir, filename);
|
|
365
503
|
try {
|
|
366
504
|
mkdirSync(cfg.screenshotsDir, { recursive: true });
|
|
367
505
|
writeFileSync(filepath, Buffer.from(base64Data, "base64"));
|
|
506
|
+
pruneScreenshots(cfg.screenshotsDir, cfg.shotKeep);
|
|
368
507
|
return filepath;
|
|
369
508
|
} catch {
|
|
370
509
|
return null;
|
|
371
510
|
}
|
|
372
511
|
}
|
|
373
512
|
|
|
513
|
+
function pruneScreenshots(dir, keep = 50) {
|
|
514
|
+
if (!keep || keep < 1) return;
|
|
515
|
+
try {
|
|
516
|
+
const files = readdirSync(dir)
|
|
517
|
+
.filter((f) => f.startsWith("bwb-") && f.endsWith(".jpeg"))
|
|
518
|
+
.map((f) => ({ f, t: statSync(join(dir, f)).mtimeMs }))
|
|
519
|
+
.sort((a, b) => b.t - a.t);
|
|
520
|
+
for (const { f } of files.slice(keep)) {
|
|
521
|
+
try { unlinkSync(join(dir, f)); } catch {}
|
|
522
|
+
}
|
|
523
|
+
} catch {}
|
|
524
|
+
}
|
|
525
|
+
|
|
374
526
|
// ─── Cleanup ──────────────────────────────────────────────────────────────────
|
|
375
527
|
|
|
376
528
|
function cleanupSync() {
|
|
377
529
|
if (cleaningUp) return;
|
|
378
530
|
cleaningUp = true;
|
|
531
|
+
clearProfileLock();
|
|
379
532
|
try {
|
|
380
533
|
if (browser) {
|
|
381
|
-
browser
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
534
|
+
const child = browser;
|
|
535
|
+
child.kill("SIGTERM");
|
|
536
|
+
// The old code armed a 3s SIGKILL timer and then the signal handler
|
|
537
|
+
// called process.exit(0) — so the timer never fired and a Chromium that
|
|
538
|
+
// ignored SIGTERM was orphaned. Kill the whole process group instead,
|
|
539
|
+
// synchronously, using the detached spawn above.
|
|
540
|
+
const pid = child.pid;
|
|
541
|
+
try { process.kill(-pid, "SIGKILL"); } catch { try { process.kill(pid, "SIGKILL"); } catch {} }
|
|
385
542
|
browser = null;
|
|
386
543
|
}
|
|
387
544
|
} catch {}
|
package/lib/config.mjs
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bwb-browser — Config resolution (pure, testable)
|
|
3
|
+
*
|
|
4
|
+
* Precedence: CLI flag > env var > default. Kept out of server.mjs so it can be
|
|
5
|
+
* unit-tested without booting an MCP server (see test/config.test.mjs).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync } from "fs";
|
|
9
|
+
import { homedir, platform } from "os";
|
|
10
|
+
import { join } from "path";
|
|
11
|
+
|
|
12
|
+
/** Flags that take a value. Value = next token, if it isn't another flag. */
|
|
13
|
+
const VALUE_FLAGS = {
|
|
14
|
+
"--browser-path": "browserPath",
|
|
15
|
+
"--port": "port",
|
|
16
|
+
"--user-data-dir": "userDataDir",
|
|
17
|
+
"--screenshots-dir": "screenshotsDir",
|
|
18
|
+
"--timeout": "navTimeout",
|
|
19
|
+
"--idle": "idleMs",
|
|
20
|
+
"--tab-max": "tabMax",
|
|
21
|
+
"--attach-port": "attachPort",
|
|
22
|
+
"--allow-domains": "allowDomains",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Flags that are booleans. `--flag`, `--flag true`, `--flag false`. */
|
|
26
|
+
const BOOL_FLAGS = {
|
|
27
|
+
"--headless": "headless",
|
|
28
|
+
"--lean": "lean",
|
|
29
|
+
"--nuclear": "nuclear",
|
|
30
|
+
"--readonly": "readonly",
|
|
31
|
+
"--no-sandbox": "noSandbox",
|
|
32
|
+
"--always-browser": "alwaysBrowser",
|
|
33
|
+
"--journal-full": "journalFull",
|
|
34
|
+
"--confirm-destructive": "confirmDestructive",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Flags that take an integer value. */
|
|
38
|
+
const INT_FLAGS = new Set([
|
|
39
|
+
"--port", "--timeout", "--idle", "--tab-max", "--attach-port",
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
export class ConfigError extends Error {}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Parse argv (without node/script) into a partial config.
|
|
46
|
+
* Bare booleans never swallow the next flag — `--nuclear --lean true` yields
|
|
47
|
+
* both. Unknown flags are an error instead of a silent typo.
|
|
48
|
+
*/
|
|
49
|
+
export function parseArgs(argv = []) {
|
|
50
|
+
const out = {};
|
|
51
|
+
for (let i = 0; i < argv.length; i++) {
|
|
52
|
+
const arg = argv[i];
|
|
53
|
+
|
|
54
|
+
if (BOOL_FLAGS[arg]) {
|
|
55
|
+
const next = argv[i + 1];
|
|
56
|
+
const key = BOOL_FLAGS[arg];
|
|
57
|
+
if (next === undefined || next.startsWith("--")) {
|
|
58
|
+
out[key] = true;
|
|
59
|
+
} else if (next === "true" || next === "false") {
|
|
60
|
+
out[key] = next === "true";
|
|
61
|
+
i++;
|
|
62
|
+
} else {
|
|
63
|
+
throw new ConfigError(`${arg} expects true or false, got "${next}"`);
|
|
64
|
+
}
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (VALUE_FLAGS[arg]) {
|
|
69
|
+
const value = argv[i + 1];
|
|
70
|
+
if (value === undefined || value.startsWith("--")) {
|
|
71
|
+
throw new ConfigError(`${arg} requires a value`);
|
|
72
|
+
}
|
|
73
|
+
const key = VALUE_FLAGS[arg];
|
|
74
|
+
if (INT_FLAGS.has(arg)) {
|
|
75
|
+
const n = Number(value);
|
|
76
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
77
|
+
throw new ConfigError(`${arg} expects a non-negative integer, got "${value}"`);
|
|
78
|
+
}
|
|
79
|
+
out[key] = n;
|
|
80
|
+
} else {
|
|
81
|
+
out[key] = value;
|
|
82
|
+
}
|
|
83
|
+
i++;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Flags handled by server.mjs itself (lifecycle, not config).
|
|
88
|
+
if (arg === "--version" || arg === "--help" || arg === "--setup"
|
|
89
|
+
|| arg === "--dry-run" || arg === "--yes") {
|
|
90
|
+
out[`_passthrough_${arg.slice(2)}`] = true;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
throw new ConfigError(`Unknown option: ${arg}`);
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function defaultScreenshotsDir(env, isAndroid) {
|
|
100
|
+
const androidPath = "/storage/emulated/0/Download/bwb-screenshots";
|
|
101
|
+
if (isAndroid && existsSync("/storage/emulated/0/Download")) return androidPath;
|
|
102
|
+
if (env.TERMUX_VERSION) return androidPath;
|
|
103
|
+
return join(homedir(), "bwb-screenshots");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Resolve the full runtime config.
|
|
108
|
+
* @param {object} cli output of parseArgs
|
|
109
|
+
* @param {object} env process.env
|
|
110
|
+
* @param {object} opts {isTermux}
|
|
111
|
+
*/
|
|
112
|
+
export function resolveConfig(cli = {}, env = process.env, opts = {}) {
|
|
113
|
+
const isTermux = opts.isTermux ?? false;
|
|
114
|
+
const termux = isTermux || Boolean(
|
|
115
|
+
env.TERMUX_VERSION ||
|
|
116
|
+
env.PREFIX?.includes("com.termux") ||
|
|
117
|
+
env.HOME?.includes("com.termux")
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const envInt = (name, fallback) => {
|
|
121
|
+
if (env[name] === undefined || env[name] === "") return fallback;
|
|
122
|
+
const n = Number(env[name]);
|
|
123
|
+
return Number.isInteger(n) && n >= 0 ? n : fallback;
|
|
124
|
+
};
|
|
125
|
+
const envBool = (name) => (env[name] === undefined ? undefined : env[name] !== "false");
|
|
126
|
+
|
|
127
|
+
const cfg = {};
|
|
128
|
+
|
|
129
|
+
cfg.port = cli.port ?? envInt("BWB_CDP_PORT", 0);
|
|
130
|
+
cfg.attachPort = cli.attachPort ?? envInt("BWB_ATTACH_PORT", 0);
|
|
131
|
+
cfg.headless = cli.headless ?? envBool("BWB_HEADLESS") ?? true;
|
|
132
|
+
cfg.browserPath = cli.browserPath ?? env.BWB_CHROME_PATH ?? null;
|
|
133
|
+
|
|
134
|
+
// Chromium's sandbox is kept where it works. --no-sandbox is needed on Termux,
|
|
135
|
+
// under root/Docker, or with an explicit BWB_NO_SANDBOX=1 — not everywhere.
|
|
136
|
+
cfg.noSandbox = cli.noSandbox ?? envBool("BWB_NO_SANDBOX") ?? false;
|
|
137
|
+
|
|
138
|
+
// Agent-safety policy (F27). readonly disables every state-changing tool;
|
|
139
|
+
// allowDomains is an optional host allowlist for goto/newTab/act navigation.
|
|
140
|
+
cfg.readonly = cli.readonly ?? envBool("BWB_READONLY") ?? false;
|
|
141
|
+
cfg.allowDomains = normalizeDomains(cli.allowDomains ?? env.BWB_ALLOW_DOMAINS ?? "");
|
|
142
|
+
cfg.confirmDestructive = cli.confirmDestructive ?? envBool("BWB_CONFIRM_DESTRUCTIVE") ?? true;
|
|
143
|
+
|
|
144
|
+
cfg.userDataDir = cli.userDataDir
|
|
145
|
+
?? env.BWB_USER_DATA_DIR
|
|
146
|
+
?? join(homedir(), ".cache", "bwb-browser");
|
|
147
|
+
|
|
148
|
+
cfg.screenshotsDir = cli.screenshotsDir
|
|
149
|
+
?? env.BWB_SCREENSHOTS_DIR
|
|
150
|
+
?? defaultScreenshotsDir(env, platform() === "android" || termux);
|
|
151
|
+
|
|
152
|
+
cfg.navTimeout = cli.navTimeout ?? envInt("BWB_NAV_TIMEOUT", 30000);
|
|
153
|
+
cfg.alwaysBrowser = cli.alwaysBrowser ?? envBool("BWB_ALWAYS_BROWSER") ?? false;
|
|
154
|
+
|
|
155
|
+
// v4 survival defaults: lean auto-detects Termux; mayfly + tab cap follow lean.
|
|
156
|
+
cfg.lean = cli.lean ?? envBool("BWB_LEAN") ?? termux;
|
|
157
|
+
cfg.nuclear = cli.nuclear ?? (env.BWB_NUCLEAR === "true");
|
|
158
|
+
|
|
159
|
+
cfg.idleMs = cli.idleMs
|
|
160
|
+
?? (env.BWB_IDLE_MS !== undefined ? envInt("BWB_IDLE_MS", 0) : (cfg.lean ? 5 * 60 * 1000 : 0));
|
|
161
|
+
cfg.tabMax = cli.tabMax
|
|
162
|
+
?? (env.BWB_TAB_MAX !== undefined ? envInt("BWB_TAB_MAX", 0) : (cfg.lean ? 3 : 0));
|
|
163
|
+
|
|
164
|
+
// Journal restore re-fires recorded URLs. Only automatic on a lean profile
|
|
165
|
+
// (the post-LMK resurrection flow); elsewhere it is opt-in (F09).
|
|
166
|
+
cfg.journalFull = cli.journalFull ?? (env.BWB_JOURNAL === "full");
|
|
167
|
+
|
|
168
|
+
cfg.allowPrivate = envBool("BWB_ALLOW_PRIVATE") ?? false;
|
|
169
|
+
cfg.shotKeep = envInt("BWB_SHOT_KEEP", 50);
|
|
170
|
+
|
|
171
|
+
return cfg;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function normalizeDomains(raw) {
|
|
175
|
+
if (!raw) return [];
|
|
176
|
+
const list = Array.isArray(raw) ? raw : String(raw).split(",");
|
|
177
|
+
return list
|
|
178
|
+
.map((d) => String(d).trim().toLowerCase().replace(/^\*\./, ""))
|
|
179
|
+
.filter(Boolean);
|
|
180
|
+
}
|
package/lib/diagnose.mjs
CHANGED
|
@@ -9,23 +9,23 @@
|
|
|
9
9
|
/**
|
|
10
10
|
* Run a full diagnostic on the current page.
|
|
11
11
|
* Returns performance metrics, console errors, broken images, meta tags.
|
|
12
|
+
*
|
|
13
|
+
* @param {object} protocol
|
|
14
|
+
* @param {object} [opts] {keepRuntimeEnabled} — true while browser_watch is
|
|
15
|
+
* recording. Runtime.disable() does NOT throw when Runtime was already
|
|
16
|
+
* enabled (it is a no-op), so the old "did it throw?" probe never detected
|
|
17
|
+
* that case and diagnose silently killed an active watch's console feed.
|
|
12
18
|
*/
|
|
13
|
-
export async function diagnosePage(protocol) {
|
|
14
|
-
const { Runtime
|
|
19
|
+
export async function diagnosePage(protocol, opts = {}) {
|
|
20
|
+
const { Runtime } = protocol;
|
|
21
|
+
const keepRuntimeEnabled = opts.keepRuntimeEnabled === true;
|
|
15
22
|
|
|
16
23
|
// Collect console errors during diagnostic
|
|
17
24
|
const consoleErrors = [];
|
|
18
25
|
let unsubConsole, unsubException;
|
|
19
|
-
let runtimeWasAlreadyEnabled = false;
|
|
20
26
|
|
|
21
27
|
try {
|
|
22
|
-
|
|
23
|
-
try {
|
|
24
|
-
await Runtime.enable();
|
|
25
|
-
} catch {
|
|
26
|
-
// Already enabled — that's fine, but don't disable it later
|
|
27
|
-
runtimeWasAlreadyEnabled = true;
|
|
28
|
-
}
|
|
28
|
+
await Runtime.enable();
|
|
29
29
|
unsubConsole = Runtime.consoleAPICalled((params) => {
|
|
30
30
|
if (params.type === "error" || params.type === "warning") {
|
|
31
31
|
consoleErrors.push({
|
|
@@ -121,7 +121,7 @@ export async function diagnosePage(protocol) {
|
|
|
121
121
|
// Cleanup
|
|
122
122
|
try { if (unsubConsole) unsubConsole(); } catch {}
|
|
123
123
|
try { if (unsubException) unsubException(); } catch {}
|
|
124
|
-
if (!
|
|
124
|
+
if (!keepRuntimeEnabled) {
|
|
125
125
|
try { await Runtime.disable(); } catch {}
|
|
126
126
|
}
|
|
127
127
|
|
|
@@ -140,6 +140,8 @@ export async function diagnosePage(protocol) {
|
|
|
140
140
|
hasBrokenImages: brokenImages.length > 0,
|
|
141
141
|
hasConsoleErrors: consoleErrors.length > 0,
|
|
142
142
|
},
|
|
143
|
+
// Heuristic, not a Lighthouse audit: a weighted penalty sum, not a
|
|
144
|
+
// measured performance grade.
|
|
143
145
|
score: calculateHealthScore(performance, consoleErrors, brokenImages),
|
|
144
146
|
};
|
|
145
147
|
}
|