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/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 `remote-debugging-port` flag),
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
- // Only touch Chromes using OUR user-data-dir — never another agent's browser.
85
- const scopedMatch = cfg.userDataDir
86
- ? `grep "remote-debugging-port" | grep "${String(cfg.userDataDir).replace(/["\\]/g, "\\$&")}" | grep -v grep`
87
- : `grep "remote-debugging-port" | grep -v grep`;
88
- // SIGTERM first for clean shutdown
89
- execSync(
90
- `ps aux | ${scopedMatch} | awk '{print $2}' | xargs -r kill -15 2>/dev/null; true`,
91
- { encoding: "utf8", timeout: 5000 }
92
- );
93
- // Give them a moment to exit cleanly, then SIGKILL survivors
94
- execSync(
95
- `sleep 1 && ps aux | ${scopedMatch} | awk '{print $2}' | xargs -r kill -9 2>/dev/null; true`,
96
- { encoding: "utf8", timeout: 5000 }
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
- const path = execSync(`which "${bin}" 2>/dev/null || echo "no"`, { encoding: "utf8", timeout: 3000 }).trim();
145
- if (path && path !== "no") return path;
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
- if (browser) {
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
- if (browser) {
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 now = new Date();
362
- const timestamp = now.toISOString().replace(/[:.]/g, "-").slice(0, 19);
363
- const filename = `bwb-${timestamp}.jpeg`;
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.kill("SIGTERM");
382
- setTimeout(() => {
383
- try { browser?.kill("SIGKILL"); } catch {}
384
- }, 3000);
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, Network, Page } = protocol;
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
- // Check if Runtime is already enabled; if not, enable it
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 (!runtimeWasAlreadyEnabled) {
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
  }