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/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 `remote-debugging-port` flag),
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
- // Only touch Chromes using OUR user-data-dir — never another agent's browser.
92
- const scopedMatch = cfg.userDataDir
93
- ? `grep "remote-debugging-port" | grep "${String(cfg.userDataDir).replace(/["\\]/g, "\\$&")}" | grep -v grep`
94
- : `grep "remote-debugging-port" | grep -v grep`;
95
- // SIGTERM first for clean shutdown
96
- execSync(
97
- `ps aux | ${scopedMatch} | awk '{print $2}' | xargs -r kill -15 2>/dev/null; true`,
98
- { encoding: "utf8", timeout: 5000 }
99
- );
100
- // Give them a moment to exit cleanly, then SIGKILL survivors
101
- execSync(
102
- `sleep 1 && ps aux | ${scopedMatch} | awk '{print $2}' | xargs -r kill -9 2>/dev/null; true`,
103
- { encoding: "utf8", timeout: 5000 }
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
- const path = execSync(`which "${bin}" 2>/dev/null || echo "no"`, { encoding: "utf8", timeout: 3000 }).trim();
155
- 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;
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 now = new Date();
404
- const timestamp = now.toISOString().replace(/[:.]/g, "-").slice(0, 19);
405
- 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`;
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.kill("SIGTERM");
424
- setTimeout(() => {
425
- try { browser?.kill("SIGKILL"); } catch {}
426
- }, 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 {} }
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, 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
  }