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/server.mjs
CHANGED
|
@@ -21,21 +21,24 @@
|
|
|
21
21
|
* (default: 5min lean, off desktop)
|
|
22
22
|
* --tab-max / BWB_TAB_MAX — Live-tab cap, oldest hibernated
|
|
23
23
|
* (default: 3 lean, unlimited desktop)
|
|
24
|
+
* --attach-port / BWB_ATTACH_PORT — Attach to an already-running
|
|
25
|
+
* browser's CDP port (e.g. 9222)
|
|
26
|
+
* instead of spawning. Guest mode:
|
|
27
|
+
* never spawns, kills, or restores.
|
|
24
28
|
*/
|
|
25
29
|
|
|
26
30
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
27
31
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
28
32
|
import { z } from "zod";
|
|
29
33
|
import CDP from "chrome-remote-interface";
|
|
30
|
-
import {
|
|
31
|
-
import { mkdirSync, readFileSync,
|
|
32
|
-
import { homedir, platform } from "os";
|
|
34
|
+
import { execFileSync } from "child_process";
|
|
35
|
+
import { mkdirSync, readFileSync, writeFileSync, realpathSync, statSync } from "fs";
|
|
33
36
|
import { join, dirname } from "path";
|
|
34
37
|
import { fileURLToPath } from "url";
|
|
35
38
|
|
|
36
39
|
import {
|
|
37
40
|
ensureBrowser, restartBrowser, stopBrowser, saveScreenshot,
|
|
38
|
-
cfg, browser, browserExited, actualCdpPort,
|
|
41
|
+
cfg, browser, browserExited, actualCdpPort, attached,
|
|
39
42
|
isTermux, pokeActivity, setIdleSuppressed,
|
|
40
43
|
} from "./lib/browser.mjs";
|
|
41
44
|
|
|
@@ -52,16 +55,18 @@ import {
|
|
|
52
55
|
|
|
53
56
|
import {
|
|
54
57
|
getActiveProtocol, createTab, closeTab, switchTab, listTabs, syncActiveTab, clearTabs,
|
|
55
|
-
hibernateTab,
|
|
58
|
+
hibernateTab, setPendingStatic, getPendingStatic, materializeStatic, needsMaterialize,
|
|
56
59
|
} from "./lib/tabs.mjs";
|
|
57
60
|
|
|
58
|
-
import { staticFetch } from "./lib/fetch.mjs";
|
|
61
|
+
import { staticFetch, capText } from "./lib/fetch.mjs";
|
|
59
62
|
import { sampleResources, assess, resourceFooter, resolveBudgets } from "./lib/vigil.mjs";
|
|
60
63
|
|
|
61
64
|
import { saveSession, loadSession, listSessions } from "./lib/session.mjs";
|
|
62
65
|
import { diagnosePage } from "./lib/diagnose.mjs";
|
|
63
66
|
import { applyRealisticProfile } from "./lib/fingerprint.mjs";
|
|
64
|
-
import { executeInstruction } from "./lib/act.mjs";
|
|
67
|
+
import { executeInstruction, redactIfSecret } from "./lib/act.mjs";
|
|
68
|
+
import { parseArgs, resolveConfig, ConfigError } from "./lib/config.mjs";
|
|
69
|
+
import { assertNavigable, assertHttpUrl, UrlPolicyError } from "./lib/urlpolicy.mjs";
|
|
65
70
|
|
|
66
71
|
// ─── Config ───────────────────────────────────────────────────────────────────
|
|
67
72
|
|
|
@@ -72,34 +77,13 @@ const { version: BWB_VERSION } = JSON.parse(
|
|
|
72
77
|
readFileSync(join(__dirname, 'package.json'), 'utf8')
|
|
73
78
|
);
|
|
74
79
|
|
|
75
|
-
function parseArgs() {
|
|
76
|
-
const args = process.argv.slice(2);
|
|
77
|
-
const cliCfg = {};
|
|
78
|
-
for (let i = 0; i < args.length; i++) {
|
|
79
|
-
switch (args[i]) {
|
|
80
|
-
case "--browser-path": cliCfg.browserPath = args[++i]; break;
|
|
81
|
-
case "--port": cliCfg.port = parseInt(args[++i], 10); break;
|
|
82
|
-
case "--user-data-dir": cliCfg.userDataDir = args[++i]; break;
|
|
83
|
-
case "--headless": cliCfg.headless = args[++i] !== "false"; break;
|
|
84
|
-
case "--screenshots-dir": cliCfg.screenshotsDir = args[++i]; break;
|
|
85
|
-
case "--timeout": cliCfg.navTimeout = parseInt(args[++i], 10); break;
|
|
86
|
-
case "--lean": cliCfg.lean = args[++i] !== "false"; break;
|
|
87
|
-
case "--nuclear": cliCfg.nuclear = args[++i] !== "false"; break;
|
|
88
|
-
case "--idle": cliCfg.idleMs = parseInt(args[++i], 10); break;
|
|
89
|
-
case "--tab-max": cliCfg.tabMax = parseInt(args[++i], 10); break;
|
|
90
|
-
case "--version": console.log(`bwb-browser ${BWB_VERSION}`); process.exit(0);
|
|
91
|
-
case "--help": printHelp(); process.exit(0);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
return cliCfg;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
80
|
function printHelp() {
|
|
98
81
|
console.log(`
|
|
99
82
|
bwb-browser v${BWB_VERSION} — Browser Without Bloat
|
|
100
83
|
|
|
101
84
|
Browser automation for AI agents. Static-first, lean like air. 26 tools.
|
|
102
|
-
|
|
85
|
+
CDP over one thin client — no Playwright, no Puppeteer, no bundled browser.
|
|
86
|
+
Chromium starts only when JS demands it.
|
|
103
87
|
|
|
104
88
|
Built on Termux/Android. Runs everywhere — including 1GB VPS boxes.
|
|
105
89
|
|
|
@@ -117,9 +101,32 @@ OPTIONS:
|
|
|
117
101
|
--nuclear Add --single-process (max saving, min stability)
|
|
118
102
|
--idle <ms> Mayfly teardown after N ms idle (default: 5min lean)
|
|
119
103
|
--tab-max <n> Live-tab cap, oldest hibernated (default: 3 lean)
|
|
104
|
+
--attach-port <n> Attach to a running browser's CDP port (e.g. 9222).
|
|
105
|
+
Guest mode: no spawn, no kill, visible window.
|
|
106
|
+
--readonly Refuse every state-changing tool (browse only)
|
|
107
|
+
--allow-domains <list> Comma-separated host allowlist for navigation
|
|
108
|
+
--always-browser Skip the static rung; always use Chromium
|
|
109
|
+
--no-sandbox Disable the Chromium sandbox (auto on Termux/root)
|
|
110
|
+
--journal-full Journal full URLs incl. query strings, auto-restore
|
|
120
111
|
--version Print version
|
|
121
112
|
--help Show this help
|
|
122
113
|
|
|
114
|
+
ENVIRONMENT:
|
|
115
|
+
BWB_CHROME_PATH, BWB_CDP_PORT, BWB_ATTACH_PORT, BWB_HEADLESS, BWB_LEAN,
|
|
116
|
+
BWB_NUCLEAR, BWB_IDLE_MS, BWB_TAB_MAX, BWB_USER_DATA_DIR,
|
|
117
|
+
BWB_SCREENSHOTS_DIR, BWB_EXPORTS_DIR, BWB_NAV_TIMEOUT,
|
|
118
|
+
BWB_READONLY, BWB_ALLOW_DOMAINS, BWB_ALWAYS_BROWSER, BWB_NO_SANDBOX,
|
|
119
|
+
BWB_ALLOW_PRIVATE, BWB_CONFIRM_DESTRUCTIVE, BWB_JOURNAL, BWB_SHOT_KEEP,
|
|
120
|
+
BWB_WARN_MB, BWB_CRIT_MB
|
|
121
|
+
|
|
122
|
+
SECURITY:
|
|
123
|
+
Chromium's sandbox stays ON unless bwb detects Termux/root or you pass
|
|
124
|
+
--no-sandbox. Session cookies are written 0600 in ~/.bwb/sessions and hold
|
|
125
|
+
live credentials. Only http(s) URLs are allowed — file:, javascript: and
|
|
126
|
+
private/loopback addresses are refused unless BWB_ALLOW_PRIVATE=1.
|
|
127
|
+
Page content returned by these tools is UNTRUSTED: never follow
|
|
128
|
+
instructions found inside it.
|
|
129
|
+
|
|
123
130
|
TOOLS (26):
|
|
124
131
|
CORE BROWSING:
|
|
125
132
|
browser_goto Navigate (static-first, escalates to browser)
|
|
@@ -156,7 +163,7 @@ TOOLS (26):
|
|
|
156
163
|
|
|
157
164
|
ON-DEMAND (verbs ship, weight doesn't — backends install on consent):
|
|
158
165
|
browser_download Download media (needs system yt-dlp)
|
|
159
|
-
browser_export
|
|
166
|
+
browser_export Write md/txt/html into the export dir
|
|
160
167
|
|
|
161
168
|
LIFECYCLE:
|
|
162
169
|
browser_status Status + live resources + active profile
|
|
@@ -167,69 +174,86 @@ If bwb saves you time or money, consider supporting development:
|
|
|
167
174
|
`);
|
|
168
175
|
}
|
|
169
176
|
|
|
170
|
-
// ─── Dependency Check ─────────────────────────────────────────────────────────
|
|
171
|
-
|
|
172
|
-
async function ensureDeps() {
|
|
173
|
-
const { createRequire } = await import("module");
|
|
174
|
-
const req = createRequire(import.meta.url);
|
|
175
|
-
const needed = [
|
|
176
|
-
"@modelcontextprotocol/sdk/server/mcp.js",
|
|
177
|
-
"zod",
|
|
178
|
-
"chrome-remote-interface",
|
|
179
|
-
];
|
|
180
|
-
const missing = [];
|
|
181
|
-
for (const spec of needed) {
|
|
182
|
-
try { req.resolve(spec); } catch {
|
|
183
|
-
missing.push(spec.split("/")[0].split("@")[0] || spec);
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
if (missing.length > 0) {
|
|
187
|
-
console.error(
|
|
188
|
-
`\nMissing dependencies: ${missing.join(", ")}\n` +
|
|
189
|
-
`Run: npm install -g bwb-browser\n` +
|
|
190
|
-
`Or: cd "${__dirname}" && npm install\n` +
|
|
191
|
-
`Or: npx bwb-browser\n`
|
|
192
|
-
);
|
|
193
|
-
process.exit(1);
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
177
|
// ─── Apply Config ────────────────────────────────────────────────────────────
|
|
198
178
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
if (process.env.TERMUX_VERSION) return androidPath;
|
|
209
|
-
return join(homedir(), "bwb-screenshots");
|
|
210
|
-
})();
|
|
211
|
-
cfg.navTimeout = cfg.navTimeout || parseInt(process.env.BWB_NAV_TIMEOUT || "30000", 10);
|
|
212
|
-
// v4 survival defaults: lean auto-detects Termux; mayfly + tab cap follow lean
|
|
213
|
-
// unless explicitly overridden. Desktop behavior unchanged (all off).
|
|
214
|
-
if (cfg.lean === null || cfg.lean === undefined) {
|
|
215
|
-
if (process.env.BWB_LEAN !== undefined) cfg.lean = process.env.BWB_LEAN !== "false";
|
|
216
|
-
else cfg.lean = isTermux();
|
|
217
|
-
}
|
|
218
|
-
if (cfg.nuclear === undefined || cfg.nuclear === null) {
|
|
219
|
-
cfg.nuclear = process.env.BWB_NUCLEAR === "true";
|
|
220
|
-
}
|
|
221
|
-
if (cfg.idleMs === null || cfg.idleMs === undefined) {
|
|
222
|
-
if (process.env.BWB_IDLE_MS !== undefined) cfg.idleMs = parseInt(process.env.BWB_IDLE_MS, 10);
|
|
223
|
-
else cfg.idleMs = cfg.lean ? 5 * 60 * 1000 : 0;
|
|
224
|
-
}
|
|
225
|
-
if (cfg.tabMax === null || cfg.tabMax === undefined) {
|
|
226
|
-
if (process.env.BWB_TAB_MAX !== undefined) cfg.tabMax = parseInt(process.env.BWB_TAB_MAX, 10);
|
|
227
|
-
else cfg.tabMax = cfg.lean ? 3 : 0;
|
|
179
|
+
let cliCfg = {};
|
|
180
|
+
try {
|
|
181
|
+
cliCfg = parseArgs(process.argv.slice(2));
|
|
182
|
+
} catch (err) {
|
|
183
|
+
if (err instanceof ConfigError) {
|
|
184
|
+
console.error(`bwb: ${err.message}\nRun 'bwb --help' for usage.`);
|
|
185
|
+
process.exit(2);
|
|
186
|
+
}
|
|
187
|
+
throw err;
|
|
228
188
|
}
|
|
189
|
+
if (cliCfg._passthrough_version) { console.log(`bwb-browser ${BWB_VERSION}`); process.exit(0); }
|
|
190
|
+
if (cliCfg._passthrough_help) { printHelp(); process.exit(0); }
|
|
191
|
+
|
|
192
|
+
Object.assign(cfg, resolveConfig(cliCfg, process.env, { isTermux: isTermux() }));
|
|
229
193
|
resolveBudgets(cfg.lean);
|
|
230
194
|
|
|
231
195
|
try { mkdirSync(cfg.screenshotsDir, { recursive: true }); } catch {}
|
|
232
196
|
|
|
197
|
+
// True once browser_loadCookies succeeds: a page that needs the session's
|
|
198
|
+
// cookies must not be answered by an anonymous static fetch (F04).
|
|
199
|
+
let cookiesLoaded = false;
|
|
200
|
+
|
|
201
|
+
// ─── Export Path Confinement (F07) ────────────────────────────────────────────
|
|
202
|
+
// browser_export used to accept any output_path: an injected page could steer
|
|
203
|
+
// the agent into overwriting ~/.bashrc. Writes are now confined to one
|
|
204
|
+
// directory, with an extension allowlist, symlink checks, and no silent
|
|
205
|
+
// overwrite.
|
|
206
|
+
|
|
207
|
+
const EXPORT_EXTS = { md: ".md", txt: ".txt", html: ".html" };
|
|
208
|
+
|
|
209
|
+
function exportsDir() {
|
|
210
|
+
return process.env.BWB_EXPORTS_DIR || join(dirname(cfg.screenshotsDir), "bwb-exports");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Resolve a caller-supplied filename inside the export dir.
|
|
215
|
+
* @returns {{path: string}|{error: Error}}
|
|
216
|
+
*/
|
|
217
|
+
export function resolveExportPath(filename, format, overwrite = false) {
|
|
218
|
+
const dir = exportsDir();
|
|
219
|
+
const ext = EXPORT_EXTS[format];
|
|
220
|
+
if (!ext) return { error: new Error(`Unsupported export format: ${format}`) };
|
|
221
|
+
|
|
222
|
+
let base;
|
|
223
|
+
if (filename) {
|
|
224
|
+
base = filename;
|
|
225
|
+
// An absolute path or a traversal is a refusal, not something to normalize
|
|
226
|
+
// into shape: the caller is told the file name, not the path.
|
|
227
|
+
if (base.includes("/") || base.includes("\\") || base === "..") {
|
|
228
|
+
return { error: new Error(`Refusing path outside the export directory: ${filename}. Pass a plain file name.`) };
|
|
229
|
+
}
|
|
230
|
+
} else {
|
|
231
|
+
base = `bwb-export-${Date.now()}${ext}`;
|
|
232
|
+
}
|
|
233
|
+
if (!base.endsWith(ext)) base += ext;
|
|
234
|
+
|
|
235
|
+
const path = join(dir, base);
|
|
236
|
+
// Resolve symlinks on the directory too, or a symlinked export dir is a
|
|
237
|
+
// free pass out of the sandbox.
|
|
238
|
+
let realDir;
|
|
239
|
+
try {
|
|
240
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
241
|
+
realDir = realpathSync(dir);
|
|
242
|
+
} catch (err) {
|
|
243
|
+
return { error: new Error(`Export directory unusable: ${err.message}`) };
|
|
244
|
+
}
|
|
245
|
+
const realPath = join(realDir, base);
|
|
246
|
+
if (!realPath.startsWith(realDir + "/")) {
|
|
247
|
+
return { error: new Error(`Refusing path outside the export directory: ${filename}`) };
|
|
248
|
+
}
|
|
249
|
+
if (!overwrite) {
|
|
250
|
+
try {
|
|
251
|
+
if (statSync(realPath)) return { error: new Error(`${base} already exists. Pass overwrite:true to replace it.`) };
|
|
252
|
+
} catch { /* does not exist — good */ }
|
|
253
|
+
}
|
|
254
|
+
return { path: realPath };
|
|
255
|
+
}
|
|
256
|
+
|
|
233
257
|
// ─── Watch State (Live Page Event Capture) ─────────────────────────────────────
|
|
234
258
|
|
|
235
259
|
const WATCH_MAX_EVENTS = 5000;
|
|
@@ -248,33 +272,39 @@ function cleanupWatch() {
|
|
|
248
272
|
watchState.events = [];
|
|
249
273
|
}
|
|
250
274
|
|
|
275
|
+
// chrome-remote-interface's event subscriptions return an UNSUBSCRIBE
|
|
276
|
+
// function. The old code discarded every one of them, so cleanupWatch() removed
|
|
277
|
+
// nothing: calling `start` twice doubled the events, and listeners stayed bound
|
|
278
|
+
// to a tab that had been switched away from or closed.
|
|
251
279
|
function setupWatch(events, cdp) {
|
|
252
280
|
cleanupWatch();
|
|
253
281
|
watchState.active = true;
|
|
282
|
+
const keep = (dispose) => { if (typeof dispose === "function") watchState.disposables.push(dispose); };
|
|
283
|
+
const want = (kind) => events.includes("all") || events.includes(kind);
|
|
254
284
|
|
|
255
|
-
if (
|
|
256
|
-
cdp.Runtime.consoleAPICalled((params) => {
|
|
285
|
+
if (want("console")) {
|
|
286
|
+
keep(cdp.Runtime.consoleAPICalled((params) => {
|
|
257
287
|
watchPush({ type: "console", timestamp: Date.now(), level: params.type || "log",
|
|
258
288
|
text: (params.args || []).map(a => a.value !== undefined ? String(a.value) : a.description || "").join(" ") });
|
|
259
|
-
});
|
|
260
|
-
cdp.Runtime.exceptionThrown((params) => {
|
|
289
|
+
}));
|
|
290
|
+
keep(cdp.Runtime.exceptionThrown((params) => {
|
|
261
291
|
const d = params.exceptionDetails;
|
|
262
292
|
watchPush({ type: "exception", timestamp: Date.now(), text: d?.exception?.description || d?.text || "Unknown exception" });
|
|
263
|
-
});
|
|
293
|
+
}));
|
|
264
294
|
}
|
|
265
|
-
if (
|
|
266
|
-
cdp.Network.requestWillBeSent((params) => {
|
|
295
|
+
if (want("network")) {
|
|
296
|
+
keep(cdp.Network.requestWillBeSent((params) => {
|
|
267
297
|
watchPush({ type: "network", timestamp: Date.now(), subtype: "request", url: params.request?.url || "", method: params.request?.method || "GET" });
|
|
268
|
-
});
|
|
269
|
-
cdp.Network.responseReceived((params) => {
|
|
298
|
+
}));
|
|
299
|
+
keep(cdp.Network.responseReceived((params) => {
|
|
270
300
|
if (params.response?.url?.startsWith("data:")) return;
|
|
271
301
|
watchPush({ type: "network", timestamp: Date.now(), subtype: "response", url: params.response?.url || "", status: params.response?.status || 0, mimeType: params.response?.mimeType || "" });
|
|
272
|
-
});
|
|
302
|
+
}));
|
|
273
303
|
}
|
|
274
|
-
if (
|
|
275
|
-
cdp.Page.frameNavigated((params) => {
|
|
304
|
+
if (want("navigation")) {
|
|
305
|
+
keep(cdp.Page.frameNavigated((params) => {
|
|
276
306
|
watchPush({ type: "navigation", timestamp: Date.now(), url: params.frame?.url || "" });
|
|
277
|
-
});
|
|
307
|
+
}));
|
|
278
308
|
}
|
|
279
309
|
}
|
|
280
310
|
|
|
@@ -282,34 +312,94 @@ function setupWatch(events, cdp) {
|
|
|
282
312
|
|
|
283
313
|
const server = new McpServer({ name: "bwb-browser", version: BWB_VERSION });
|
|
284
314
|
|
|
315
|
+
/** Uniform error envelope: tools report failures as data, never as throws. */
|
|
316
|
+
function errResult(err, prefix = "") {
|
|
317
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
318
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: prefix ? `${prefix}: ${message}` : message }) }] };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Tools that change state — disabled wholesale by --readonly / BWB_READONLY. */
|
|
322
|
+
const WRITE_TOOLS = new Set([
|
|
323
|
+
"browser_click", "browser_fill", "browser_eval", "browser_act",
|
|
324
|
+
"browser_export", "browser_download", "browser_saveCookies", "browser_restart",
|
|
325
|
+
"browser_newTab", "browser_closeTab", "browser_switchTab",
|
|
326
|
+
"browser_setViewport", "browser_fingerprint", "browser_back",
|
|
327
|
+
]);
|
|
328
|
+
|
|
285
329
|
// Tool implementations
|
|
286
330
|
const tools = {
|
|
287
331
|
// ═══════════════ CORE BROWSING ═══════════════
|
|
288
332
|
|
|
289
333
|
browser_goto: {
|
|
290
|
-
description: "
|
|
291
|
-
schema: {
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
334
|
+
description: "Open a URL. `mode:\"auto\"` (default) tries a plain HTTP fetch with article extraction first (fast, no browser) and falls back to Chromium for JS-rendered pages, login walls, or when cookies are loaded; `mode:\"browser\"` always uses Chromium. Returns `{mode, title, url, text, links}`. Only http(s) URLs are allowed. After a static result the next browser tool brings Chromium to that same URL automatically.",
|
|
335
|
+
schema: {
|
|
336
|
+
url: z.string().describe("URL to navigate to (http/https only)"),
|
|
337
|
+
mode: z.enum(["auto", "static", "browser"]).describe("auto (default): static first, browser on demand. static: never spawn Chromium. browser: always use Chromium.").optional(),
|
|
338
|
+
maxChars: z.number().describe("Cap on returned text (default 20000)").optional(),
|
|
339
|
+
raw: z.boolean().describe("Return the raw HTML text instead of Readability output").optional(),
|
|
340
|
+
},
|
|
341
|
+
handler: async ({ url, mode = "auto", maxChars = 20000, raw = false }) => {
|
|
342
|
+
try {
|
|
343
|
+
assertNavigable(url, { allowDomains: cfg.allowDomains || null });
|
|
344
|
+
} catch (err) {
|
|
345
|
+
return errResult(err);
|
|
302
346
|
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
347
|
+
|
|
348
|
+
// ─── Shared escalation path (F04): one navigate() for every entry point ───
|
|
349
|
+
const navigateInBrowser = async () => {
|
|
350
|
+
const cdp = await getActiveProtocol();
|
|
351
|
+
setPendingStatic(null);
|
|
352
|
+
let result;
|
|
353
|
+
try {
|
|
354
|
+
result = await gotoUrl(cdp.Page, cdp.Runtime, url, cfg.navTimeout, {
|
|
355
|
+
allowDomains: cfg.allowDomains || null,
|
|
356
|
+
});
|
|
357
|
+
} catch (err) {
|
|
358
|
+
// A failed navigation is DATA, not a protocol-level exception: the
|
|
359
|
+
// agent needs to read "ERR_NAME_NOT_RESOLVED", not a stack trace.
|
|
360
|
+
throw Object.assign(err, { isToolError: true });
|
|
361
|
+
}
|
|
362
|
+
syncActiveTab(result.title, result.url);
|
|
363
|
+
return { ...result, mode: "browser" };
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
// Skip the static rung when it would answer DIFFERENTLY than the browser
|
|
367
|
+
// would: cookies loaded this run, a browser already showing a real page,
|
|
368
|
+
// or the global BWB_ALWAYS_BROWSER override.
|
|
369
|
+
const browserBusy = browser && !browserExited;
|
|
370
|
+
const useStatic =
|
|
371
|
+
mode === "static" ||
|
|
372
|
+
(mode === "auto" && !cfg.alwaysBrowser && !cookiesLoaded && !browserBusy);
|
|
373
|
+
|
|
374
|
+
if (useStatic) {
|
|
375
|
+
const attempt = await staticFetch(url, {
|
|
376
|
+
timeout: Math.min(cfg.navTimeout, 15000),
|
|
377
|
+
allowPrivate: cfg.allowPrivate,
|
|
378
|
+
allowDomains: cfg.allowDomains?.length ? cfg.allowDomains : null,
|
|
379
|
+
maxChars,
|
|
380
|
+
raw,
|
|
381
|
+
});
|
|
382
|
+
if (attempt.mode === "static") {
|
|
383
|
+
// Record it: the first tool that needs Chromium navigates there.
|
|
384
|
+
setPendingStatic(attempt.finalUrl || url, attempt.title);
|
|
385
|
+
return { content: [{ type: "text", text: JSON.stringify({
|
|
386
|
+
mode: "static", title: attempt.title, url: attempt.finalUrl,
|
|
387
|
+
text: attempt.text, links: attempt.links || [], confidence: attempt.confidence,
|
|
388
|
+
...(attempt.truncated ? { truncated: true, nextOffset: attempt.nextOffset } : {}),
|
|
389
|
+
note: "Served without Chromium. Interaction or screenshots start the browser on this same URL.",
|
|
390
|
+
}) }] };
|
|
391
|
+
}
|
|
392
|
+
if (attempt.mode === "error") {
|
|
393
|
+
// Dead URL — CDP shares the same network, don't spawn Chromium for a 404.
|
|
394
|
+
return { content: [{ type: "text", text: JSON.stringify({ mode: "error", error: attempt.error }) }] };
|
|
395
|
+
}
|
|
396
|
+
// Rung 2: escalate (JS shell, auth wall, non-text content).
|
|
397
|
+
const result = await navigateInBrowser();
|
|
398
|
+
return { content: [{ type: "text", text: JSON.stringify({ ...result, escalated: attempt.reason }) }] };
|
|
306
399
|
}
|
|
307
|
-
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
const result = await gotoUrl(Page, Runtime, url, cfg.navTimeout);
|
|
311
|
-
syncActiveTab(result.title, result.url);
|
|
312
|
-
return { content: [{ type: "text", text: JSON.stringify({ ...result, mode: "browser", escalated: attempt.reason }) }] };
|
|
400
|
+
|
|
401
|
+
const result = await navigateInBrowser();
|
|
402
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
313
403
|
},
|
|
314
404
|
},
|
|
315
405
|
|
|
@@ -360,28 +450,43 @@ const tools = {
|
|
|
360
450
|
},
|
|
361
451
|
|
|
362
452
|
browser_html: {
|
|
363
|
-
description: "
|
|
364
|
-
schema: {
|
|
365
|
-
|
|
453
|
+
description: "Return the page HTML (or the outerHTML of the first element matching a CSS selector). Capped at `maxChars` (default 200000). Use browser_text for human-readable text, browser_elements for a clickable list.",
|
|
454
|
+
schema: {
|
|
455
|
+
selector: z.string().describe("Optional CSS selector").optional(),
|
|
456
|
+
maxChars: z.number().describe("Cap on returned HTML (default 200000)").optional(),
|
|
457
|
+
},
|
|
458
|
+
handler: async ({ selector, maxChars = 200000 }) => {
|
|
366
459
|
const { Runtime } = await getActiveProtocol();
|
|
367
460
|
const expr = selector
|
|
368
461
|
? `document.querySelector(${JSON.stringify(selector)})?.outerHTML || ''`
|
|
369
462
|
: "document.documentElement.outerHTML";
|
|
370
463
|
const { result } = await Runtime.evaluate({ expression: expr });
|
|
371
|
-
|
|
464
|
+
const html = result?.value || "";
|
|
465
|
+
return { content: [{ type: "text", text: capText(html, maxChars).text }] };
|
|
372
466
|
},
|
|
373
467
|
},
|
|
374
468
|
|
|
375
469
|
browser_text: {
|
|
376
|
-
description: "
|
|
377
|
-
schema: {
|
|
378
|
-
|
|
470
|
+
description: "Return the visible text (`innerText`) of the page or of the first element matching a CSS selector — no script/style bodies, no hidden nodes. Capped at `maxChars` (default 20000; `truncated:true` when cut).",
|
|
471
|
+
schema: {
|
|
472
|
+
selector: z.string().describe("Optional CSS selector").optional(),
|
|
473
|
+
maxChars: z.number().describe("Cap on returned text (default 20000)").optional(),
|
|
474
|
+
},
|
|
475
|
+
handler: async ({ selector, maxChars = 20000 }) => {
|
|
379
476
|
const { Runtime } = await getActiveProtocol();
|
|
477
|
+
// innerText, not textContent: textContent includes <script>/<style>
|
|
478
|
+
// bodies and display:none subtrees, which is not "visible text".
|
|
380
479
|
const expr = selector
|
|
381
|
-
? `document.querySelector(${JSON.stringify(selector)})?.
|
|
382
|
-
:
|
|
480
|
+
? `(document.querySelector(${JSON.stringify(selector)})?.innerText ?? '')`
|
|
481
|
+
: `(document.body?.innerText ?? document.body?.textContent ?? '')`;
|
|
383
482
|
const { result } = await Runtime.evaluate({ expression: expr });
|
|
384
|
-
|
|
483
|
+
const text = result?.value || "";
|
|
484
|
+
const capped = capText(text, maxChars);
|
|
485
|
+
return {
|
|
486
|
+
content: [{ type: "text", text: JSON.stringify(capped.truncated
|
|
487
|
+
? { text: capped.text, truncated: true, nextOffset: capped.nextOffset }
|
|
488
|
+
: { text: capped.text }) }],
|
|
489
|
+
};
|
|
385
490
|
},
|
|
386
491
|
},
|
|
387
492
|
|
|
@@ -410,24 +515,35 @@ const tools = {
|
|
|
410
515
|
// ═══════════════ INTERACTION ═══════════════
|
|
411
516
|
|
|
412
517
|
browser_click: {
|
|
413
|
-
description: "Click an element by CSS selector
|
|
518
|
+
description: "Click an element by CSS selector with native CDP mouse events. Scrolls it into view and hit-tests the click point first; the result reports `hit` (whether the point really was that element) and `landedOn` (what was actually there).",
|
|
414
519
|
schema: { selector: z.string().describe("CSS selector") },
|
|
415
520
|
handler: async ({ selector }) => {
|
|
416
521
|
const cdp = await getActiveProtocol();
|
|
417
522
|
const { Page, Runtime, Input } = cdp;
|
|
418
523
|
const info = await clickElement(Page, Runtime, Input, selector);
|
|
419
|
-
return { content: [{ type: "text", text: JSON.stringify({
|
|
524
|
+
return { content: [{ type: "text", text: JSON.stringify({
|
|
525
|
+
clicked: selector, tag: info.tag, text: info.text,
|
|
526
|
+
hit: info.hit, landedOn: info.landedOn,
|
|
527
|
+
...(info.hit === false ? { warning: "The click point was covered or off-screen — the click may have missed." } : {}),
|
|
528
|
+
}) }] };
|
|
420
529
|
},
|
|
421
530
|
},
|
|
422
531
|
|
|
423
532
|
browser_fill: {
|
|
424
|
-
description: "Clear
|
|
533
|
+
description: "Clear an input field and fill it with text using native CDP Input.insertText (fires the page's own input events). Existing content is selected first, so the field is replaced, not appended to. Returns the length only — never the text — when the target is a password field or looks like a secret.",
|
|
425
534
|
schema: { selector: z.string().describe("CSS selector for input"), text: z.string().describe("Text to fill") },
|
|
426
535
|
handler: async ({ selector, text }) => {
|
|
427
536
|
const cdp = await getActiveProtocol();
|
|
428
537
|
const { Page, Runtime, Input } = cdp;
|
|
429
|
-
await fillElement(Page, Runtime, Input, selector, text);
|
|
430
|
-
|
|
538
|
+
const info = await fillElement(Page, Runtime, Input, selector, text);
|
|
539
|
+
// Redact on what the FIELD is, not only what the selector is called:
|
|
540
|
+
// `#pw` is a password field just as much as `#password` is.
|
|
541
|
+
const secretField = info.type === "password" ||
|
|
542
|
+
/pass|secret|token|otp|pin|cvv|card/i.test(selector);
|
|
543
|
+
const echo = secretField
|
|
544
|
+
? { length: [...String(text)].length, redacted: true }
|
|
545
|
+
: redactIfSecret(selector, text);
|
|
546
|
+
return { content: [{ type: "text", text: JSON.stringify({ filled: selector, ...echo }) }] };
|
|
431
547
|
},
|
|
432
548
|
},
|
|
433
549
|
|
|
@@ -454,28 +570,46 @@ const tools = {
|
|
|
454
570
|
},
|
|
455
571
|
|
|
456
572
|
browser_eval: {
|
|
457
|
-
description: "
|
|
458
|
-
schema: {
|
|
459
|
-
|
|
573
|
+
description: "Run a JavaScript expression in the active page and return its JSON-serializable result. Promises are awaited (`timeout` ms, default 10000). Runs with the page's privileges, including any logged-in session: only run code you wrote or understand.",
|
|
574
|
+
schema: {
|
|
575
|
+
expression: z.string().describe("JavaScript expression"),
|
|
576
|
+
timeout: z.number().describe("Max ms to await a promise (default 10000)").optional(),
|
|
577
|
+
},
|
|
578
|
+
handler: async ({ expression, timeout = 10000 }) => {
|
|
460
579
|
const { Runtime } = await getActiveProtocol();
|
|
461
|
-
const response = await Runtime.evaluate({
|
|
580
|
+
const response = await Runtime.evaluate({
|
|
581
|
+
expression,
|
|
582
|
+
returnByValue: true,
|
|
583
|
+
// Without awaitPromise, `await fetch(...)` and async IIFEs resolve to
|
|
584
|
+
// {} — the single most common reason an agent thinks its script "did
|
|
585
|
+
// nothing".
|
|
586
|
+
awaitPromise: true,
|
|
587
|
+
timeout,
|
|
588
|
+
});
|
|
462
589
|
if (response.exceptionDetails) {
|
|
463
590
|
const exc = response.exceptionDetails;
|
|
464
591
|
throw new Error(`JS Error: ${exc.exception?.description || exc.text || "Unknown JS error"}`);
|
|
465
592
|
}
|
|
466
593
|
const { result } = response;
|
|
467
|
-
|
|
594
|
+
let value = result?.value;
|
|
595
|
+
if (value === undefined && result?.description) value = result.description;
|
|
596
|
+
return { content: [{ type: "text", text: JSON.stringify(value ?? null) }] };
|
|
468
597
|
},
|
|
469
598
|
},
|
|
470
599
|
|
|
471
600
|
browser_setViewport: {
|
|
472
|
-
description: "Change the viewport size (width × height)
|
|
601
|
+
description: "Change the viewport size (width × height) for responsive testing. Defaults to 1280×720. Pass `reset:true` to clear the override.",
|
|
473
602
|
schema: {
|
|
474
|
-
width: z.number().min(320).max(7680).describe("Viewport width in pixels (default
|
|
475
|
-
height: z.number().min(240).max(4320).describe("Viewport height in pixels (default
|
|
603
|
+
width: z.number().min(320).max(7680).describe("Viewport width in pixels (default 1280)").optional(),
|
|
604
|
+
height: z.number().min(240).max(4320).describe("Viewport height in pixels (default 720)").optional(),
|
|
605
|
+
reset: z.boolean().describe("Clear the override and return to the window size").optional(),
|
|
476
606
|
},
|
|
477
|
-
handler: async ({ width = 1280, height = 720 }) => {
|
|
607
|
+
handler: async ({ width = 1280, height = 720, reset = false }) => {
|
|
478
608
|
const { Emulation } = await getActiveProtocol();
|
|
609
|
+
if (reset) {
|
|
610
|
+
await Emulation.clearDeviceMetricsOverride();
|
|
611
|
+
return { content: [{ type: "text", text: JSON.stringify({ viewport: "reset" }) }] };
|
|
612
|
+
}
|
|
479
613
|
await Emulation.setDeviceMetricsOverride({ width, height, deviceScaleFactor: 1, mobile: false });
|
|
480
614
|
return { content: [{ type: "text", text: JSON.stringify({ viewport: `${width}x${height}` }) }] };
|
|
481
615
|
},
|
|
@@ -484,18 +618,34 @@ const tools = {
|
|
|
484
618
|
// ═══════════════ 🔥 ADVANCED ═══════════════
|
|
485
619
|
|
|
486
620
|
browser_act: {
|
|
487
|
-
description: "
|
|
488
|
-
schema: {
|
|
489
|
-
|
|
621
|
+
description: "Perform one simple action on the current page from a plain-English instruction: `go to <url>`, `search for <text>`, `click <label>`, `fill <field> with <value>`, `type <text> in <field>`, `scroll down|up`, `extract <text>`, `what's on this page`. Matching is literal text/label based (no LLM): if several elements match it returns `candidates` instead of guessing, and clicks on destructive labels (delete/buy/pay/submit…) return `needs_confirmation` unless `force:true`. Returns `{action, ...}` or `{action:\"<name>_error\", error}`. For anything precise use browser_click, browser_fill, browser_elements or browser_eval with a CSS selector.",
|
|
622
|
+
schema: {
|
|
623
|
+
instruction: z.string().describe("Natural language instruction for what to do on the page"),
|
|
624
|
+
force: z.boolean().describe("Click even when the matched label looks destructive").optional(),
|
|
625
|
+
},
|
|
626
|
+
handler: async ({ instruction, force = false }) => {
|
|
490
627
|
const cdp = await getActiveProtocol();
|
|
491
|
-
const result = await executeInstruction(cdp, instruction
|
|
628
|
+
const result = await executeInstruction(cdp, instruction, {
|
|
629
|
+
confirmDestructive: cfg.confirmDestructive,
|
|
630
|
+
force,
|
|
631
|
+
// Navigation goes through the same ladder-aware path as browser_goto,
|
|
632
|
+
// so an act navigation leaves the browser on the page it claims.
|
|
633
|
+
navigate: async (url) => {
|
|
634
|
+
try { assertNavigable(url, { allowDomains: cfg.allowDomains || null }); }
|
|
635
|
+
catch (err) { throw err; }
|
|
636
|
+
const res = await gotoUrl(cdp.Page, cdp.Runtime, url, cfg.navTimeout, {
|
|
637
|
+
allowDomains: cfg.allowDomains || null,
|
|
638
|
+
});
|
|
639
|
+
return { url: res.url, timedOut: res.timedOut };
|
|
640
|
+
},
|
|
641
|
+
});
|
|
492
642
|
if (result.url) syncActiveTab(result.title, result.url);
|
|
493
643
|
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
494
644
|
},
|
|
495
645
|
},
|
|
496
646
|
|
|
497
647
|
browser_watch: {
|
|
498
|
-
description: "
|
|
648
|
+
description: "Record page events from the active tab. `start` begins capturing console messages, exceptions, network requests/responses and navigations (`events`: console, network, navigation, all). `poll` returns and clears events since the last poll, plus memory readings. `stop` ends capture and returns what remains. Keeps the newest 5,000 events. Bound to the tab that was active at `start` — switching tabs or restarting the browser stops the capture.",
|
|
499
649
|
schema: {
|
|
500
650
|
action: z.enum(["start", "poll", "stop"]).describe("start=begin recording, poll=get events since last poll, stop=cleanup"),
|
|
501
651
|
events: z.array(z.enum(["console", "network", "navigation", "all"])).describe("Event types to capture (default: all)").optional(),
|
|
@@ -505,6 +655,9 @@ const tools = {
|
|
|
505
655
|
const cdp = await getActiveProtocol();
|
|
506
656
|
await cdp.Runtime.enable();
|
|
507
657
|
await cdp.Network.enable();
|
|
658
|
+
// frameNavigated is only delivered after Page.enable — the old code
|
|
659
|
+
// subscribed without it and silently captured no navigation events.
|
|
660
|
+
await cdp.Page.enable?.();
|
|
508
661
|
setupWatch(events, cdp);
|
|
509
662
|
setIdleSuppressed(true); // recording in progress — mayfly must not teardown
|
|
510
663
|
return { content: [{ type: "text", text: JSON.stringify({ status: "watching", events, msg: "Recording started. Poll to get events." }) }] };
|
|
@@ -528,17 +681,17 @@ const tools = {
|
|
|
528
681
|
},
|
|
529
682
|
|
|
530
683
|
browser_diagnose: {
|
|
531
|
-
description: "
|
|
684
|
+
description: "Page health check for the active page: load timings, console errors, broken images, meta tags, interaction counts, and a heuristic score (0-100, weighted penalty sum — not a Lighthouse audit). Safe to call while browser_watch is recording.",
|
|
532
685
|
schema: {},
|
|
533
686
|
handler: async () => {
|
|
534
687
|
const cdp = await getActiveProtocol();
|
|
535
|
-
const report = await diagnosePage(cdp);
|
|
688
|
+
const report = await diagnosePage(cdp, { keepRuntimeEnabled: watchState.active });
|
|
536
689
|
return { content: [{ type: "text", text: JSON.stringify(report) }] };
|
|
537
690
|
},
|
|
538
691
|
},
|
|
539
692
|
|
|
540
693
|
browser_fingerprint: {
|
|
541
|
-
description: "Apply a
|
|
694
|
+
description: "Apply common anti-detection patches (hide the webdriver flag, plausible plugins/languages, a user agent derived from the real Chromium build) to pages loaded afterwards. Intended for testing sites you own or have permission to test. Call before browser_goto.",
|
|
542
695
|
schema: {},
|
|
543
696
|
handler: async () => {
|
|
544
697
|
const cdp = await getActiveProtocol();
|
|
@@ -603,22 +756,28 @@ const tools = {
|
|
|
603
756
|
// ═══════════════ SESSION ═══════════════
|
|
604
757
|
|
|
605
758
|
browser_saveCookies: {
|
|
606
|
-
description: "Save
|
|
607
|
-
schema: {
|
|
608
|
-
|
|
759
|
+
description: "Save cookies for this browser profile as a JSON file in ~/.bwb/sessions (permissions 600). The file contains live login credentials: treat it like a password. Pass `domains` to save only some sites. After loading, navigate to the target site for cookies to apply.",
|
|
760
|
+
schema: {
|
|
761
|
+
name: z.string().describe("Name for this session (e.g., 'gmail')"),
|
|
762
|
+
domains: z.array(z.string()).describe("Only save cookies for these domains (optional; default: all)").optional(),
|
|
763
|
+
},
|
|
764
|
+
handler: async ({ name, domains }) => {
|
|
609
765
|
const cdp = await getActiveProtocol();
|
|
610
|
-
const result = await saveSession(name, cdp);
|
|
766
|
+
const result = await saveSession(name, cdp, { domains });
|
|
611
767
|
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
612
768
|
},
|
|
613
769
|
},
|
|
614
770
|
|
|
615
771
|
browser_loadCookies: {
|
|
616
|
-
description: "Load a saved
|
|
617
|
-
schema: { name: z.string().describe("Session name to load (e.g., '
|
|
772
|
+
description: "Load a saved session (cookies) from disk. Only http(s) pages requested after this call will use them — static fetches are skipped for the rest of the run, so a logged-in page is never answered anonymously.",
|
|
773
|
+
schema: { name: z.string().describe("Session name to load (e.g., 'gmail')") },
|
|
618
774
|
handler: async ({ name }) => {
|
|
619
775
|
const cdp = await getActiveProtocol();
|
|
620
776
|
const result = await loadSession(name, cdp);
|
|
621
|
-
|
|
777
|
+
// From here on the static rung would answer with a logged-OUT page and
|
|
778
|
+
// report high confidence. Force the browser rung.
|
|
779
|
+
cookiesLoaded = true;
|
|
780
|
+
return { content: [{ type: "text", text: JSON.stringify({ ...result, staticFetchDisabled: true }) }] };
|
|
622
781
|
},
|
|
623
782
|
},
|
|
624
783
|
|
|
@@ -636,20 +795,26 @@ const tools = {
|
|
|
636
795
|
// bundled — probed at call time, installed only on explicit user consent.
|
|
637
796
|
|
|
638
797
|
browser_download: {
|
|
639
|
-
description: "Download media from a URL (video, audio, subtitles, thumbnail). Requires yt-dlp on the system — if missing, returns install instructions
|
|
798
|
+
description: "Download media from a URL (video, audio, subtitles, thumbnail). Requires yt-dlp on the system — if missing, returns install instructions and downloads nothing. http(s) only; the URL is passed to yt-dlp as an argument, never through a shell.",
|
|
640
799
|
schema: {
|
|
641
|
-
url: z.string().describe("Media URL"),
|
|
800
|
+
url: z.string().describe("Media URL (http/https)"),
|
|
642
801
|
format: z.enum(["best", "audio", "video", "subtitles", "thumbnail"]).describe("What to download").optional(),
|
|
643
802
|
quality: z.enum(["best", "good", "worst"]).describe("Quality tier").optional(),
|
|
644
803
|
},
|
|
645
804
|
handler: async ({ url, format = "best", quality = "best" }) => {
|
|
646
|
-
//
|
|
647
|
-
|
|
648
|
-
|
|
805
|
+
// Real URL parsing instead of a regex. The old regex had `\\s` inside a
|
|
806
|
+
// character class — a literal backslash AND the letter "s" — so every URL
|
|
807
|
+
// containing an "s" was refused (x.com/user/status/1, instagram, tiktok)
|
|
808
|
+
// while "https://example.com/a b" was accepted.
|
|
809
|
+
let target;
|
|
810
|
+
try {
|
|
811
|
+
target = assertHttpUrl(url, { allowDomains: cfg.allowDomains || null });
|
|
812
|
+
} catch (err) {
|
|
813
|
+
return errResult(err, "refused: only http(s) URLs without shell metacharacters are allowed");
|
|
649
814
|
}
|
|
650
815
|
let hasYtDlp = false;
|
|
651
816
|
try {
|
|
652
|
-
|
|
817
|
+
execFileSync("yt-dlp", ["--version"], { stdio: "ignore", timeout: 5000 });
|
|
653
818
|
hasYtDlp = true;
|
|
654
819
|
} catch {}
|
|
655
820
|
if (!hasYtDlp) {
|
|
@@ -672,9 +837,11 @@ const tools = {
|
|
|
672
837
|
else if (format === "thumbnail") args.push("--write-thumbnail", "--skip-download");
|
|
673
838
|
if (quality === "worst") args.push("-f", "worst");
|
|
674
839
|
else if (quality === "good") args.push("-f", "best[height<=720]");
|
|
675
|
-
|
|
840
|
+
// `--` ends option parsing so a URL can never be read as a yt-dlp flag.
|
|
841
|
+
args.push("--", target.href);
|
|
676
842
|
try {
|
|
677
|
-
|
|
843
|
+
// execFile, not execSync: no shell is constructed at any point.
|
|
844
|
+
const out = execFileSync("yt-dlp", args, { encoding: "utf8", timeout: 600000, maxBuffer: 1024 * 1024 });
|
|
678
845
|
const file = out.trim().split("\n").pop();
|
|
679
846
|
return { content: [{ type: "text", text: JSON.stringify({ downloaded: file, format, quality }) }] };
|
|
680
847
|
} catch (err) {
|
|
@@ -684,45 +851,26 @@ const tools = {
|
|
|
684
851
|
},
|
|
685
852
|
|
|
686
853
|
browser_export: {
|
|
687
|
-
description: "
|
|
854
|
+
description: "Write text to a file inside the bwb export directory (default ~/bwb-exports). Formats: md, txt, html. Paths outside the export directory are refused. No pdf/docx/pptx: those were reported as \"ready\" while writing nothing.",
|
|
688
855
|
schema: {
|
|
689
856
|
text: z.string().describe("Content to export (markdown accepted)"),
|
|
690
|
-
format: z.enum(["md", "txt", "html"
|
|
691
|
-
|
|
692
|
-
title: z.string().describe("Document title").optional(),
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
const dest =
|
|
697
|
-
if (
|
|
698
|
-
try { wfs(dest, text, "utf8"); } catch (err) {
|
|
699
|
-
return { content: [{ type: "text", text: JSON.stringify({ error: `write failed: ${err.message}` }) }] };
|
|
700
|
-
}
|
|
701
|
-
return { content: [{ type: "text", text: JSON.stringify({ exported: dest, format }) }] };
|
|
702
|
-
}
|
|
857
|
+
format: z.enum(["md", "txt", "html"]).describe("Output format").optional(),
|
|
858
|
+
filename: z.string().describe("File name inside the export dir (optional; extension from format)").optional(),
|
|
859
|
+
title: z.string().describe("Document title (html only)").optional(),
|
|
860
|
+
overwrite: z.boolean().describe("Allow replacing an existing file").optional(),
|
|
861
|
+
},
|
|
862
|
+
handler: async ({ text, format = "md", filename, title = "bwb export", overwrite = false }) => {
|
|
863
|
+
const dest = resolveExportPath(filename, format, overwrite);
|
|
864
|
+
if (dest.error) return errResult(dest.error);
|
|
703
865
|
if (format === "html") {
|
|
704
866
|
const esc = text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
// pdf/docx/pptx need python libs — probe, then consent-gate.
|
|
711
|
-
const need = { pdf: "reportlab", docx: "python-docx", pptx: "python-pptx" }[format];
|
|
712
|
-
let have = false;
|
|
713
|
-
try {
|
|
714
|
-
execSync(`python3 -c "import ${need.split("-").join("_")}"`, { stdio: "ignore", timeout: 10000 });
|
|
715
|
-
have = true;
|
|
716
|
-
} catch {}
|
|
717
|
-
if (!have) {
|
|
718
|
-
return { content: [{ type: "text", text: JSON.stringify({
|
|
719
|
-
needsInstall: true,
|
|
720
|
-
tool: need,
|
|
721
|
-
install: `pip install ${need}`,
|
|
722
|
-
ask: `${need} is not installed. Reply YES (agent: ask the human) to install it, or install manually and retry. Nothing was written. md/txt/html export works without it.`,
|
|
723
|
-
}) }] };
|
|
867
|
+
writeFileSync(dest.path,
|
|
868
|
+
`<!doctype html><html><head><meta charset="utf8"><title>${title}</title></head><body><pre>${esc}</pre></body></html>`,
|
|
869
|
+
"utf8");
|
|
870
|
+
} else {
|
|
871
|
+
writeFileSync(dest.path, text, "utf8");
|
|
724
872
|
}
|
|
725
|
-
return { content: [{ type: "text", text: JSON.stringify({
|
|
873
|
+
return { content: [{ type: "text", text: JSON.stringify({ exported: dest.path, format }) }] };
|
|
726
874
|
},
|
|
727
875
|
},
|
|
728
876
|
|
|
@@ -733,9 +881,11 @@ const tools = {
|
|
|
733
881
|
schema: {},
|
|
734
882
|
handler: async () => {
|
|
735
883
|
const status = { connected: false, port: cfg.port, actualPort: null, running: false, pid: null, tabs: [] };
|
|
736
|
-
|
|
884
|
+
// Attach mode: no child process (browser === null) — liveness is the CDP link.
|
|
885
|
+
if ((browser && !browserExited) || (attached && !browserExited)) {
|
|
737
886
|
status.running = true;
|
|
738
|
-
status.pid = browser.pid;
|
|
887
|
+
status.pid = browser ? browser.pid : null;
|
|
888
|
+
if (attached) status.attached = true;
|
|
739
889
|
status.tabs = listTabs();
|
|
740
890
|
// actualCdpPort is the real bound port; cfg.port may be 0 (random).
|
|
741
891
|
// Never fall back to a hardcoded 9222 — that could be another tool's browser.
|
|
@@ -753,34 +903,89 @@ const tools = {
|
|
|
753
903
|
status.resources = sampleResources(browser?.pid, status.tabs.filter((t) => !t.hibernated).length);
|
|
754
904
|
status.resources.state = assess(status.resources);
|
|
755
905
|
status.profile = { lean: cfg.lean, nuclear: cfg.nuclear, idleMs: cfg.idleMs, tabMax: cfg.tabMax };
|
|
906
|
+
if (attached) status.profile.attached = actualCdpPort || cfg.attachPort;
|
|
756
907
|
} catch {}
|
|
757
908
|
return { content: [{ type: "text", text: JSON.stringify(status) }] };
|
|
758
909
|
},
|
|
759
910
|
},
|
|
760
911
|
|
|
761
912
|
browser_restart: {
|
|
762
|
-
description: "Cleanly restart the browser process.
|
|
913
|
+
description: "Cleanly restart the browser process, then resume the page you were on. Use it to free memory, clear state, or recover from a wedged session. Cookies in the profile survive; in-page state does not.",
|
|
763
914
|
schema: {},
|
|
764
915
|
handler: async () => {
|
|
916
|
+
// An explicit restart is the agent asking for a fresh browser mid-task,
|
|
917
|
+
// not permission to forget which page the task was on. Re-queue the
|
|
918
|
+
// current URL so the next page tool lands back on it.
|
|
919
|
+
const active = listTabs().find((t) => t.active);
|
|
920
|
+
const resume = /^https?:/i.test(active?.url || "") ? active.url : null;
|
|
765
921
|
clearTabs(); // Kill stale tab connections before restart
|
|
766
922
|
cleanupWatch(); // Detach event listeners from the dying protocol before it's gone
|
|
767
923
|
const result = await restartBrowser();
|
|
768
|
-
|
|
924
|
+
if (resume) setPendingStatic(resume, active.title);
|
|
925
|
+
return { content: [{ type: "text", text: JSON.stringify({ ...result, resuming: resume }) }] };
|
|
769
926
|
},
|
|
770
927
|
},
|
|
771
928
|
};
|
|
772
929
|
|
|
773
930
|
// ─── Register & Start ─────────────────────────────────────────────────────────
|
|
774
931
|
|
|
775
|
-
//
|
|
776
|
-
//
|
|
777
|
-
|
|
932
|
+
// Tools that operate on a live page. browser_goto and the static tools are
|
|
933
|
+
// excluded: they must work without ever starting Chromium.
|
|
934
|
+
const PAGE_TOOLS = new Set([
|
|
935
|
+
"browser_text", "browser_html", "browser_screenshot", "browser_click",
|
|
936
|
+
"browser_fill", "browser_elements", "browser_eval", "browser_back",
|
|
937
|
+
"browser_waitForSelector", "browser_diagnose", "browser_fingerprint",
|
|
938
|
+
"browser_loadCookies", "browser_saveCookies",
|
|
939
|
+
]);
|
|
940
|
+
|
|
941
|
+
// Single choke point for every tool call:
|
|
942
|
+
// 1. --readonly blocks state-changing tools before they run
|
|
943
|
+
// 2. materialize a pending static page so "static first" is actually true
|
|
944
|
+
// 3. poke the mayfly timer
|
|
945
|
+
// 4. append a ~100-byte resource footer, shedding load on critical pressure
|
|
946
|
+
let criticalStreak = 0;
|
|
947
|
+
|
|
778
948
|
for (const [name, tool] of Object.entries(tools)) {
|
|
779
949
|
const inner = tool.handler;
|
|
780
950
|
server.tool(name, tool.description, tool.schema, async (args) => {
|
|
951
|
+
if (cfg.readonly && WRITE_TOOLS.has(name)) {
|
|
952
|
+
return { content: [
|
|
953
|
+
{ type: "text", text: JSON.stringify({
|
|
954
|
+
error: `bwb is running in --readonly mode: ${name} is disabled. Restart without --readonly (or BWB_READONLY) to allow state changes.`,
|
|
955
|
+
}) },
|
|
956
|
+
{ type: "text", text: resourceFooter(sampleResources(null, 0), "readonly") },
|
|
957
|
+
] };
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// Static-first only pays off if the browser catches up when it matters.
|
|
961
|
+
// Before any tool that needs a live page, navigate Chromium to whatever
|
|
962
|
+
// browser_goto served statically (F04).
|
|
963
|
+
if (PAGE_TOOLS.has(name) && needsMaterialize()) {
|
|
964
|
+
try {
|
|
965
|
+
await materializeStatic();
|
|
966
|
+
} catch (err) {
|
|
967
|
+
return { content: [{ type: "text", text: JSON.stringify({
|
|
968
|
+
error: `Could not load the statically fetched page in the browser: ${err.message}`,
|
|
969
|
+
}) }] };
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
// A watch is bound to one tab: switching tabs or restarting invalidates it.
|
|
973
|
+
if (watchState.active && (name === "browser_switchTab" || name === "browser_closeTab")) {
|
|
974
|
+
cleanupWatch();
|
|
975
|
+
setIdleSuppressed(false);
|
|
976
|
+
}
|
|
977
|
+
|
|
781
978
|
let result;
|
|
782
979
|
try {
|
|
783
980
|
result = await inner(args);
|
|
981
|
+
} catch (err) {
|
|
982
|
+
// Tools report failure as data. An exception here would arrive at the
|
|
983
|
+
// agent as a protocol error with no structured detail.
|
|
984
|
+
if (err?.isToolError || err?.name === "UrlPolicyError") {
|
|
985
|
+
result = errResult(err);
|
|
986
|
+
} else {
|
|
987
|
+
throw err;
|
|
988
|
+
}
|
|
784
989
|
} finally {
|
|
785
990
|
try { pokeActivity(); } catch {}
|
|
786
991
|
}
|
|
@@ -788,7 +993,9 @@ for (const [name, tool] of Object.entries(tools)) {
|
|
|
788
993
|
const liveTabs = listTabs().filter((t) => !t.hibernated).length;
|
|
789
994
|
const sample = sampleResources(browser?.pid, liveTabs);
|
|
790
995
|
let note = "";
|
|
791
|
-
|
|
996
|
+
// Never auto-shed in attach mode: those are the user's REAL tabs.
|
|
997
|
+
// Report pressure in the footer; the human closes their own tabs.
|
|
998
|
+
if (!attached && assess(sample) === "critical" && browser && !browserExited) {
|
|
792
999
|
// Evidence first: keep the peak numbers that triggered the shed.
|
|
793
1000
|
const peak = `${sample.mcpMb}+${sample.chromiumMb ?? "?"}MB`;
|
|
794
1001
|
// Shed oldest non-active tabs first; teardown at one tab. Journal keeps all.
|
|
@@ -800,10 +1007,24 @@ for (const [name, tool] of Object.entries(tools)) {
|
|
|
800
1007
|
Object.assign(sample, after);
|
|
801
1008
|
if (assess(sample) !== "critical") break;
|
|
802
1009
|
}
|
|
803
|
-
|
|
1010
|
+
// Teardown throws away in-page state (a half-filled form, scroll
|
|
1011
|
+
// position, a page's JS heap) and the next call resurrects from the
|
|
1012
|
+
// journal. Only do it when the pressure is not a one-off sample AND
|
|
1013
|
+
// no multi-step flow is in flight.
|
|
1014
|
+
criticalStreak++;
|
|
1015
|
+
const flowInProgress = watchState.active;
|
|
1016
|
+
if (assess(sample) === "critical"
|
|
1017
|
+
&& listTabs().filter((t) => !t.hibernated).length <= 1
|
|
1018
|
+
&& criticalStreak >= 2
|
|
1019
|
+
&& !flowInProgress) {
|
|
804
1020
|
try { await stopBrowser("oom-guard"); } catch {}
|
|
805
|
-
|
|
1021
|
+
criticalStreak = 0;
|
|
1022
|
+
note += `${note ? "; " : ""}browser stopped at peak ${peak} (oom-guard, twice) — journal saved, next call resurrects`;
|
|
1023
|
+
} else if (flowInProgress) {
|
|
1024
|
+
note += `${note ? "; " : ""}oom-guard held: browser_watch is recording`;
|
|
806
1025
|
}
|
|
1026
|
+
} else {
|
|
1027
|
+
criticalStreak = 0;
|
|
807
1028
|
}
|
|
808
1029
|
if (result && Array.isArray(result.content)) {
|
|
809
1030
|
result.content.push({ type: "text", text: resourceFooter(sample, note) });
|
|
@@ -813,6 +1034,5 @@ for (const [name, tool] of Object.entries(tools)) {
|
|
|
813
1034
|
});
|
|
814
1035
|
}
|
|
815
1036
|
|
|
816
|
-
await ensureDeps();
|
|
817
1037
|
const transport = new StdioServerTransport();
|
|
818
1038
|
await server.connect(transport);
|