barebrowse 0.7.0 → 0.9.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/CHANGELOG.md +227 -0
- package/LICENSE +202 -21
- package/NOTICE +8 -0
- package/README.md +37 -10
- package/barebrowse.context.md +43 -18
- package/cli.js +114 -3
- package/mcp-server.js +302 -82
- package/package.json +2 -2
- package/src/bareagent.js +33 -0
- package/src/chromium.js +115 -5
- package/src/consent.js +3 -8
- package/src/daemon.js +13 -0
- package/src/index.js +429 -132
- package/src/network-idle.js +62 -0
- package/src/stealth.js +87 -6
- package/.aurora/plans/active/harden-assess/design.md +0 -68
- package/.aurora/plans/active/harden-assess/plan.md +0 -71
- package/.aurora/plans/active/harden-assess/prd.md +0 -38
package/mcp-server.js
CHANGED
|
@@ -10,8 +10,47 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { browse, connect } from './src/index.js';
|
|
13
|
-
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
14
|
-
import { join } from 'node:path';
|
|
13
|
+
import { mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
|
14
|
+
import { join, dirname } from 'node:path';
|
|
15
|
+
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
16
|
+
|
|
17
|
+
// Read version from package.json so serverInfo.version doesn't drift behind
|
|
18
|
+
// release bumps (pre-fix this was hardcoded 0.7.1 while package.json was 0.8.0).
|
|
19
|
+
const _pkgPath = join(dirname(fileURLToPath(import.meta.url)), 'package.json');
|
|
20
|
+
const PKG_VERSION = JSON.parse(readFileSync(_pkgPath, 'utf8')).version;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Per-tool timeouts (ms). One blanket 30s was too short for SPA cold loads
|
|
24
|
+
* (goto regularly exceeded it on slow sites) and too long for instant ops
|
|
25
|
+
* like scroll. The split below is the H5 plan:
|
|
26
|
+
* - navigation (goto/reload): 60s
|
|
27
|
+
* - browser-history nav (back/forward): 30s
|
|
28
|
+
* - interactive ops (click/type/press/scroll/hover/select/drag): 15s
|
|
29
|
+
* - read-only ops (snapshot/tabs/eval/wait_for): 15s (wait_for has its own
|
|
30
|
+
* internal deadline; this is the outer cap)
|
|
31
|
+
* - heavy I/O (pdf/screenshot/upload): 45s
|
|
32
|
+
* Exported so tests can pin the contract.
|
|
33
|
+
*/
|
|
34
|
+
export const TIMEOUTS = {
|
|
35
|
+
goto: 60000,
|
|
36
|
+
reload: 60000,
|
|
37
|
+
back: 30000,
|
|
38
|
+
forward: 30000,
|
|
39
|
+
snapshot: 15000,
|
|
40
|
+
click: 15000,
|
|
41
|
+
type: 15000,
|
|
42
|
+
press: 15000,
|
|
43
|
+
scroll: 15000,
|
|
44
|
+
hover: 15000,
|
|
45
|
+
select: 15000,
|
|
46
|
+
drag: 15000,
|
|
47
|
+
tabs: 5000,
|
|
48
|
+
eval: 15000,
|
|
49
|
+
wait_for: 60000,
|
|
50
|
+
upload: 45000,
|
|
51
|
+
pdf: 45000,
|
|
52
|
+
screenshot: 45000,
|
|
53
|
+
};
|
|
15
54
|
|
|
16
55
|
// Optional: privacy assessment via wearehere
|
|
17
56
|
let assessFn = null;
|
|
@@ -20,20 +59,43 @@ try {
|
|
|
20
59
|
} catch {}
|
|
21
60
|
|
|
22
61
|
|
|
23
|
-
function
|
|
62
|
+
function isTransient(err) {
|
|
24
63
|
const m = err.message || '';
|
|
25
|
-
return m.includes('WebSocket') || m.includes('Target closed') || m.includes('Session closed')
|
|
64
|
+
return m.includes('WebSocket') || m.includes('Target closed') || m.includes('Session closed')
|
|
65
|
+
|| m.includes('CDP') || m.includes('Timeout waiting for CDP event') || m.includes('timed out');
|
|
26
66
|
}
|
|
27
67
|
|
|
28
|
-
/**
|
|
29
|
-
|
|
68
|
+
/**
|
|
69
|
+
* Run fn with a per-attempt timeout. On transient failure (CDP death OR
|
|
70
|
+
* timeout), reset the session. If `retry` is true (default), retry once on
|
|
71
|
+
* a fresh page; if false, rethrow without retrying — required for
|
|
72
|
+
* non-idempotent ops (click/type/etc.) where a partial first attempt
|
|
73
|
+
* shouldn't be replayed against a blank fresh page.
|
|
74
|
+
* @param {Function} fn - async function to execute
|
|
75
|
+
* @param {number} timeoutMs - per-attempt timeout in ms
|
|
76
|
+
* @param {object} [opts]
|
|
77
|
+
* @param {boolean} [opts.retry=true] - whether to retry once on transient failure
|
|
78
|
+
*/
|
|
79
|
+
async function withRetry(fn, timeoutMs, { retry = true } = {}) {
|
|
80
|
+
async function attempt() {
|
|
81
|
+
if (!timeoutMs) return await fn();
|
|
82
|
+
let timer;
|
|
83
|
+
const result = await Promise.race([
|
|
84
|
+
fn(),
|
|
85
|
+
new Promise((_, rej) => { timer = setTimeout(() => rej(new Error(`timed out after ${timeoutMs / 1000}s`)), timeoutMs); }),
|
|
86
|
+
]);
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
|
|
30
91
|
try {
|
|
31
|
-
return await
|
|
92
|
+
return await attempt();
|
|
32
93
|
} catch (err) {
|
|
33
|
-
if (!
|
|
34
|
-
//
|
|
94
|
+
if (!isTransient(err)) throw err;
|
|
95
|
+
// Transient failure — reset session so the next request gets a fresh page.
|
|
35
96
|
_page = null;
|
|
36
|
-
|
|
97
|
+
if (!retry) throw err;
|
|
98
|
+
return await attempt();
|
|
37
99
|
}
|
|
38
100
|
}
|
|
39
101
|
|
|
@@ -79,10 +141,10 @@ function acquireAssessSlot() {
|
|
|
79
141
|
}
|
|
80
142
|
|
|
81
143
|
|
|
82
|
-
const TOOLS = [
|
|
144
|
+
export const TOOLS = [
|
|
83
145
|
{
|
|
84
146
|
name: 'browse',
|
|
85
|
-
description: '
|
|
147
|
+
description: 'One-shot headless browse — fetches a URL through a real browser (executes JS, injects cookies, dismisses consent, evades bot detection). Only when plain HTTP fetch can\'t render the page. Returns a pruned ARIA snapshot with [ref=N] markers. Stateless — for multi-step interaction use goto.',
|
|
86
148
|
inputSchema: {
|
|
87
149
|
type: 'object',
|
|
88
150
|
properties: {
|
|
@@ -95,7 +157,7 @@ const TOOLS = [
|
|
|
95
157
|
},
|
|
96
158
|
{
|
|
97
159
|
name: 'goto',
|
|
98
|
-
description: '
|
|
160
|
+
description: 'Open URL in a persistent interactive browser session (pair with snapshot/click/type/press for multi-step flows). Use when the task needs clicking, typing, or form submission. Injects auth cookies. Returns ok — call snapshot to observe.',
|
|
99
161
|
inputSchema: {
|
|
100
162
|
type: 'object',
|
|
101
163
|
properties: {
|
|
@@ -204,8 +266,92 @@ const TOOLS = [
|
|
|
204
266
|
},
|
|
205
267
|
},
|
|
206
268
|
},
|
|
269
|
+
{
|
|
270
|
+
name: 'reload',
|
|
271
|
+
description: 'Reload the current page in the session. Returns ok — call snapshot to observe.',
|
|
272
|
+
inputSchema: {
|
|
273
|
+
type: 'object',
|
|
274
|
+
properties: {
|
|
275
|
+
ignoreCache: { type: 'boolean', description: 'Bypass HTTP cache (hard reload). Default: false.' },
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
name: 'screenshot',
|
|
281
|
+
description: 'Capture a screenshot of the current page. Saves to .barebrowse/screenshot-*.png (or .jpeg/.webp) and returns the file path. Use the file with your image tools.',
|
|
282
|
+
inputSchema: {
|
|
283
|
+
type: 'object',
|
|
284
|
+
properties: {
|
|
285
|
+
format: { type: 'string', enum: ['png', 'jpeg', 'webp'], description: 'Image format (default: png)' },
|
|
286
|
+
quality: { type: 'number', description: 'JPEG/WebP quality 0-100 (default: 80, ignored for PNG)' },
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
name: 'wait_for',
|
|
292
|
+
description: 'Wait for visible text or a CSS selector to appear on the current page. Returns ok when found, throws on timeout.',
|
|
293
|
+
inputSchema: {
|
|
294
|
+
type: 'object',
|
|
295
|
+
properties: {
|
|
296
|
+
text: { type: 'string', description: 'Substring that must appear in document.body.innerText' },
|
|
297
|
+
selector: { type: 'string', description: 'CSS selector that must match document.querySelector' },
|
|
298
|
+
timeout: { type: 'number', description: 'Timeout in ms (default: 30000)' },
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
name: 'tabs',
|
|
304
|
+
description: 'List open tabs in the session, or switch to one by index. Returns JSON array of { index, url, title } or "ok" after switch.',
|
|
305
|
+
inputSchema: {
|
|
306
|
+
type: 'object',
|
|
307
|
+
properties: {
|
|
308
|
+
switchTo: { type: 'number', description: 'Tab index to activate. Omit to just list tabs.' },
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
name: 'select',
|
|
314
|
+
description: 'Set the value of a <select> dropdown (or custom listbox) by ref. Returns ok.',
|
|
315
|
+
inputSchema: {
|
|
316
|
+
type: 'object',
|
|
317
|
+
properties: {
|
|
318
|
+
ref: { type: 'string', description: 'Element ref from snapshot' },
|
|
319
|
+
value: { type: 'string', description: 'Option value or visible text to select' },
|
|
320
|
+
},
|
|
321
|
+
required: ['ref', 'value'],
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
name: 'hover',
|
|
326
|
+
description: 'Hover over an element by ref (triggers tooltips, hover menus). Returns ok.',
|
|
327
|
+
inputSchema: {
|
|
328
|
+
type: 'object',
|
|
329
|
+
properties: {
|
|
330
|
+
ref: { type: 'string', description: 'Element ref from snapshot' },
|
|
331
|
+
},
|
|
332
|
+
required: ['ref'],
|
|
333
|
+
},
|
|
334
|
+
},
|
|
207
335
|
];
|
|
208
336
|
|
|
337
|
+
// Powerful escape hatch — guarded behind an explicit env-var opt-in.
|
|
338
|
+
// Runtime.evaluate in the user's authenticated session lets an agent read
|
|
339
|
+
// cookies/localStorage, dispatch arbitrary events, hit any endpoint, etc.
|
|
340
|
+
// Off by default; flip BAREBROWSE_MCP_EVAL=1 to enable.
|
|
341
|
+
if (process.env.BAREBROWSE_MCP_EVAL === '1') {
|
|
342
|
+
TOOLS.push({
|
|
343
|
+
name: 'eval',
|
|
344
|
+
description: 'Run a JavaScript expression in the current page and return the result. POWERFUL: full access to the authenticated session — DOM, cookies, localStorage, fetch. Enabled because BAREBROWSE_MCP_EVAL=1 is set.',
|
|
345
|
+
inputSchema: {
|
|
346
|
+
type: 'object',
|
|
347
|
+
properties: {
|
|
348
|
+
expression: { type: 'string', description: 'JavaScript expression to evaluate' },
|
|
349
|
+
},
|
|
350
|
+
required: ['expression'],
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
209
355
|
// Add assess tool if wearehere is installed
|
|
210
356
|
if (assessFn) {
|
|
211
357
|
TOOLS.push({
|
|
@@ -226,7 +372,12 @@ if (assessFn) {
|
|
|
226
372
|
async function handleToolCall(name, args) {
|
|
227
373
|
switch (name) {
|
|
228
374
|
case 'browse': {
|
|
229
|
-
|
|
375
|
+
let timer;
|
|
376
|
+
const text = await Promise.race([
|
|
377
|
+
browse(args.url, { mode: args.mode }),
|
|
378
|
+
new Promise((_, rej) => { timer = setTimeout(() => rej(new Error('browse timed out after 60s')), 60000); }),
|
|
379
|
+
]);
|
|
380
|
+
clearTimeout(timer);
|
|
230
381
|
const limit = args.maxChars ?? MAX_CHARS_DEFAULT;
|
|
231
382
|
if (text.length > limit) {
|
|
232
383
|
const file = saveSnapshot(text);
|
|
@@ -239,7 +390,7 @@ async function handleToolCall(name, args) {
|
|
|
239
390
|
try { await page.injectCookies(args.url); } catch {}
|
|
240
391
|
await page.goto(args.url);
|
|
241
392
|
return 'ok';
|
|
242
|
-
});
|
|
393
|
+
}, TIMEOUTS.goto);
|
|
243
394
|
case 'snapshot': return withRetry(async () => {
|
|
244
395
|
const page = await getPage();
|
|
245
396
|
const text = await page.snapshot();
|
|
@@ -249,22 +400,22 @@ async function handleToolCall(name, args) {
|
|
|
249
400
|
return `Snapshot (${text.length} chars) saved to ${file}`;
|
|
250
401
|
}
|
|
251
402
|
return text;
|
|
252
|
-
});
|
|
403
|
+
}, TIMEOUTS.snapshot);
|
|
253
404
|
case 'click': return withRetry(async () => {
|
|
254
405
|
const page = await getPage();
|
|
255
406
|
await page.click(args.ref);
|
|
256
407
|
return 'ok';
|
|
257
|
-
});
|
|
408
|
+
}, TIMEOUTS.click, { retry: false });
|
|
258
409
|
case 'type': return withRetry(async () => {
|
|
259
410
|
const page = await getPage();
|
|
260
411
|
await page.type(args.ref, args.text, { clear: args.clear });
|
|
261
412
|
return 'ok';
|
|
262
|
-
});
|
|
413
|
+
}, TIMEOUTS.type, { retry: false });
|
|
263
414
|
case 'press': return withRetry(async () => {
|
|
264
415
|
const page = await getPage();
|
|
265
416
|
await page.press(args.key);
|
|
266
417
|
return 'ok';
|
|
267
|
-
});
|
|
418
|
+
}, TIMEOUTS.press, { retry: false });
|
|
268
419
|
case 'scroll': return withRetry(async () => {
|
|
269
420
|
const page = await getPage();
|
|
270
421
|
let dy = args.deltaY;
|
|
@@ -276,31 +427,90 @@ async function handleToolCall(name, args) {
|
|
|
276
427
|
}
|
|
277
428
|
await page.scroll(dy);
|
|
278
429
|
return 'ok';
|
|
279
|
-
});
|
|
430
|
+
}, TIMEOUTS.scroll, { retry: false });
|
|
280
431
|
case 'back': return withRetry(async () => {
|
|
281
432
|
const page = await getPage();
|
|
282
433
|
await page.goBack();
|
|
283
434
|
return 'ok';
|
|
284
|
-
});
|
|
435
|
+
}, TIMEOUTS.back, { retry: false });
|
|
285
436
|
case 'forward': return withRetry(async () => {
|
|
286
437
|
const page = await getPage();
|
|
287
438
|
await page.goForward();
|
|
288
439
|
return 'ok';
|
|
289
|
-
});
|
|
440
|
+
}, TIMEOUTS.forward, { retry: false });
|
|
290
441
|
case 'drag': return withRetry(async () => {
|
|
291
442
|
const page = await getPage();
|
|
292
443
|
await page.drag(args.fromRef, args.toRef);
|
|
293
444
|
return 'ok';
|
|
294
|
-
});
|
|
445
|
+
}, TIMEOUTS.drag, { retry: false });
|
|
295
446
|
case 'upload': return withRetry(async () => {
|
|
296
447
|
const page = await getPage();
|
|
297
448
|
await page.upload(args.ref, args.files);
|
|
298
449
|
return 'ok';
|
|
299
|
-
});
|
|
450
|
+
}, TIMEOUTS.upload, { retry: false });
|
|
300
451
|
case 'pdf': return withRetry(async () => {
|
|
301
452
|
const page = await getPage();
|
|
302
453
|
return await page.pdf({ landscape: args.landscape });
|
|
303
|
-
});
|
|
454
|
+
}, TIMEOUTS.pdf);
|
|
455
|
+
case 'reload': return withRetry(async () => {
|
|
456
|
+
const page = await getPage();
|
|
457
|
+
await page.reload({ ignoreCache: !!args.ignoreCache });
|
|
458
|
+
return 'ok';
|
|
459
|
+
}, TIMEOUTS.reload);
|
|
460
|
+
case 'screenshot': return withRetry(async () => {
|
|
461
|
+
const page = await getPage();
|
|
462
|
+
const format = args.format || 'png';
|
|
463
|
+
const b64 = await page.screenshot({ format, quality: args.quality });
|
|
464
|
+
mkdirSync(OUTPUT_DIR, { recursive: true });
|
|
465
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
466
|
+
const file = join(OUTPUT_DIR, `screenshot-${ts}.${format}`);
|
|
467
|
+
writeFileSync(file, Buffer.from(b64, 'base64'));
|
|
468
|
+
return file;
|
|
469
|
+
}, TIMEOUTS.screenshot);
|
|
470
|
+
case 'wait_for': return withRetry(async () => {
|
|
471
|
+
const page = await getPage();
|
|
472
|
+
await page.waitFor({ text: args.text, selector: args.selector, timeout: args.timeout });
|
|
473
|
+
return 'ok';
|
|
474
|
+
}, TIMEOUTS.wait_for, { retry: false });
|
|
475
|
+
case 'tabs': return withRetry(async () => {
|
|
476
|
+
const page = await getPage();
|
|
477
|
+
if (typeof args.switchTo === 'number') {
|
|
478
|
+
await page.switchTab(args.switchTo);
|
|
479
|
+
return 'ok';
|
|
480
|
+
}
|
|
481
|
+
const list = await page.tabs();
|
|
482
|
+
return JSON.stringify(list, null, 2);
|
|
483
|
+
}, TIMEOUTS.tabs, { retry: false });
|
|
484
|
+
case 'select': return withRetry(async () => {
|
|
485
|
+
const page = await getPage();
|
|
486
|
+
await page.select(args.ref, args.value);
|
|
487
|
+
return 'ok';
|
|
488
|
+
}, TIMEOUTS.select, { retry: false });
|
|
489
|
+
case 'hover': return withRetry(async () => {
|
|
490
|
+
const page = await getPage();
|
|
491
|
+
await page.hover(args.ref);
|
|
492
|
+
return 'ok';
|
|
493
|
+
}, TIMEOUTS.hover, { retry: false });
|
|
494
|
+
case 'eval': {
|
|
495
|
+
// Only reachable when BAREBROWSE_MCP_EVAL=1 — the tool isn't registered
|
|
496
|
+
// otherwise, but this guard is the second line of defense in case the
|
|
497
|
+
// env var changes between tools/list and tools/call.
|
|
498
|
+
if (process.env.BAREBROWSE_MCP_EVAL !== '1') {
|
|
499
|
+
throw new Error('eval is disabled. Set BAREBROWSE_MCP_EVAL=1 to enable.');
|
|
500
|
+
}
|
|
501
|
+
return withRetry(async () => {
|
|
502
|
+
const page = await getPage();
|
|
503
|
+
const { result, exceptionDetails } = await page.cdp.send('Runtime.evaluate', {
|
|
504
|
+
expression: args.expression,
|
|
505
|
+
returnByValue: true,
|
|
506
|
+
awaitPromise: true,
|
|
507
|
+
});
|
|
508
|
+
if (exceptionDetails) {
|
|
509
|
+
throw new Error(exceptionDetails.text + (exceptionDetails.exception?.description ? `: ${exceptionDetails.exception.description}` : ''));
|
|
510
|
+
}
|
|
511
|
+
return result.value === undefined ? 'undefined' : JSON.stringify(result.value);
|
|
512
|
+
}, TIMEOUTS.eval, { retry: false });
|
|
513
|
+
}
|
|
304
514
|
case 'assess': {
|
|
305
515
|
if (!assessFn) throw new Error('wearehere is not installed. Run: npm install wearehere');
|
|
306
516
|
const releaseSlot = await acquireAssessSlot();
|
|
@@ -342,7 +552,7 @@ async function handleToolCall(name, args) {
|
|
|
342
552
|
} catch (err) {
|
|
343
553
|
clearTimeout(timer);
|
|
344
554
|
await tab.close().catch(() => {});
|
|
345
|
-
if (
|
|
555
|
+
if (isTransient(err)) _page = null;
|
|
346
556
|
throw err;
|
|
347
557
|
}
|
|
348
558
|
} finally {
|
|
@@ -369,7 +579,7 @@ async function handleMessage(msg) {
|
|
|
369
579
|
return jsonrpcResponse(id, {
|
|
370
580
|
protocolVersion: '2024-11-05',
|
|
371
581
|
capabilities: { tools: {} },
|
|
372
|
-
serverInfo: { name: 'barebrowse', version:
|
|
582
|
+
serverInfo: { name: 'barebrowse', version: PKG_VERSION },
|
|
373
583
|
});
|
|
374
584
|
}
|
|
375
585
|
|
|
@@ -383,19 +593,13 @@ async function handleMessage(msg) {
|
|
|
383
593
|
|
|
384
594
|
if (method === 'tools/call') {
|
|
385
595
|
const { name, arguments: args } = params;
|
|
386
|
-
const TOOL_TIMEOUT = name === 'browse' || name === 'assess' ? 60000 : 30000;
|
|
387
596
|
try {
|
|
388
|
-
|
|
389
|
-
const result = await Promise.race([
|
|
390
|
-
handleToolCall(name, args || {}),
|
|
391
|
-
new Promise((_, rej) => { timer = setTimeout(() => rej(new Error(`Tool "${name}" timed out after ${TOOL_TIMEOUT / 1000}s`)), TOOL_TIMEOUT); }),
|
|
392
|
-
]);
|
|
393
|
-
clearTimeout(timer);
|
|
597
|
+
const result = await handleToolCall(name, args || {});
|
|
394
598
|
return jsonrpcResponse(id, {
|
|
395
599
|
content: [{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result) }],
|
|
396
600
|
});
|
|
397
601
|
} catch (err) {
|
|
398
|
-
if (
|
|
602
|
+
if (isTransient(err)) _page = null;
|
|
399
603
|
return jsonrpcResponse(id, {
|
|
400
604
|
content: [{ type: 'text', text: `Error: ${err.message}` }],
|
|
401
605
|
isError: true,
|
|
@@ -407,55 +611,71 @@ async function handleMessage(msg) {
|
|
|
407
611
|
}
|
|
408
612
|
|
|
409
613
|
// --- Stdio transport ---
|
|
614
|
+
//
|
|
615
|
+
// Exported as runStdio() so callers (notably cli.js) can explicitly start the
|
|
616
|
+
// JSON-RPC loop. The previous "auto-start when isMain" guard broke the
|
|
617
|
+
// `npx barebrowse mcp` path because cli.js launches the server via
|
|
618
|
+
// `await import('./mcp-server.js')` — process.argv[1] is cli.js, not
|
|
619
|
+
// mcp-server.js, so isMain was false and the loop never started. Both the
|
|
620
|
+
// direct `node mcp-server.js` invocation and the cli.js path now call
|
|
621
|
+
// runStdio() explicitly. Tests import TIMEOUTS/TOOLS without calling it.
|
|
622
|
+
|
|
623
|
+
export function runStdio() {
|
|
624
|
+
// One-line startup banner to stderr (stderr because stdout is the JSON-RPC
|
|
625
|
+
// channel — must not contain non-JSON-RPC bytes). Captured by Claude Code's
|
|
626
|
+
// MCP log, makes "I added barebrowse twice and got the wrong one" issues
|
|
627
|
+
// diagnosable: the path here is the absolute file actually being served,
|
|
628
|
+
// so a scope conflict shows two different paths in two log files.
|
|
629
|
+
const _selfPath = fileURLToPath(import.meta.url);
|
|
630
|
+
process.stderr.write(`barebrowse mcp v${PKG_VERSION} | serving from ${_selfPath} | pid ${process.pid}\n`);
|
|
631
|
+
|
|
632
|
+
let buffer = '';
|
|
633
|
+
|
|
634
|
+
process.stdin.setEncoding('utf8');
|
|
635
|
+
process.stdin.on('data', (chunk) => {
|
|
636
|
+
buffer += chunk;
|
|
637
|
+
|
|
638
|
+
let newlineIdx;
|
|
639
|
+
while ((newlineIdx = buffer.indexOf('\n')) !== -1) {
|
|
640
|
+
const line = buffer.slice(0, newlineIdx).trim();
|
|
641
|
+
buffer = buffer.slice(newlineIdx + 1);
|
|
642
|
+
if (!line) continue;
|
|
410
643
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
process.stdin.setEncoding('utf8');
|
|
414
|
-
process.stdin.on('data', (chunk) => {
|
|
415
|
-
buffer += chunk;
|
|
416
|
-
|
|
417
|
-
let newlineIdx;
|
|
418
|
-
while ((newlineIdx = buffer.indexOf('\n')) !== -1) {
|
|
419
|
-
const line = buffer.slice(0, newlineIdx).trim();
|
|
420
|
-
buffer = buffer.slice(newlineIdx + 1);
|
|
421
|
-
if (!line) continue;
|
|
422
|
-
|
|
423
|
-
try {
|
|
424
|
-
const msg = JSON.parse(line);
|
|
425
|
-
|
|
426
|
-
handleMessage(msg).then((response) => {
|
|
427
|
-
if (response) {
|
|
644
|
+
try {
|
|
645
|
+
const msg = JSON.parse(line);
|
|
428
646
|
|
|
429
|
-
|
|
647
|
+
handleMessage(msg).then((response) => {
|
|
648
|
+
if (response) {
|
|
649
|
+
process.stdout.write(response + '\n');
|
|
650
|
+
}
|
|
651
|
+
}).catch((err) => {
|
|
652
|
+
process.stdout.write(jsonrpcError(msg.id, -32700, `Error: ${err.message}`) + '\n');
|
|
653
|
+
});
|
|
654
|
+
} catch (err) {
|
|
655
|
+
process.stdout.write(jsonrpcError(null, -32700, `Parse error: ${err.message}`) + '\n');
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
});
|
|
430
659
|
|
|
431
|
-
|
|
432
|
-
|
|
660
|
+
// Prevent unhandled rejections and uncaught exceptions from crashing the server.
|
|
661
|
+
// Browser OOM/crash rejects all pending CDP promises — some may not be awaited.
|
|
662
|
+
process.on('unhandledRejection', () => { _page = null; });
|
|
663
|
+
process.on('uncaughtException', () => { _page = null; });
|
|
433
664
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
665
|
+
// Clean up on exit
|
|
666
|
+
process.on('SIGINT', async () => {
|
|
667
|
+
if (_page) await _page.close().catch(() => {});
|
|
668
|
+
process.exit(0);
|
|
669
|
+
});
|
|
670
|
+
process.on('SIGTERM', async () => {
|
|
671
|
+
if (_page) await _page.close().catch(() => {});
|
|
672
|
+
process.exit(0);
|
|
673
|
+
});
|
|
674
|
+
}
|
|
437
675
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
// Browser OOM/crash rejects all pending CDP promises — some may not be awaited.
|
|
445
|
-
process.on('unhandledRejection', (err) => {
|
|
446
|
-
_page = null;
|
|
447
|
-
});
|
|
448
|
-
process.on('uncaughtException', (err) => {
|
|
449
|
-
_page = null;
|
|
450
|
-
});
|
|
451
|
-
|
|
452
|
-
// Clean up on exit
|
|
453
|
-
process.on('SIGINT', async () => {
|
|
454
|
-
if (_page) await _page.close().catch(() => {});
|
|
455
|
-
process.exit(0);
|
|
456
|
-
});
|
|
457
|
-
|
|
458
|
-
process.on('SIGTERM', async () => {
|
|
459
|
-
if (_page) await _page.close().catch(() => {});
|
|
460
|
-
process.exit(0);
|
|
461
|
-
});
|
|
676
|
+
// Direct invocation (`node mcp-server.js`) still works without cli.js — auto-
|
|
677
|
+
// start if this file IS process.argv[1]. The cli.js path imports + calls
|
|
678
|
+
// runStdio() explicitly so we never depend on argv[1] matching.
|
|
679
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
680
|
+
runStdio();
|
|
681
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "barebrowse",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Authenticated web browsing for autonomous agents via CDP. URL in, pruned ARIA snapshot out.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -31,5 +31,5 @@
|
|
|
31
31
|
"optionalDependencies": {
|
|
32
32
|
"wearehere": "^1.0.0"
|
|
33
33
|
},
|
|
34
|
-
"license": "
|
|
34
|
+
"license": "Apache-2.0"
|
|
35
35
|
}
|
package/src/bareagent.js
CHANGED
|
@@ -244,6 +244,39 @@ export function createBrowseTools(opts = {}) {
|
|
|
244
244
|
return await page.screenshot({ format });
|
|
245
245
|
},
|
|
246
246
|
},
|
|
247
|
+
{
|
|
248
|
+
name: 'reload',
|
|
249
|
+
description: 'Reload the current page. Returns the updated snapshot.',
|
|
250
|
+
parameters: {
|
|
251
|
+
type: 'object',
|
|
252
|
+
properties: {
|
|
253
|
+
ignoreCache: { type: 'boolean', description: 'Bypass HTTP cache (hard reload). Default: false.' },
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
execute: async ({ ignoreCache } = {}) => actionAndSnapshot((page) => page.reload({ ignoreCache })),
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
name: 'wait_for',
|
|
260
|
+
description: 'Wait for visible text or a CSS selector to appear on the current page. Returns the updated snapshot once found.',
|
|
261
|
+
parameters: {
|
|
262
|
+
type: 'object',
|
|
263
|
+
properties: {
|
|
264
|
+
text: { type: 'string', description: 'Substring that must appear in document.body.innerText' },
|
|
265
|
+
selector: { type: 'string', description: 'CSS selector that must match document.querySelector' },
|
|
266
|
+
timeout: { type: 'number', description: 'Timeout in ms (default: 30000)' },
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
execute: async ({ text, selector, timeout } = {}) => actionAndSnapshot((page) => page.waitFor({ text, selector, timeout })),
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
name: 'downloads',
|
|
273
|
+
description: 'List files captured via Content-Disposition: attachment downloads during this session. Returns JSON array of { url, suggestedFilename, savedPath, state, totalBytes, receivedBytes } per file.',
|
|
274
|
+
parameters: { type: 'object', properties: {} },
|
|
275
|
+
execute: async () => {
|
|
276
|
+
const page = await getPage();
|
|
277
|
+
return JSON.stringify(page.downloads.map((d) => ({ ...d })), null, 2);
|
|
278
|
+
},
|
|
279
|
+
},
|
|
247
280
|
];
|
|
248
281
|
|
|
249
282
|
// Add assess tool if wearehere is installed
|