bwb-browser 4.0.1 → 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 +31 -17
- package/README.md +76 -32
- package/lib/act.mjs +474 -215
- package/lib/browser.mjs +145 -30
- 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 +418 -211
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
|
|
|
@@ -37,6 +37,14 @@ export const cfg = {
|
|
|
37
37
|
tabMax: null, // null = auto (3 lean, unlimited desktop); 0 = unlimited
|
|
38
38
|
attachPort: 0, // 0 = off (spawn own browser). N = attach to existing
|
|
39
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
|
|
40
48
|
};
|
|
41
49
|
|
|
42
50
|
// True when connected to a foreign (user-owned) browser via --attach-port.
|
|
@@ -84,24 +92,81 @@ export function pokeActivity() {
|
|
|
84
92
|
|
|
85
93
|
// `fuser -k` and `lsof` can't read /proc/net/tcp on Termux/Android (permission denied).
|
|
86
94
|
// Instead, kill by PID from `ps` — works on every platform.
|
|
87
|
-
// Only kills bwb's headless Chrome instances (marked by
|
|
88
|
-
// 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.
|
|
89
101
|
export function killOrphanedChrome() {
|
|
102
|
+
if (platform() === "win32") return; // no ps/grep/awk/xargs; use the CDP port instead
|
|
103
|
+
let out;
|
|
90
104
|
try {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
);
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
);
|
|
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);
|
|
105
170
|
} catch {}
|
|
106
171
|
}
|
|
107
172
|
|
|
@@ -114,6 +179,7 @@ export function findBrowserPath(cliPath) {
|
|
|
114
179
|
|
|
115
180
|
const os = platform();
|
|
116
181
|
const home = homedir();
|
|
182
|
+
const lookup = platform() === "win32" ? "where" : "which";
|
|
117
183
|
|
|
118
184
|
const candidates = {
|
|
119
185
|
android: [
|
|
@@ -151,13 +217,31 @@ export function findBrowserPath(cliPath) {
|
|
|
151
217
|
continue;
|
|
152
218
|
}
|
|
153
219
|
try {
|
|
154
|
-
|
|
155
|
-
|
|
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;
|
|
156
224
|
} catch { /* try next */ }
|
|
157
225
|
}
|
|
158
226
|
return null;
|
|
159
227
|
}
|
|
160
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
|
+
|
|
161
245
|
function assertBrowserExists(cliPath) {
|
|
162
246
|
const path = findBrowserPath(cliPath);
|
|
163
247
|
if (!path) {
|
|
@@ -220,16 +304,18 @@ export async function ensureBrowser() {
|
|
|
220
304
|
(async () => {
|
|
221
305
|
try {
|
|
222
306
|
const browserPath = assertBrowserExists(cfg.browserPath);
|
|
307
|
+
checkProfileLock();
|
|
308
|
+
writeProfileLock();
|
|
223
309
|
killOrphanedChrome();
|
|
224
310
|
await new Promise(r => setTimeout(r, 500));
|
|
225
311
|
|
|
226
312
|
const debugPort = cfg.port || 0;
|
|
313
|
+
const noSandbox = needsNoSandbox();
|
|
227
314
|
const args = [
|
|
228
315
|
"--headless",
|
|
229
|
-
"--no-sandbox",
|
|
316
|
+
...(noSandbox ? ["--no-sandbox", "--disable-setuid-sandbox"] : []),
|
|
230
317
|
"--disable-gpu",
|
|
231
318
|
"--disable-dev-shm-usage",
|
|
232
|
-
"--disable-setuid-sandbox",
|
|
233
319
|
"--disable-software-rasterizer",
|
|
234
320
|
"--remote-debugging-port=" + debugPort,
|
|
235
321
|
"--user-data-dir=" + cfg.userDataDir,
|
|
@@ -240,6 +326,9 @@ export async function ensureBrowser() {
|
|
|
240
326
|
|
|
241
327
|
browser = spawn(browserPath, args, {
|
|
242
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,
|
|
243
332
|
env: { ...process.env, DISPLAY: process.env.DISPLAY || ":0" },
|
|
244
333
|
});
|
|
245
334
|
|
|
@@ -358,7 +447,7 @@ export async function stopBrowser(reason = "manual") {
|
|
|
358
447
|
// ─── Restart ──────────────────────────────────────────────────────────────────
|
|
359
448
|
|
|
360
449
|
export async function restartBrowser() {
|
|
361
|
-
if (cleaningUp) return;
|
|
450
|
+
if (cleaningUp) return { status: "busy" };
|
|
362
451
|
cleaningUp = true;
|
|
363
452
|
|
|
364
453
|
try {
|
|
@@ -399,31 +488,57 @@ async function restoreJournalTabs() {
|
|
|
399
488
|
|
|
400
489
|
// ─── Screenshot Helper ────────────────────────────────────────────────────────
|
|
401
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
|
+
*/
|
|
402
498
|
export function saveScreenshot(base64Data) {
|
|
403
|
-
const
|
|
404
|
-
const
|
|
405
|
-
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`;
|
|
406
502
|
const filepath = join(cfg.screenshotsDir, filename);
|
|
407
503
|
try {
|
|
408
504
|
mkdirSync(cfg.screenshotsDir, { recursive: true });
|
|
409
505
|
writeFileSync(filepath, Buffer.from(base64Data, "base64"));
|
|
506
|
+
pruneScreenshots(cfg.screenshotsDir, cfg.shotKeep);
|
|
410
507
|
return filepath;
|
|
411
508
|
} catch {
|
|
412
509
|
return null;
|
|
413
510
|
}
|
|
414
511
|
}
|
|
415
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
|
+
|
|
416
526
|
// ─── Cleanup ──────────────────────────────────────────────────────────────────
|
|
417
527
|
|
|
418
528
|
function cleanupSync() {
|
|
419
529
|
if (cleaningUp) return;
|
|
420
530
|
cleaningUp = true;
|
|
531
|
+
clearProfileLock();
|
|
421
532
|
try {
|
|
422
533
|
if (browser) {
|
|
423
|
-
browser
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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 {} }
|
|
427
542
|
browser = null;
|
|
428
543
|
}
|
|
429
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
|
}
|