barebrowse 0.7.0 → 0.7.1

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 CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.1
4
+
5
+ Fix: timeout now triggers auto-retry instead of bypassing it.
6
+
7
+ ### Bug fix (`mcp-server.js`)
8
+ - **Root cause:** The 30s timeout was a `Promise.race` *outside* `withRetry()`. When a page timed out, the race rejected immediately — `withRetry` never got a chance to reset the session and retry. Timeouts also didn't match `isCdpDead()`, so even if they did reach `withRetry`, they wouldn't be retried.
9
+ - **Fix:** Moved per-attempt timeout *inside* `withRetry()`. Each attempt gets its own 30s deadline. On timeout or CDP death, the session resets and a fresh attempt runs. The outer `Promise.race` is removed entirely.
10
+ - `isCdpDead()` renamed to `isTransient()` — now also matches timeout errors (`"Timeout waiting for CDP event"`, `"timed out"`)
11
+ - Non-transient errors (validation, unknown tool) are still not retried
12
+
13
+ ### Tests
14
+ - 11 new unit tests in `test/unit/mcp.test.js`: `isTransient` detection (CDP death, timeouts, non-transient), `withRetry` behavior (success, CDP retry, timeout retry, no-retry for validation, double-failure, no-timeout mode)
15
+ - 80 total tests (39 unit + 41 integration)
16
+
3
17
  ## 0.7.0
4
18
 
5
19
  MCP resilience: timeouts, auto-retry, LLM-friendly scroll, and click fallback for hidden elements.
@@ -1,7 +1,7 @@
1
1
  # barebrowse -- Integration Guide
2
2
 
3
3
  > For AI assistants and developers wiring barebrowse into a project.
4
- > v0.7.0 | Node.js >= 22 | 0 required deps | MIT
4
+ > v0.7.1 | Node.js >= 22 | 0 required deps | MIT
5
5
 
6
6
  ## What this is
7
7
 
@@ -241,7 +241,7 @@ Action tools return `'ok'` -- the agent calls `snapshot` explicitly to observe.
241
241
 
242
242
  Session runs in hybrid mode (headless with automatic headed fallback on bot detection). `goto` injects cookies from the user's browser before navigation for authenticated access.
243
243
 
244
- Session tools share a singleton page, lazy-created on first use. All session tools have auto-retry on transient CDP failures (browser crash, WebSocket close) — session resets and retries once automatically. 30s timeout on all tools (60s for `browse`/`assess`). Scroll accepts `direction: "up"/"down"` in addition to numeric `deltaY`. Click falls back to JS `.click()` when elements have no layout. Assess tries headless first; if bot-blocked, retries headed. Browser OOM/crash auto-recovers (session resets, server stays alive).
244
+ Session tools share a singleton page, lazy-created on first use. All session tools have auto-retry on transient failures (browser crash, WebSocket close, navigation timeout) — each attempt gets its own 30s deadline, session resets between attempts, retries once automatically. Scroll accepts `direction: "up"/"down"` in addition to numeric `deltaY`. Click falls back to JS `.click()` when elements have no layout. `browse` has a 60s timeout (no retry — stateless). Assess tries headless first; if bot-blocked, retries headed. Browser OOM/crash auto-recovers (session resets, server stays alive).
245
245
 
246
246
  ## Architecture
247
247
 
package/mcp-server.js CHANGED
@@ -20,20 +20,37 @@ try {
20
20
  } catch {}
21
21
 
22
22
 
23
- function isCdpDead(err) {
23
+ function isTransient(err) {
24
24
  const m = err.message || '';
25
- return m.includes('WebSocket') || m.includes('Target closed') || m.includes('Session closed') || m.includes('CDP');
25
+ return m.includes('WebSocket') || m.includes('Target closed') || m.includes('Session closed')
26
+ || m.includes('CDP') || m.includes('Timeout waiting for CDP event') || m.includes('timed out');
26
27
  }
27
28
 
28
- /** Retry-once wrapper for transient CDP failures. Resets session and retries. */
29
- async function withRetry(fn) {
29
+ /**
30
+ * Retry-once wrapper with per-attempt timeout.
31
+ * On transient failure (CDP death OR timeout), resets session and retries once.
32
+ * @param {Function} fn - async function to execute
33
+ * @param {number} timeoutMs - per-attempt timeout in ms
34
+ */
35
+ async function withRetry(fn, timeoutMs) {
36
+ async function attempt() {
37
+ if (!timeoutMs) return await fn();
38
+ let timer;
39
+ const result = await Promise.race([
40
+ fn(),
41
+ new Promise((_, rej) => { timer = setTimeout(() => rej(new Error(`timed out after ${timeoutMs / 1000}s`)), timeoutMs); }),
42
+ ]);
43
+ clearTimeout(timer);
44
+ return result;
45
+ }
46
+
30
47
  try {
31
- return await fn();
48
+ return await attempt();
32
49
  } catch (err) {
33
- if (!isCdpDead(err)) throw err;
34
- // CDP died — reset session and retry once
50
+ if (!isTransient(err)) throw err;
51
+ // Transient failure — reset session and retry once
35
52
  _page = null;
36
- return await fn();
53
+ return await attempt();
37
54
  }
38
55
  }
39
56
 
@@ -226,7 +243,12 @@ if (assessFn) {
226
243
  async function handleToolCall(name, args) {
227
244
  switch (name) {
228
245
  case 'browse': {
229
- const text = await browse(args.url, { mode: args.mode });
246
+ let timer;
247
+ const text = await Promise.race([
248
+ browse(args.url, { mode: args.mode }),
249
+ new Promise((_, rej) => { timer = setTimeout(() => rej(new Error('browse timed out after 60s')), 60000); }),
250
+ ]);
251
+ clearTimeout(timer);
230
252
  const limit = args.maxChars ?? MAX_CHARS_DEFAULT;
231
253
  if (text.length > limit) {
232
254
  const file = saveSnapshot(text);
@@ -239,7 +261,7 @@ async function handleToolCall(name, args) {
239
261
  try { await page.injectCookies(args.url); } catch {}
240
262
  await page.goto(args.url);
241
263
  return 'ok';
242
- });
264
+ }, 30000);
243
265
  case 'snapshot': return withRetry(async () => {
244
266
  const page = await getPage();
245
267
  const text = await page.snapshot();
@@ -249,22 +271,22 @@ async function handleToolCall(name, args) {
249
271
  return `Snapshot (${text.length} chars) saved to ${file}`;
250
272
  }
251
273
  return text;
252
- });
274
+ }, 30000);
253
275
  case 'click': return withRetry(async () => {
254
276
  const page = await getPage();
255
277
  await page.click(args.ref);
256
278
  return 'ok';
257
- });
279
+ }, 30000);
258
280
  case 'type': return withRetry(async () => {
259
281
  const page = await getPage();
260
282
  await page.type(args.ref, args.text, { clear: args.clear });
261
283
  return 'ok';
262
- });
284
+ }, 30000);
263
285
  case 'press': return withRetry(async () => {
264
286
  const page = await getPage();
265
287
  await page.press(args.key);
266
288
  return 'ok';
267
- });
289
+ }, 30000);
268
290
  case 'scroll': return withRetry(async () => {
269
291
  const page = await getPage();
270
292
  let dy = args.deltaY;
@@ -276,31 +298,31 @@ async function handleToolCall(name, args) {
276
298
  }
277
299
  await page.scroll(dy);
278
300
  return 'ok';
279
- });
301
+ }, 30000);
280
302
  case 'back': return withRetry(async () => {
281
303
  const page = await getPage();
282
304
  await page.goBack();
283
305
  return 'ok';
284
- });
306
+ }, 30000);
285
307
  case 'forward': return withRetry(async () => {
286
308
  const page = await getPage();
287
309
  await page.goForward();
288
310
  return 'ok';
289
- });
311
+ }, 30000);
290
312
  case 'drag': return withRetry(async () => {
291
313
  const page = await getPage();
292
314
  await page.drag(args.fromRef, args.toRef);
293
315
  return 'ok';
294
- });
316
+ }, 30000);
295
317
  case 'upload': return withRetry(async () => {
296
318
  const page = await getPage();
297
319
  await page.upload(args.ref, args.files);
298
320
  return 'ok';
299
- });
321
+ }, 30000);
300
322
  case 'pdf': return withRetry(async () => {
301
323
  const page = await getPage();
302
324
  return await page.pdf({ landscape: args.landscape });
303
- });
325
+ }, 30000);
304
326
  case 'assess': {
305
327
  if (!assessFn) throw new Error('wearehere is not installed. Run: npm install wearehere');
306
328
  const releaseSlot = await acquireAssessSlot();
@@ -342,7 +364,7 @@ async function handleToolCall(name, args) {
342
364
  } catch (err) {
343
365
  clearTimeout(timer);
344
366
  await tab.close().catch(() => {});
345
- if (isCdpDead(err)) _page = null;
367
+ if (isTransient(err)) _page = null;
346
368
  throw err;
347
369
  }
348
370
  } finally {
@@ -369,7 +391,7 @@ async function handleMessage(msg) {
369
391
  return jsonrpcResponse(id, {
370
392
  protocolVersion: '2024-11-05',
371
393
  capabilities: { tools: {} },
372
- serverInfo: { name: 'barebrowse', version: '0.7.0' },
394
+ serverInfo: { name: 'barebrowse', version: '0.7.1' },
373
395
  });
374
396
  }
375
397
 
@@ -383,19 +405,13 @@ async function handleMessage(msg) {
383
405
 
384
406
  if (method === 'tools/call') {
385
407
  const { name, arguments: args } = params;
386
- const TOOL_TIMEOUT = name === 'browse' || name === 'assess' ? 60000 : 30000;
387
408
  try {
388
- let timer;
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);
409
+ const result = await handleToolCall(name, args || {});
394
410
  return jsonrpcResponse(id, {
395
411
  content: [{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result) }],
396
412
  });
397
413
  } catch (err) {
398
- if (isCdpDead(err)) _page = null;
414
+ if (isTransient(err)) _page = null;
399
415
  return jsonrpcResponse(id, {
400
416
  content: [{ type: 'text', text: `Error: ${err.message}` }],
401
417
  isError: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "barebrowse",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
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",
@@ -1,68 +0,0 @@
1
- # Design: Harden assess tool
2
-
3
- ## Architecture
4
-
5
- ### Current flow (broken)
6
- ```
7
- assess(url) → connect({ mode: 'hybrid' }) ← NEW browser, no cookies
8
- → assessFn(page, url)
9
- → page.close() ← kills browser
10
- ```
11
-
12
- ### New flow
13
- ```
14
- assess(url) → getPage() ← reuse session browser
15
- → page.createTab() ← new tab in same browser
16
- → tab.injectCookies(url) ← cookie injection
17
- → assessFn(tab, url) ← assess uses tab
18
- → tab.close() ← close tab only
19
- timeout guard wraps entire flow
20
- retry wraps entire flow
21
- ```
22
-
23
- ## Key design decisions
24
-
25
- ### Why a new tab, not the session page?
26
- wearehere's `assess()` calls:
27
- - `session.send('Page.addScriptToEvaluateOnNewDocument', ...)` — injects fingerprint detection scripts
28
- - `networkSession.on('Network.requestWillBeSent', ...)` — monitors all network traffic
29
-
30
- These would pollute the session page. A separate tab has its own CDP session with isolated Page/Network domains.
31
-
32
- ### createTab() page-like interface
33
- wearehere expects a page object with: `goto()`, `cdp` (raw session), `waitForNetworkIdle()`. createTab() returns exactly this interface:
34
-
35
- ```javascript
36
- {
37
- goto(url, timeout) // navigate tab
38
- cdp // raw CDP session for this tab
39
- waitForNetworkIdle(opts) // reuses existing waitForNetworkIdle()
40
- injectCookies(url) // cookie injection for this tab
41
- close() // close tab, NOT the browser
42
- }
43
- ```
44
-
45
- ### Retry strategy
46
- ```
47
- attempt 1: assess with 45s timeout
48
- fail → wait 2s
49
- attempt 2: assess with 45s timeout (if browser crashed, reset _page first)
50
- fail → return { error: "assessment_failed", ... }
51
- ```
52
-
53
- ### Timeout implementation
54
- ```javascript
55
- const result = await Promise.race([
56
- doAssess(page, url, opts),
57
- new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 45000))
58
- ]);
59
- ```
60
- On timeout, the tab is closed in a finally block.
61
-
62
- ## Files changed
63
- | File | Change |
64
- |------|--------|
65
- | `src/index.js` | Add `createTab()` and tab's `close()` to connect() return object |
66
- | `mcp-server.js` | Rewrite assess case: use getPage().createTab(), add retry + timeout |
67
- | `README.md` | Update assess description, add self-healing mention |
68
- | `barebrowse.context.md` | Update assess section, document createTab() |
@@ -1,71 +0,0 @@
1
- # Plan: Harden assess tool — session reuse + self-healing
2
-
3
- ## Plan ID
4
- `harden-assess`
5
-
6
- ## Summary
7
- Make the assess tool reuse the MCP session's browser instance (cookies, headed fallback) instead of spawning throwaway browsers, add retry logic for transient failures, and add a timeout guard so no single assessment can hang forever.
8
-
9
- ## Problem Statement
10
- The `assess` tool in `mcp-server.js` calls `connect({ mode: 'hybrid' })` directly — creating a fresh headless browser per call with no cookies, no session state, and no headed fallback. Every other MCP tool uses the `getPage()` singleton. This causes:
11
-
12
- 1. **No cookies** — assess browses as a stranger, getting blocked by consent walls and bot detection that cookies would bypass
13
- 2. **No headed fallback reuse** — if the singleton already fell back to headed mode, assess still starts fresh headless and hits the same blocks
14
- 3. **No retry** — any failure (browser crash, navigation timeout, CDP disconnect) kills the assessment with no recovery
15
- 4. **No timeout guard** — if `wearehere`'s `assess()` hangs (e.g. network idle never resolves), the MCP call blocks indefinitely
16
-
17
- However, assess can't simply use the shared `_page` singleton directly because `wearehere` injects init scripts (`addScriptToEvaluateOnNewDocument`) and network listeners that would pollute the session page for subsequent calls. The solution is to create a **second page tab** within the same browser instance.
18
-
19
- ## Proposed Solution
20
- 1. **Session-aware page creation** — Instead of `connect()`, assess opens a new CDP tab within the existing browser. This shares the browser process (same cookies, same headed/headless state) but isolates assess's script injections to its own tab.
21
- 2. **Retry with backoff** — Wrap the assess call in a retry loop (max 2 attempts, 2s backoff). On browser crash, reconnect the singleton.
22
- 3. **Timeout guard** — Wrap each assess call in `Promise.race` with a 45s hard deadline. If exceeded, return an error result (not hang).
23
-
24
- ## Benefits
25
- - Assess gets cookies and headed fallback for free — no separate browser instance
26
- - Failed assessments auto-retry instead of dying
27
- - Hanging assessments time out gracefully instead of blocking the MCP server forever
28
- - Eliminates the 10+ second cold-start per assessment (browser launch)
29
-
30
- ## Scope
31
- ### In Scope
32
- - Modify `mcp-server.js` assess handler to create tabs within existing browser
33
- - Add `createTab()` / `closeTab()` helper to `src/index.js` connect() page handle
34
- - Add retry wrapper in mcp-server.js
35
- - Add timeout guard in mcp-server.js
36
- - Update docs: README.md, barebrowse.context.md, CLAUDE.md
37
-
38
- ### Out of Scope
39
- - Changing wearehere's internal logic
40
- - Adding retry/self-healing to other MCP tools (future work)
41
- - Batch/queue mode for multiple assessments
42
- - Changing the assess tool's MCP interface (same inputs/outputs)
43
-
44
- ## Dependencies
45
- - `wearehere` package (assess function signature unchanged)
46
- - `src/index.js` connect() API (adding createTab/closeTab methods)
47
- - `src/cdp.js` (Target.createTarget / closeTarget already available)
48
-
49
- ## Implementation Strategy
50
- Phase 1: Add tab management to connect() page handle (createTab, closeTab)
51
- Phase 2: Rewrite assess handler to use session tab + retry + timeout
52
- Phase 3: Update documentation
53
-
54
- ## Risks and Mitigations
55
- | Risk | Impact | Mitigation |
56
- |------|--------|------------|
57
- | Init script injection leaks across tabs | Pollutes session page | Each tab gets its own Page domain; addScriptToEvaluateOnNewDocument is per-target |
58
- | Browser crash during assess kills session too | Session page lost | getPage() already handles reconnection lazily (set _page = null, next call recreates) |
59
- | wearehere expects full page handle, not raw CDP session | API mismatch | createTab() returns a page-like object with goto, cdp, waitForNetworkIdle — same interface |
60
-
61
- ## Success Criteria
62
- - [ ] `assess` reuses the session browser (no separate `connect()` call)
63
- - [ ] `assess` inherits cookies from the session
64
- - [ ] `assess` works when session is in headed mode (hybrid fallback already triggered)
65
- - [ ] Failed assessment retries once before returning error
66
- - [ ] Assessment hanging > 45s returns timeout error, doesn't block server
67
- - [ ] All existing tests pass
68
- - [ ] Documentation updated
69
-
70
- ## Open Questions
71
- 1. Should createTab() also inject cookies? — **Recommendation**: Yes, call `authenticate()` for the target URL before navigation, same as `goto` does.
@@ -1,38 +0,0 @@
1
- # PRD: Harden assess tool
2
-
3
- ## Overview
4
- The `assess` MCP tool must reuse the session browser, retry on failure, and time out gracefully.
5
-
6
- ## Requirements
7
-
8
- ### R1: Session-aware tab creation
9
- The assess tool MUST create a new browser tab within the existing MCP session browser instead of spawning a separate browser via `connect()`. The tab MUST:
10
- - Share the same browser process (inheriting headless/headed state)
11
- - Have access to the browser's cookie jar
12
- - Isolate its CDP domains (Page, Network, DOM) from the session page
13
- - Be closed after each assessment completes or fails
14
-
15
- ### R2: Cookie injection
16
- Before navigating, the assess tab MUST inject cookies from the user's browser for the target URL, using the same `authenticate()` mechanism as `goto`.
17
-
18
- ### R3: Retry on failure
19
- If an assessment fails (navigation timeout, CDP error, browser crash), the tool MUST:
20
- - Retry once after a 2-second delay
21
- - If the browser crashed, reset the session singleton (`_page = null`) so getPage() reconnects
22
- - If retry also fails, return a structured error result (not throw)
23
-
24
- ### R4: Timeout guard
25
- Each assessment MUST have a hard timeout of 45 seconds. If exceeded:
26
- - The tab is force-closed
27
- - A structured error result is returned: `{ site, url, error: "timeout", scanned_at }`
28
- - The session page is NOT affected
29
-
30
- ### R5: Backwards compatibility
31
- - The assess tool's MCP interface (inputs/outputs) MUST NOT change
32
- - Successful assessments return the same JSON format as before
33
- - The tool appears/disappears based on wearehere availability (unchanged)
34
-
35
- ## Non-functional
36
- - No new dependencies
37
- - No changes to wearehere package
38
- - createTab()/closeTab() exposed on connect() page handle for library users too