bwb-browser 4.0.1 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +31 -17
- package/README.md +76 -32
- package/lib/act.mjs +474 -215
- package/lib/browser.mjs +145 -30
- package/lib/config.mjs +180 -0
- package/lib/diagnose.mjs +13 -11
- package/lib/fetch.mjs +187 -75
- package/lib/fingerprint.mjs +73 -28
- package/lib/helpers.mjs +86 -39
- package/lib/session.mjs +34 -7
- package/lib/setup.mjs +94 -13
- package/lib/tabs.mjs +127 -15
- package/lib/urlpolicy.mjs +137 -0
- package/lib/vigil.mjs +44 -21
- package/package.json +15 -7
- package/server.mjs +418 -211
package/server.mjs
CHANGED
|
@@ -31,9 +31,8 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
31
31
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
32
32
|
import { z } from "zod";
|
|
33
33
|
import CDP from "chrome-remote-interface";
|
|
34
|
-
import {
|
|
35
|
-
import { mkdirSync, readFileSync,
|
|
36
|
-
import { homedir, platform } from "os";
|
|
34
|
+
import { execFileSync } from "child_process";
|
|
35
|
+
import { mkdirSync, readFileSync, writeFileSync, realpathSync, statSync } from "fs";
|
|
37
36
|
import { join, dirname } from "path";
|
|
38
37
|
import { fileURLToPath } from "url";
|
|
39
38
|
|
|
@@ -56,16 +55,18 @@ import {
|
|
|
56
55
|
|
|
57
56
|
import {
|
|
58
57
|
getActiveProtocol, createTab, closeTab, switchTab, listTabs, syncActiveTab, clearTabs,
|
|
59
|
-
hibernateTab,
|
|
58
|
+
hibernateTab, setPendingStatic, getPendingStatic, materializeStatic, needsMaterialize,
|
|
60
59
|
} from "./lib/tabs.mjs";
|
|
61
60
|
|
|
62
|
-
import { staticFetch } from "./lib/fetch.mjs";
|
|
61
|
+
import { staticFetch, capText } from "./lib/fetch.mjs";
|
|
63
62
|
import { sampleResources, assess, resourceFooter, resolveBudgets } from "./lib/vigil.mjs";
|
|
64
63
|
|
|
65
64
|
import { saveSession, loadSession, listSessions } from "./lib/session.mjs";
|
|
66
65
|
import { diagnosePage } from "./lib/diagnose.mjs";
|
|
67
66
|
import { applyRealisticProfile } from "./lib/fingerprint.mjs";
|
|
68
|
-
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";
|
|
69
70
|
|
|
70
71
|
// ─── Config ───────────────────────────────────────────────────────────────────
|
|
71
72
|
|
|
@@ -76,35 +77,13 @@ const { version: BWB_VERSION } = JSON.parse(
|
|
|
76
77
|
readFileSync(join(__dirname, 'package.json'), 'utf8')
|
|
77
78
|
);
|
|
78
79
|
|
|
79
|
-
function parseArgs() {
|
|
80
|
-
const args = process.argv.slice(2);
|
|
81
|
-
const cliCfg = {};
|
|
82
|
-
for (let i = 0; i < args.length; i++) {
|
|
83
|
-
switch (args[i]) {
|
|
84
|
-
case "--browser-path": cliCfg.browserPath = args[++i]; break;
|
|
85
|
-
case "--port": cliCfg.port = parseInt(args[++i], 10); break;
|
|
86
|
-
case "--user-data-dir": cliCfg.userDataDir = args[++i]; break;
|
|
87
|
-
case "--headless": cliCfg.headless = args[++i] !== "false"; break;
|
|
88
|
-
case "--screenshots-dir": cliCfg.screenshotsDir = args[++i]; break;
|
|
89
|
-
case "--timeout": cliCfg.navTimeout = parseInt(args[++i], 10); break;
|
|
90
|
-
case "--lean": cliCfg.lean = args[++i] !== "false"; break;
|
|
91
|
-
case "--nuclear": cliCfg.nuclear = args[++i] !== "false"; break;
|
|
92
|
-
case "--idle": cliCfg.idleMs = parseInt(args[++i], 10); break;
|
|
93
|
-
case "--tab-max": cliCfg.tabMax = parseInt(args[++i], 10); break;
|
|
94
|
-
case "--attach-port": cliCfg.attachPort = parseInt(args[++i], 10); break;
|
|
95
|
-
case "--version": console.log(`bwb-browser ${BWB_VERSION}`); process.exit(0);
|
|
96
|
-
case "--help": printHelp(); process.exit(0);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
return cliCfg;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
80
|
function printHelp() {
|
|
103
81
|
console.log(`
|
|
104
82
|
bwb-browser v${BWB_VERSION} — Browser Without Bloat
|
|
105
83
|
|
|
106
84
|
Browser automation for AI agents. Static-first, lean like air. 26 tools.
|
|
107
|
-
|
|
85
|
+
CDP over one thin client — no Playwright, no Puppeteer, no bundled browser.
|
|
86
|
+
Chromium starts only when JS demands it.
|
|
108
87
|
|
|
109
88
|
Built on Termux/Android. Runs everywhere — including 1GB VPS boxes.
|
|
110
89
|
|
|
@@ -124,9 +103,30 @@ OPTIONS:
|
|
|
124
103
|
--tab-max <n> Live-tab cap, oldest hibernated (default: 3 lean)
|
|
125
104
|
--attach-port <n> Attach to a running browser's CDP port (e.g. 9222).
|
|
126
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
|
|
127
111
|
--version Print version
|
|
128
112
|
--help Show this help
|
|
129
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
|
+
|
|
130
130
|
TOOLS (26):
|
|
131
131
|
CORE BROWSING:
|
|
132
132
|
browser_goto Navigate (static-first, escalates to browser)
|
|
@@ -163,7 +163,7 @@ TOOLS (26):
|
|
|
163
163
|
|
|
164
164
|
ON-DEMAND (verbs ship, weight doesn't — backends install on consent):
|
|
165
165
|
browser_download Download media (needs system yt-dlp)
|
|
166
|
-
browser_export
|
|
166
|
+
browser_export Write md/txt/html into the export dir
|
|
167
167
|
|
|
168
168
|
LIFECYCLE:
|
|
169
169
|
browser_status Status + live resources + active profile
|
|
@@ -174,70 +174,86 @@ If bwb saves you time or money, consider supporting development:
|
|
|
174
174
|
`);
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
-
// ─── Dependency Check ─────────────────────────────────────────────────────────
|
|
178
|
-
|
|
179
|
-
async function ensureDeps() {
|
|
180
|
-
const { createRequire } = await import("module");
|
|
181
|
-
const req = createRequire(import.meta.url);
|
|
182
|
-
const needed = [
|
|
183
|
-
"@modelcontextprotocol/sdk/server/mcp.js",
|
|
184
|
-
"zod",
|
|
185
|
-
"chrome-remote-interface",
|
|
186
|
-
];
|
|
187
|
-
const missing = [];
|
|
188
|
-
for (const spec of needed) {
|
|
189
|
-
try { req.resolve(spec); } catch {
|
|
190
|
-
missing.push(spec.split("/")[0].split("@")[0] || spec);
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
if (missing.length > 0) {
|
|
194
|
-
console.error(
|
|
195
|
-
`\nMissing dependencies: ${missing.join(", ")}\n` +
|
|
196
|
-
`Run: npm install -g bwb-browser\n` +
|
|
197
|
-
`Or: cd "${__dirname}" && npm install\n` +
|
|
198
|
-
`Or: npx bwb-browser\n`
|
|
199
|
-
);
|
|
200
|
-
process.exit(1);
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
|
|
204
177
|
// ─── Apply Config ────────────────────────────────────────────────────────────
|
|
205
178
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
if (process.env.HOME?.includes("com.termux")) return androidPath;
|
|
216
|
-
if (process.env.TERMUX_VERSION) return androidPath;
|
|
217
|
-
return join(homedir(), "bwb-screenshots");
|
|
218
|
-
})();
|
|
219
|
-
cfg.navTimeout = cfg.navTimeout || parseInt(process.env.BWB_NAV_TIMEOUT || "30000", 10);
|
|
220
|
-
// v4 survival defaults: lean auto-detects Termux; mayfly + tab cap follow lean
|
|
221
|
-
// unless explicitly overridden. Desktop behavior unchanged (all off).
|
|
222
|
-
if (cfg.lean === null || cfg.lean === undefined) {
|
|
223
|
-
if (process.env.BWB_LEAN !== undefined) cfg.lean = process.env.BWB_LEAN !== "false";
|
|
224
|
-
else cfg.lean = isTermux();
|
|
225
|
-
}
|
|
226
|
-
if (cfg.nuclear === undefined || cfg.nuclear === null) {
|
|
227
|
-
cfg.nuclear = process.env.BWB_NUCLEAR === "true";
|
|
228
|
-
}
|
|
229
|
-
if (cfg.idleMs === null || cfg.idleMs === undefined) {
|
|
230
|
-
if (process.env.BWB_IDLE_MS !== undefined) cfg.idleMs = parseInt(process.env.BWB_IDLE_MS, 10);
|
|
231
|
-
else cfg.idleMs = cfg.lean ? 5 * 60 * 1000 : 0;
|
|
232
|
-
}
|
|
233
|
-
if (cfg.tabMax === null || cfg.tabMax === undefined) {
|
|
234
|
-
if (process.env.BWB_TAB_MAX !== undefined) cfg.tabMax = parseInt(process.env.BWB_TAB_MAX, 10);
|
|
235
|
-
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;
|
|
236
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() }));
|
|
237
193
|
resolveBudgets(cfg.lean);
|
|
238
194
|
|
|
239
195
|
try { mkdirSync(cfg.screenshotsDir, { recursive: true }); } catch {}
|
|
240
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
|
+
|
|
241
257
|
// ─── Watch State (Live Page Event Capture) ─────────────────────────────────────
|
|
242
258
|
|
|
243
259
|
const WATCH_MAX_EVENTS = 5000;
|
|
@@ -256,33 +272,39 @@ function cleanupWatch() {
|
|
|
256
272
|
watchState.events = [];
|
|
257
273
|
}
|
|
258
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.
|
|
259
279
|
function setupWatch(events, cdp) {
|
|
260
280
|
cleanupWatch();
|
|
261
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);
|
|
262
284
|
|
|
263
|
-
if (
|
|
264
|
-
cdp.Runtime.consoleAPICalled((params) => {
|
|
285
|
+
if (want("console")) {
|
|
286
|
+
keep(cdp.Runtime.consoleAPICalled((params) => {
|
|
265
287
|
watchPush({ type: "console", timestamp: Date.now(), level: params.type || "log",
|
|
266
288
|
text: (params.args || []).map(a => a.value !== undefined ? String(a.value) : a.description || "").join(" ") });
|
|
267
|
-
});
|
|
268
|
-
cdp.Runtime.exceptionThrown((params) => {
|
|
289
|
+
}));
|
|
290
|
+
keep(cdp.Runtime.exceptionThrown((params) => {
|
|
269
291
|
const d = params.exceptionDetails;
|
|
270
292
|
watchPush({ type: "exception", timestamp: Date.now(), text: d?.exception?.description || d?.text || "Unknown exception" });
|
|
271
|
-
});
|
|
293
|
+
}));
|
|
272
294
|
}
|
|
273
|
-
if (
|
|
274
|
-
cdp.Network.requestWillBeSent((params) => {
|
|
295
|
+
if (want("network")) {
|
|
296
|
+
keep(cdp.Network.requestWillBeSent((params) => {
|
|
275
297
|
watchPush({ type: "network", timestamp: Date.now(), subtype: "request", url: params.request?.url || "", method: params.request?.method || "GET" });
|
|
276
|
-
});
|
|
277
|
-
cdp.Network.responseReceived((params) => {
|
|
298
|
+
}));
|
|
299
|
+
keep(cdp.Network.responseReceived((params) => {
|
|
278
300
|
if (params.response?.url?.startsWith("data:")) return;
|
|
279
301
|
watchPush({ type: "network", timestamp: Date.now(), subtype: "response", url: params.response?.url || "", status: params.response?.status || 0, mimeType: params.response?.mimeType || "" });
|
|
280
|
-
});
|
|
302
|
+
}));
|
|
281
303
|
}
|
|
282
|
-
if (
|
|
283
|
-
cdp.Page.frameNavigated((params) => {
|
|
304
|
+
if (want("navigation")) {
|
|
305
|
+
keep(cdp.Page.frameNavigated((params) => {
|
|
284
306
|
watchPush({ type: "navigation", timestamp: Date.now(), url: params.frame?.url || "" });
|
|
285
|
-
});
|
|
307
|
+
}));
|
|
286
308
|
}
|
|
287
309
|
}
|
|
288
310
|
|
|
@@ -290,34 +312,94 @@ function setupWatch(events, cdp) {
|
|
|
290
312
|
|
|
291
313
|
const server = new McpServer({ name: "bwb-browser", version: BWB_VERSION });
|
|
292
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
|
+
|
|
293
329
|
// Tool implementations
|
|
294
330
|
const tools = {
|
|
295
331
|
// ═══════════════ CORE BROWSING ═══════════════
|
|
296
332
|
|
|
297
333
|
browser_goto: {
|
|
298
|
-
description: "
|
|
299
|
-
schema: {
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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);
|
|
310
346
|
}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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 }) }] };
|
|
314
399
|
}
|
|
315
|
-
|
|
316
|
-
const
|
|
317
|
-
|
|
318
|
-
const result = await gotoUrl(Page, Runtime, url, cfg.navTimeout);
|
|
319
|
-
syncActiveTab(result.title, result.url);
|
|
320
|
-
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) }] };
|
|
321
403
|
},
|
|
322
404
|
},
|
|
323
405
|
|
|
@@ -368,28 +450,43 @@ const tools = {
|
|
|
368
450
|
},
|
|
369
451
|
|
|
370
452
|
browser_html: {
|
|
371
|
-
description: "
|
|
372
|
-
schema: {
|
|
373
|
-
|
|
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 }) => {
|
|
374
459
|
const { Runtime } = await getActiveProtocol();
|
|
375
460
|
const expr = selector
|
|
376
461
|
? `document.querySelector(${JSON.stringify(selector)})?.outerHTML || ''`
|
|
377
462
|
: "document.documentElement.outerHTML";
|
|
378
463
|
const { result } = await Runtime.evaluate({ expression: expr });
|
|
379
|
-
|
|
464
|
+
const html = result?.value || "";
|
|
465
|
+
return { content: [{ type: "text", text: capText(html, maxChars).text }] };
|
|
380
466
|
},
|
|
381
467
|
},
|
|
382
468
|
|
|
383
469
|
browser_text: {
|
|
384
|
-
description: "
|
|
385
|
-
schema: {
|
|
386
|
-
|
|
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 }) => {
|
|
387
476
|
const { Runtime } = await getActiveProtocol();
|
|
477
|
+
// innerText, not textContent: textContent includes <script>/<style>
|
|
478
|
+
// bodies and display:none subtrees, which is not "visible text".
|
|
388
479
|
const expr = selector
|
|
389
|
-
? `document.querySelector(${JSON.stringify(selector)})?.
|
|
390
|
-
:
|
|
480
|
+
? `(document.querySelector(${JSON.stringify(selector)})?.innerText ?? '')`
|
|
481
|
+
: `(document.body?.innerText ?? document.body?.textContent ?? '')`;
|
|
391
482
|
const { result } = await Runtime.evaluate({ expression: expr });
|
|
392
|
-
|
|
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
|
+
};
|
|
393
490
|
},
|
|
394
491
|
},
|
|
395
492
|
|
|
@@ -418,24 +515,35 @@ const tools = {
|
|
|
418
515
|
// ═══════════════ INTERACTION ═══════════════
|
|
419
516
|
|
|
420
517
|
browser_click: {
|
|
421
|
-
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).",
|
|
422
519
|
schema: { selector: z.string().describe("CSS selector") },
|
|
423
520
|
handler: async ({ selector }) => {
|
|
424
521
|
const cdp = await getActiveProtocol();
|
|
425
522
|
const { Page, Runtime, Input } = cdp;
|
|
426
523
|
const info = await clickElement(Page, Runtime, Input, selector);
|
|
427
|
-
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
|
+
}) }] };
|
|
428
529
|
},
|
|
429
530
|
},
|
|
430
531
|
|
|
431
532
|
browser_fill: {
|
|
432
|
-
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.",
|
|
433
534
|
schema: { selector: z.string().describe("CSS selector for input"), text: z.string().describe("Text to fill") },
|
|
434
535
|
handler: async ({ selector, text }) => {
|
|
435
536
|
const cdp = await getActiveProtocol();
|
|
436
537
|
const { Page, Runtime, Input } = cdp;
|
|
437
|
-
await fillElement(Page, Runtime, Input, selector, text);
|
|
438
|
-
|
|
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 }) }] };
|
|
439
547
|
},
|
|
440
548
|
},
|
|
441
549
|
|
|
@@ -462,28 +570,46 @@ const tools = {
|
|
|
462
570
|
},
|
|
463
571
|
|
|
464
572
|
browser_eval: {
|
|
465
|
-
description: "
|
|
466
|
-
schema: {
|
|
467
|
-
|
|
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 }) => {
|
|
468
579
|
const { Runtime } = await getActiveProtocol();
|
|
469
|
-
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
|
+
});
|
|
470
589
|
if (response.exceptionDetails) {
|
|
471
590
|
const exc = response.exceptionDetails;
|
|
472
591
|
throw new Error(`JS Error: ${exc.exception?.description || exc.text || "Unknown JS error"}`);
|
|
473
592
|
}
|
|
474
593
|
const { result } = response;
|
|
475
|
-
|
|
594
|
+
let value = result?.value;
|
|
595
|
+
if (value === undefined && result?.description) value = result.description;
|
|
596
|
+
return { content: [{ type: "text", text: JSON.stringify(value ?? null) }] };
|
|
476
597
|
},
|
|
477
598
|
},
|
|
478
599
|
|
|
479
600
|
browser_setViewport: {
|
|
480
|
-
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.",
|
|
481
602
|
schema: {
|
|
482
|
-
width: z.number().min(320).max(7680).describe("Viewport width in pixels (default
|
|
483
|
-
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(),
|
|
484
606
|
},
|
|
485
|
-
handler: async ({ width = 1280, height = 720 }) => {
|
|
607
|
+
handler: async ({ width = 1280, height = 720, reset = false }) => {
|
|
486
608
|
const { Emulation } = await getActiveProtocol();
|
|
609
|
+
if (reset) {
|
|
610
|
+
await Emulation.clearDeviceMetricsOverride();
|
|
611
|
+
return { content: [{ type: "text", text: JSON.stringify({ viewport: "reset" }) }] };
|
|
612
|
+
}
|
|
487
613
|
await Emulation.setDeviceMetricsOverride({ width, height, deviceScaleFactor: 1, mobile: false });
|
|
488
614
|
return { content: [{ type: "text", text: JSON.stringify({ viewport: `${width}x${height}` }) }] };
|
|
489
615
|
},
|
|
@@ -492,18 +618,34 @@ const tools = {
|
|
|
492
618
|
// ═══════════════ 🔥 ADVANCED ═══════════════
|
|
493
619
|
|
|
494
620
|
browser_act: {
|
|
495
|
-
description: "
|
|
496
|
-
schema: {
|
|
497
|
-
|
|
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 }) => {
|
|
498
627
|
const cdp = await getActiveProtocol();
|
|
499
|
-
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
|
+
});
|
|
500
642
|
if (result.url) syncActiveTab(result.title, result.url);
|
|
501
643
|
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
502
644
|
},
|
|
503
645
|
},
|
|
504
646
|
|
|
505
647
|
browser_watch: {
|
|
506
|
-
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.",
|
|
507
649
|
schema: {
|
|
508
650
|
action: z.enum(["start", "poll", "stop"]).describe("start=begin recording, poll=get events since last poll, stop=cleanup"),
|
|
509
651
|
events: z.array(z.enum(["console", "network", "navigation", "all"])).describe("Event types to capture (default: all)").optional(),
|
|
@@ -513,6 +655,9 @@ const tools = {
|
|
|
513
655
|
const cdp = await getActiveProtocol();
|
|
514
656
|
await cdp.Runtime.enable();
|
|
515
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?.();
|
|
516
661
|
setupWatch(events, cdp);
|
|
517
662
|
setIdleSuppressed(true); // recording in progress — mayfly must not teardown
|
|
518
663
|
return { content: [{ type: "text", text: JSON.stringify({ status: "watching", events, msg: "Recording started. Poll to get events." }) }] };
|
|
@@ -536,17 +681,17 @@ const tools = {
|
|
|
536
681
|
},
|
|
537
682
|
|
|
538
683
|
browser_diagnose: {
|
|
539
|
-
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.",
|
|
540
685
|
schema: {},
|
|
541
686
|
handler: async () => {
|
|
542
687
|
const cdp = await getActiveProtocol();
|
|
543
|
-
const report = await diagnosePage(cdp);
|
|
688
|
+
const report = await diagnosePage(cdp, { keepRuntimeEnabled: watchState.active });
|
|
544
689
|
return { content: [{ type: "text", text: JSON.stringify(report) }] };
|
|
545
690
|
},
|
|
546
691
|
},
|
|
547
692
|
|
|
548
693
|
browser_fingerprint: {
|
|
549
|
-
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.",
|
|
550
695
|
schema: {},
|
|
551
696
|
handler: async () => {
|
|
552
697
|
const cdp = await getActiveProtocol();
|
|
@@ -611,22 +756,28 @@ const tools = {
|
|
|
611
756
|
// ═══════════════ SESSION ═══════════════
|
|
612
757
|
|
|
613
758
|
browser_saveCookies: {
|
|
614
|
-
description: "Save
|
|
615
|
-
schema: {
|
|
616
|
-
|
|
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 }) => {
|
|
617
765
|
const cdp = await getActiveProtocol();
|
|
618
|
-
const result = await saveSession(name, cdp);
|
|
766
|
+
const result = await saveSession(name, cdp, { domains });
|
|
619
767
|
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
620
768
|
},
|
|
621
769
|
},
|
|
622
770
|
|
|
623
771
|
browser_loadCookies: {
|
|
624
|
-
description: "Load a saved
|
|
625
|
-
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')") },
|
|
626
774
|
handler: async ({ name }) => {
|
|
627
775
|
const cdp = await getActiveProtocol();
|
|
628
776
|
const result = await loadSession(name, cdp);
|
|
629
|
-
|
|
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 }) }] };
|
|
630
781
|
},
|
|
631
782
|
},
|
|
632
783
|
|
|
@@ -644,20 +795,26 @@ const tools = {
|
|
|
644
795
|
// bundled — probed at call time, installed only on explicit user consent.
|
|
645
796
|
|
|
646
797
|
browser_download: {
|
|
647
|
-
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.",
|
|
648
799
|
schema: {
|
|
649
|
-
url: z.string().describe("Media URL"),
|
|
800
|
+
url: z.string().describe("Media URL (http/https)"),
|
|
650
801
|
format: z.enum(["best", "audio", "video", "subtitles", "thumbnail"]).describe("What to download").optional(),
|
|
651
802
|
quality: z.enum(["best", "good", "worst"]).describe("Quality tier").optional(),
|
|
652
803
|
},
|
|
653
804
|
handler: async ({ url, format = "best", quality = "best" }) => {
|
|
654
|
-
//
|
|
655
|
-
|
|
656
|
-
|
|
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");
|
|
657
814
|
}
|
|
658
815
|
let hasYtDlp = false;
|
|
659
816
|
try {
|
|
660
|
-
|
|
817
|
+
execFileSync("yt-dlp", ["--version"], { stdio: "ignore", timeout: 5000 });
|
|
661
818
|
hasYtDlp = true;
|
|
662
819
|
} catch {}
|
|
663
820
|
if (!hasYtDlp) {
|
|
@@ -680,9 +837,11 @@ const tools = {
|
|
|
680
837
|
else if (format === "thumbnail") args.push("--write-thumbnail", "--skip-download");
|
|
681
838
|
if (quality === "worst") args.push("-f", "worst");
|
|
682
839
|
else if (quality === "good") args.push("-f", "best[height<=720]");
|
|
683
|
-
|
|
840
|
+
// `--` ends option parsing so a URL can never be read as a yt-dlp flag.
|
|
841
|
+
args.push("--", target.href);
|
|
684
842
|
try {
|
|
685
|
-
|
|
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 });
|
|
686
845
|
const file = out.trim().split("\n").pop();
|
|
687
846
|
return { content: [{ type: "text", text: JSON.stringify({ downloaded: file, format, quality }) }] };
|
|
688
847
|
} catch (err) {
|
|
@@ -692,45 +851,26 @@ const tools = {
|
|
|
692
851
|
},
|
|
693
852
|
|
|
694
853
|
browser_export: {
|
|
695
|
-
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.",
|
|
696
855
|
schema: {
|
|
697
856
|
text: z.string().describe("Content to export (markdown accepted)"),
|
|
698
|
-
format: z.enum(["md", "txt", "html"
|
|
699
|
-
|
|
700
|
-
title: z.string().describe("Document title").optional(),
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
const dest =
|
|
705
|
-
if (
|
|
706
|
-
try { wfs(dest, text, "utf8"); } catch (err) {
|
|
707
|
-
return { content: [{ type: "text", text: JSON.stringify({ error: `write failed: ${err.message}` }) }] };
|
|
708
|
-
}
|
|
709
|
-
return { content: [{ type: "text", text: JSON.stringify({ exported: dest, format }) }] };
|
|
710
|
-
}
|
|
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);
|
|
711
865
|
if (format === "html") {
|
|
712
866
|
const esc = text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
// pdf/docx/pptx need python libs — probe, then consent-gate.
|
|
719
|
-
const need = { pdf: "reportlab", docx: "python-docx", pptx: "python-pptx" }[format];
|
|
720
|
-
let have = false;
|
|
721
|
-
try {
|
|
722
|
-
execSync(`python3 -c "import ${need.split("-").join("_")}"`, { stdio: "ignore", timeout: 10000 });
|
|
723
|
-
have = true;
|
|
724
|
-
} catch {}
|
|
725
|
-
if (!have) {
|
|
726
|
-
return { content: [{ type: "text", text: JSON.stringify({
|
|
727
|
-
needsInstall: true,
|
|
728
|
-
tool: need,
|
|
729
|
-
install: `pip install ${need}`,
|
|
730
|
-
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.`,
|
|
731
|
-
}) }] };
|
|
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");
|
|
732
872
|
}
|
|
733
|
-
return { content: [{ type: "text", text: JSON.stringify({
|
|
873
|
+
return { content: [{ type: "text", text: JSON.stringify({ exported: dest.path, format }) }] };
|
|
734
874
|
},
|
|
735
875
|
},
|
|
736
876
|
|
|
@@ -770,28 +910,82 @@ const tools = {
|
|
|
770
910
|
},
|
|
771
911
|
|
|
772
912
|
browser_restart: {
|
|
773
|
-
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.",
|
|
774
914
|
schema: {},
|
|
775
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;
|
|
776
921
|
clearTabs(); // Kill stale tab connections before restart
|
|
777
922
|
cleanupWatch(); // Detach event listeners from the dying protocol before it's gone
|
|
778
923
|
const result = await restartBrowser();
|
|
779
|
-
|
|
924
|
+
if (resume) setPendingStatic(resume, active.title);
|
|
925
|
+
return { content: [{ type: "text", text: JSON.stringify({ ...result, resuming: resume }) }] };
|
|
780
926
|
},
|
|
781
927
|
},
|
|
782
928
|
};
|
|
783
929
|
|
|
784
930
|
// ─── Register & Start ─────────────────────────────────────────────────────────
|
|
785
931
|
|
|
786
|
-
//
|
|
787
|
-
//
|
|
788
|
-
|
|
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
|
+
|
|
789
948
|
for (const [name, tool] of Object.entries(tools)) {
|
|
790
949
|
const inner = tool.handler;
|
|
791
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
|
+
|
|
792
978
|
let result;
|
|
793
979
|
try {
|
|
794
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
|
+
}
|
|
795
989
|
} finally {
|
|
796
990
|
try { pokeActivity(); } catch {}
|
|
797
991
|
}
|
|
@@ -813,10 +1007,24 @@ for (const [name, tool] of Object.entries(tools)) {
|
|
|
813
1007
|
Object.assign(sample, after);
|
|
814
1008
|
if (assess(sample) !== "critical") break;
|
|
815
1009
|
}
|
|
816
|
-
|
|
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) {
|
|
817
1020
|
try { await stopBrowser("oom-guard"); } catch {}
|
|
818
|
-
|
|
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`;
|
|
819
1025
|
}
|
|
1026
|
+
} else {
|
|
1027
|
+
criticalStreak = 0;
|
|
820
1028
|
}
|
|
821
1029
|
if (result && Array.isArray(result.content)) {
|
|
822
1030
|
result.content.push({ type: "text", text: resourceFooter(sample, note) });
|
|
@@ -826,6 +1034,5 @@ for (const [name, tool] of Object.entries(tools)) {
|
|
|
826
1034
|
});
|
|
827
1035
|
}
|
|
828
1036
|
|
|
829
|
-
await ensureDeps();
|
|
830
1037
|
const transport = new StdioServerTransport();
|
|
831
1038
|
await server.connect(transport);
|