barebrowse 0.11.0 → 0.13.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 CHANGED
@@ -1,5 +1,83 @@
1
1
  # Changelog
2
2
 
3
+ ## [Unreleased]
4
+
5
+ ## [0.13.0] - 2026-06-12
6
+
7
+ ### Added
8
+ - **`readable()` — clean article extraction.** New read mode that returns the
9
+ main article of a page as clean text (title + body prose, nav/ads/sidebars
10
+ stripped) via Mozilla Readability injected in-page over CDP. Companion to
11
+ `snapshot()`, not a replacement: `snapshot()` is the *actionable* ARIA tree
12
+ for clicking/typing; `readable()` is for *reading/summarising* article-like
13
+ pages (news, blogs, docs, wiki), where `snapshot()` is noisy and silently
14
+ lossy on long prose. Article detection is unreliable, so `readable()` never
15
+ hard-gates — it always returns the text plus an advisory `confidence`
16
+ (`high`/`low`) and a hint to fall back to `snapshot()` on non-article pages.
17
+ Exposed everywhere: `page.readable()`, MCP `readable` tool, bareagent
18
+ `readable` tool, and `barebrowse readable` CLI (→ `.barebrowse/article-*.txt`).
19
+
20
+ ### Fixed
21
+ - **Large pages no longer kill the CDP connection.** Node's built-in WebSocket
22
+ (undici) silently caps decompressed messages at ~3 MB and *permanently* tears
23
+ down the socket when a single `Accessibility.getFullAXTree` response exceeds
24
+ it — which broke `snapshot()` (and consent dismissal during `goto()`) on big
25
+ pages (e.g. long Wikipedia articles). `cdp.js` now uses the `ws` package with
26
+ a 256 MB `maxPayload`; the built-in exposes no way to raise the limit.
27
+ Regression test: `connect.test.js` snapshots a 12k-node page that tripped the
28
+ old cap.
29
+
30
+ ### Security
31
+ - **MCP output files are now owner-only (`0600`).** `saveSnapshot()` and the
32
+ screenshot tool previously wrote snapshots / articles / screenshots with
33
+ default perms (`0644` in a `0755` dir under the standard umask) —
34
+ authenticated page content readable by other local users on a shared host.
35
+ They now write `0600` files in a `0700` dir, umask-independent, matching the
36
+ daemon's existing invariant. Regression-guarded by a test that fails on a
37
+ `0644` write.
38
+ - **Daemon hardening:** `GET /status` (the only pre-auth endpoint) no longer
39
+ returns the pid; `/command` now caps the request body at 16 MB (→ `413`).
40
+
41
+ ### Changed
42
+ - **Two runtime dependencies (previously zero):** `ws` (CDP transport, above)
43
+ and `@mozilla/readability` (`readable()`). Both are lightweight, widely
44
+ adopted, and actively maintained, per the project's dependency rule (external
45
+ only when the stdlib genuinely can't do the job).
46
+
47
+ ## [0.12.0] - 2026-05-29
48
+
49
+ ### Added
50
+ - **Shipped TypeScript types, generated from JSDoc.** The package now ships
51
+ `.d.ts` declarations so adopters get autocomplete and type errors out of the
52
+ box — no `@types/barebrowse`. The `.js` we author is still the `.js` that
53
+ ships; there is **no build step for runtime code**. Types are generated by
54
+ `tsc` (`checkJs` + `strictNullChecks`), emitted to a git-ignored `types/`, and
55
+ built into the tarball at publish via `prepublishOnly`. Because they are
56
+ generated-and-never-committed, the JSDoc, the `.d.ts`, and CI cannot drift.
57
+ `exports` now carries a `types` condition on every subpath.
58
+ - **`ci.yml` (push/PR gate):** `npm ci → typecheck → build:types → test`. A
59
+ JSDoc/code mismatch is now a type error that blocks merge. No lint step — `tsc`
60
+ covers the bug class that matters for a vanilla-ESM lib.
61
+ - Dev-only tooling: `typescript` + `@types/node` (devDependencies; never
62
+ shipped), `tsconfig.json`, and `typecheck` / `build:types` / `prepublishOnly`
63
+ scripts.
64
+
65
+ ### Changed
66
+ - **`publish.yml` is now manual-only (`workflow_dispatch`) — npm OIDC trusted publishing with provenance, idempotent, and verifies the registry end-state.**
67
+ - **Packaging now uses a `files` allowlist** (`src/`, generated `types/`,
68
+ `cli.js`, `mcp-server.js`, and the doc set) instead of the old `.npmignore`
69
+ denylist, which was removed. Repo-only files (`test/`, `docs/`, `CLAUDE.md`)
70
+ are excluded from the tarball.
71
+
72
+ ### Fixed
73
+ - **`auth.js`: cookie databases are now opened with `readOnly: true`.** The
74
+ previous `readonly` (lowercase) key is silently ignored by `node:sqlite`;
75
+ surfaced by the new `tsc` typecheck. Read-only was already enforced via the
76
+ `?immutable=1` connection URI, so observable behavior is unchanged — this
77
+ honors the intended option. Added minimal, behavior-preserving null/type
78
+ guards in a few spots (`server.address()`, SQLite row values) flagged by
79
+ `strictNullChecks`.
80
+
3
81
  ## 0.11.0
4
82
 
5
83
  ### Security hardening — audit findings fixed, safe-by-default
package/README.md CHANGED
@@ -10,6 +10,7 @@
10
10
  ```
11
11
 
12
12
  <p align="center">
13
+ <a href="https://github.com/hamr0/barebrowse/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/hamr0/barebrowse/ci.yml?label=CI" alt="CI"></a>
13
14
  <img src="https://img.shields.io/github/package-json/v/hamr0/barebrowse?label=version&color=2a4f8c" alt="version (auto from package.json)">
14
15
  <img src="https://img.shields.io/badge/license-Apache%202.0-2a4f8c" alt="license: Apache 2.0">
15
16
  </p>
@@ -25,7 +26,7 @@ barebrowse gives your AI agent a real browser. Navigate, read, interact, move on
25
26
 
26
27
  It uses the browser you already have -- your sessions, your cookies. Pages come back stripped to what matters -- 40-90% fewer tokens than raw output.
27
28
 
28
- No Playwright. Zero dependencies. No bundled browser. No 200MB download.
29
+ No Playwright. No bundled browser. No 200MB download. Two tiny dependencies (`ws` + Mozilla Readability).
29
30
 
30
31
  ## Install
31
32
 
@@ -35,6 +36,8 @@ npm install barebrowse
35
36
 
36
37
  Requires Node.js >= 22 and any installed Chromium-based browser.
37
38
 
39
+ Ships with TypeScript types (generated from JSDoc) — autocomplete and type-checking work out of the box, no `@types/barebrowse` needed. The library is vanilla JS with no build step.
40
+
38
41
  ## Three ways to use it
39
42
 
40
43
  ### 1. CLI session -- for coding agents and quick testing
@@ -42,6 +45,7 @@ Requires Node.js >= 22 and any installed Chromium-based browser.
42
45
  ```bash
43
46
  barebrowse open https://example.com # Start session + navigate
44
47
  barebrowse snapshot # ARIA snapshot → .barebrowse/page-*.yml
48
+ barebrowse readable # Clean article text → .barebrowse/article-*.txt
45
49
  barebrowse click 8 # Click element
46
50
  barebrowse close # End session
47
51
  ```
@@ -92,7 +96,7 @@ Or manually add to your config (`claude_desktop_config.json`, `.cursor/mcp.json`
92
96
  }
93
97
  ```
94
98
 
95
- 18 tools: `browse`, `goto`, `snapshot`, `click`, `type`, `press`, `scroll`, `hover`, `select`, `back`, `forward`, `reload`, `drag`, `upload`, `pdf`, `screenshot`, `wait_for`, `tabs`. Plus `assess` (privacy scan) if [wearehere](https://github.com/hamr0/wearehere) is installed. Plus opt-in `eval` (`BAREBROWSE_MCP_EVAL=1`) — runs JS in the authenticated session, off by default because it can read cookies/localStorage. Session runs in hybrid mode with automatic cookie injection. Per-tool timeouts (goto/reload/wait_for 60s, back/forward 30s, interactive ops 15s, pdf/screenshot/upload 45s) with auto-retry on transient failures (idempotent only — mutating tools fail loudly to avoid double-submits).
99
+ 19 tools: `browse`, `goto`, `snapshot`, `readable`, `click`, `type`, `press`, `scroll`, `hover`, `select`, `back`, `forward`, `reload`, `drag`, `upload`, `pdf`, `screenshot`, `wait_for`, `tabs`. Plus `assess` (privacy scan) if [wearehere](https://github.com/hamr0/wearehere) is installed. Plus opt-in `eval` (`BAREBROWSE_MCP_EVAL=1`) — runs JS in the authenticated session, off by default because it can read cookies/localStorage. Session runs in hybrid mode with automatic cookie injection. Per-tool timeouts (goto/reload/wait_for 60s, back/forward 30s, interactive ops 15s, pdf/screenshot/upload 45s) with auto-retry on transient failures (idempotent only — mutating tools fail loudly to avoid double-submits).
96
100
 
97
101
  `browse` and `snapshot` accept `pruneMode: 'act'|'read'` (v0.9.1). `act` (default) keeps interactive elements — best for clicking/filling. `read` keeps paragraphs, headings, and long text — best for articles, docs, and content extraction. If act-mode collapses a content-heavy page near-totally, the snapshot includes a `hint: …` line suggesting `pruneMode='read'` so the agent doesn't bail to a separate HTTP fetch.
98
102
 
@@ -100,7 +104,7 @@ Troubleshooting MCP setup: `npx barebrowse doctor` scans every known config loca
100
104
 
101
105
  ### 3. Library -- for agentic automation
102
106
 
103
- Import barebrowse in your agent code. One-shot reads, interactive sessions, full observe-think-act loops. Works with any LLM orchestration library. Ships with a ready-made adapter for [bareagent](https://www.npmjs.com/package/bare-agent) (17 tools, auto-snapshot after every action).
107
+ Import barebrowse in your agent code. One-shot reads, interactive sessions, full observe-think-act loops. Works with any LLM orchestration library. Ships with a ready-made adapter for [bareagent](https://www.npmjs.com/package/bare-agent) (18 tools, auto-snapshot after every action).
104
108
 
105
109
  For code examples, API reference, and wiring instructions, see **[barebrowse.context.md](barebrowse.context.md)** -- the full integration guide.
106
110
 
@@ -167,6 +171,7 @@ Everything the agent can do through barebrowse:
167
171
  | **Navigate** | Load a URL, wait for page load, auto-dismiss consent |
168
172
  | **Back / Forward** | Browser history navigation |
169
173
  | **Snapshot** | Pruned ARIA tree with `[ref=N]` markers. Two modes: `act` (buttons, links, inputs) and `read` (full text). 40-90% token reduction. |
174
+ | **Readable** | Clean article text (title + body, chrome stripped — Reader-View engine). For *reading* article-like pages, not interacting. Advisory `confidence`; falls back to snapshot on non-articles. |
170
175
  | **Click** | Scroll into view + mouse click at element center, JS fallback for hidden elements |
171
176
  | **Type** | Focus + insert text, with option to clear existing content first |
172
177
  | **Press** | Special keys: Enter, Tab, Escape, Backspace, Delete, arrows, Space |
@@ -213,7 +218,7 @@ URL -> find/launch browser (chromium.js)
213
218
  -> agent-ready snapshot with [ref=N] markers
214
219
  ```
215
220
 
216
- 11 modules, 2,400 lines, zero required dependencies.
221
+ 14 modules, ~3,000 lines, two small dependencies (`ws`, `@mozilla/readability`).
217
222
 
218
223
  ## Requirements
219
224
 
@@ -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.11.0 | Node.js >= 22 | 0 required deps | Apache-2.0
4
+ > v0.12.0 | Node.js >= 22 | 0 required deps | Apache-2.0
5
5
 
6
6
  ## What this is
7
7
 
@@ -13,6 +13,11 @@ No Playwright. No bundled browser. No build step. Vanilla JS, ES modules.
13
13
  npm install barebrowse
14
14
  ```
15
15
 
16
+ **TypeScript:** ships with `.d.ts` types generated from the source JSDoc, so
17
+ autocomplete and type-checking work out of the box — no `@types/barebrowse`
18
+ needed. The library itself is vanilla JS with no build step; the types are a
19
+ publish-time artifact.
20
+
16
21
  Three integration paths:
17
22
  1. **Library:** `import { browse, connect } from 'barebrowse'` -- one-shot or interactive session
18
23
  2. **MCP server:** `barebrowse mcp` -- JSON-RPC over stdio for Claude Desktop, Cursor, etc.
@@ -62,6 +67,7 @@ const snapshot = await browse('https://example.com', {
62
67
  | `goForward()` | -- | void | Navigate forward in browser history |
63
68
  | `reload(opts?)` | { ignoreCache?: boolean, timeout?: number } | void | Reload the current page. Clears refMap (refs from pre-reload reject). |
64
69
  | `snapshot(pruneOpts?)` | false or { mode: 'act'\|'read' } | string | ARIA tree with `[ref=N]` markers. Pass `false` for raw. |
70
+ | `readable()` | -- | object | Clean article text (Reader-View engine). `{ ok, title, byline, text, length, confidence: 'high'\|'low', readerable, hint? }` or `{ ok: false, hint }`. For *reading*, not interacting — see note below. |
65
71
  | `click(ref)` | ref: string | void | Scroll into view + mouse press+release at center |
66
72
  | `type(ref, text, opts?)` | ref: string, text: string, opts: { clear?, keyEvents? } | void | Focus + insert text. `clear: true` replaces existing. |
67
73
  | `press(key)` | key: string | void | Special key: Enter, Tab, Escape, Backspace, Delete, arrows, Home, End, PageUp, PageDown, Space |
@@ -117,6 +123,28 @@ Key rules:
117
123
  - `click(ref)` / `type(ref, text)` / `hover(ref)` / `select(ref, value)` use these ref strings
118
124
  - Pruning removes noise (~47-95% token reduction) while keeping all interactive elements
119
125
 
126
+ ## readable() vs snapshot() — which to use
127
+
128
+ Two different jobs:
129
+
130
+ - **`snapshot()`** → the *actionable* ARIA tree (`[ref=N]` markers). Use it to **interact** (click/type/fill) or on **any** page type (home pages, search results, app UIs).
131
+ - **`readable()`** → the *article* as clean reading text (title + body prose; nav/ads/sidebars stripped). Use it **only** to **read or summarise** article-like pages (news, blogs, docs, wiki), where `snapshot()` is both noisy and *silently lossy on long prose* (`read` pruning can drop body text).
132
+
133
+ `readable()` is **not** a token-savings feature — vs a read-mode snapshot it can be smaller *or* larger depending on the page; its value is **complete, clean prose**. It returns no refs, so you cannot interact with its output.
134
+
135
+ **Article detection is unreliable**, so `readable()` never hard-gates. It always returns whatever it extracted plus an advisory `confidence`:
136
+ - `confidence: 'high'` → safe to treat as an article.
137
+ - `confidence: 'low'` (or `ok: false`) → probably not an article; the `hint` tells the agent to fall back to `snapshot()`.
138
+
139
+ ```js
140
+ const r = await page.readable();
141
+ if (r.ok && r.confidence === 'high') {
142
+ use(r.text); // clean article prose
143
+ } else {
144
+ const snap = await page.snapshot({ mode: 'read' }); // fall back
145
+ }
146
+ ```
147
+
120
148
  ## Interaction loop: observe, think, act
121
149
 
122
150
  ```javascript
@@ -202,10 +230,10 @@ try {
202
230
  ```
203
231
 
204
232
  `createBrowseTools(opts)` returns:
205
- - `tools` -- array of bareagent-compatible tool objects: `browse`, `goto`, `snapshot`, `click`, `type`, `press`, `scroll`, `select`, `hover`, `back`, `forward`, `reload` (v0.9.0), `drag`, `upload`, `tabs`, `switchTab`, `pdf`, `screenshot`, `wait_for` (v0.9.0), `downloads` (v0.9.0), plus `assess` if wearehere installed
233
+ - `tools` -- array of bareagent-compatible tool objects: `browse`, `goto`, `snapshot`, `readable`, `click`, `type`, `press`, `scroll`, `select`, `hover`, `back`, `forward`, `reload` (v0.9.0), `drag`, `upload`, `tabs`, `switchTab`, `pdf`, `screenshot`, `wait_for` (v0.9.0), `downloads` (v0.9.0), plus `assess` if wearehere installed
206
234
  - `close()` -- cleanup function, call when done
207
235
 
208
- Action tools (click, type, press, scroll, hover, goto, back, forward, reload, drag, upload, select, switchTab, wait_for) auto-return a fresh snapshot so the LLM always sees the result. 300ms settle delay after actions for DOM updates.
236
+ Action tools (click, type, press, scroll, hover, goto, back, forward, reload, drag, upload, select, switchTab, wait_for) auto-return a fresh snapshot so the LLM always sees the result. 300ms settle delay after actions for DOM updates. `readable` is a read tool (like `snapshot`): it returns the article text directly, not a follow-up snapshot.
209
237
 
210
238
  `onDialog` is intentionally not exposed as a tool — it's a callback shape that doesn't fit a request/response tool loop. If your bareagent flow needs to override a confirm/prompt, drop to `import { connect }` directly and pass the page through.
211
239
 
@@ -216,6 +244,7 @@ For coding agents (Claude Code, Copilot, Cursor) and quick interactive testing.
216
244
  ```bash
217
245
  barebrowse open https://example.com # Start daemon + navigate
218
246
  barebrowse snapshot # → .barebrowse/page-<timestamp>.yml
247
+ barebrowse readable # → .barebrowse/article-<timestamp>.txt (clean article text)
219
248
  barebrowse click 8 # Click element ref=8
220
249
  barebrowse type 12 hello world # Type into element ref=12
221
250
  barebrowse back # Go back in history
@@ -257,11 +286,11 @@ barebrowse ships an MCP server for direct use with Claude Desktop, Cursor, or an
257
286
  }
258
287
  ```
259
288
 
260
- 18 core tools as of v0.9.0: `browse` (one-shot), `goto`, `snapshot`, `click`, `type`, `press`, `scroll`, `hover`, `select`, `back`, `forward`, `reload`, `drag`, `upload`, `pdf`, `screenshot`, `wait_for`, `tabs`. Plus `assess` (privacy scan) if `wearehere` is installed (`npm install wearehere`). Plus the **opt-in `eval` tool** gated by `BAREBROWSE_MCP_EVAL=1` (default OFF) — `Runtime.evaluate` in the user's authenticated session can read cookies/localStorage and hit any same-origin endpoint, so opt-in only.
289
+ 19 core tools: `browse` (one-shot), `goto`, `snapshot`, `readable`, `click`, `type`, `press`, `scroll`, `hover`, `select`, `back`, `forward`, `reload`, `drag`, `upload`, `pdf`, `screenshot`, `wait_for`, `tabs`. Plus `assess` (privacy scan) if `wearehere` is installed (`npm install wearehere`). Plus the **opt-in `eval` tool** gated by `BAREBROWSE_MCP_EVAL=1` (default OFF) — `Runtime.evaluate` in the user's authenticated session can read cookies/localStorage and hit any same-origin endpoint, so opt-in only.
261
290
 
262
291
  Action tools return `'ok'` -- the agent calls `snapshot` explicitly to observe. This avoids double-token output since MCP tool calls are cheap to chain.
263
292
 
264
- `browse` and `snapshot` accept a `maxChars` param (default 30000). If the snapshot exceeds the limit, it's saved to `.barebrowse/page-<timestamp>.yml` and a short message with the file path is returned instead. `screenshot` always saves to `.barebrowse/screenshot-<timestamp>.{png,jpeg,webp}` and returns the file path (raw base64 in a JSON-RPC response would blow `maxChars`). `tabs` returns the JSON array, or with `switchTo: N` it switches and returns `'ok'`.
293
+ `browse`, `snapshot`, and `readable` accept a `maxChars` param (default 30000). If the output exceeds the limit it's saved to `.barebrowse/` and a short message with the file path is returned instead (`page-<timestamp>.yml` for snapshots, `article-<timestamp>.txt` for `readable`). `screenshot` always saves to `.barebrowse/screenshot-<timestamp>.{png,jpeg,webp}` and returns the file path (raw base64 in a JSON-RPC response would blow `maxChars`). `tabs` returns the JSON array, or with `switchTo: N` it switches and returns `'ok'`. All files MCP writes are owner-only (`0600` in a `0700` dir) — they can hold authenticated page content, so they're not world-readable on a shared host.
265
294
 
266
295
  `browse` and `snapshot` also accept `pruneMode: 'act'|'read'`. `act` (the default) keeps interactive elements and short labels — best for clicking/filling. `read` keeps paragraphs, headings, and long text — best for articles, docs, and content extraction. Same surface on the bareagent adapter. If act mode collapses a content-heavy page (raw > 5 KB → pruned < 500 chars AND < 5% of raw), the result includes a `hint: act mode dropped most of the page — retry with pruneMode='read' …` line between the stats and the tree so the caller knows to re-snapshot in read mode instead of bailing to a separate HTTP fetch.
267
296
 
package/cli.js CHANGED
@@ -38,6 +38,8 @@ if (args.includes('--daemon-internal')) {
38
38
  await cmdProxy('goto', { url: args[1], timeout: parseFlag('--timeout') });
39
39
  } else if (cmd === 'snapshot') {
40
40
  await cmdProxy('snapshot', { mode: parseFlag('--mode') });
41
+ } else if (cmd === 'readable') {
42
+ await cmdProxy('readable');
41
43
  } else if (cmd === 'screenshot') {
42
44
  await cmdProxy('screenshot', { format: parseFlag('--format') });
43
45
  } else if (cmd === 'click' && args[1]) {
@@ -504,6 +506,7 @@ Navigation:
504
506
  barebrowse forward Go forward in history
505
507
  barebrowse reload [--no-cache] Reload current page
506
508
  barebrowse snapshot [--mode=M] ARIA snapshot -> .barebrowse/page-*.yml
509
+ barebrowse readable Clean article text -> .barebrowse/article-*.txt
507
510
  barebrowse screenshot [--format] Screenshot -> .barebrowse/screenshot-*.png
508
511
  barebrowse pdf [--landscape] PDF export -> .barebrowse/page-*.pdf
509
512
 
package/mcp-server.js CHANGED
@@ -3,13 +3,15 @@
3
3
  * mcp-server.js — MCP server for barebrowse.
4
4
  *
5
5
  * Raw JSON-RPC 2.0 over stdio. No SDK dependency.
6
- * 12 tools: browse, goto, snapshot, click, type, press, scroll, back, forward, drag, upload, pdf.
6
+ * Tools: browse, goto, snapshot, readable, click, type, press, scroll, back,
7
+ * forward, drag, upload, pdf, reload, screenshot, wait_for, tabs, select, hover.
7
8
  *
8
9
  * Session tools share a singleton page, lazy-created on first use.
9
10
  * Action tools return 'ok' — agent calls snapshot explicitly to observe.
10
11
  */
11
12
 
12
13
  import { browse, connect } from './src/index.js';
14
+ import { formatReadable } from './src/readable.js';
13
15
  import { mkdirSync, writeFileSync, readFileSync } from 'node:fs';
14
16
  import { join, dirname } from 'node:path';
15
17
  import { pathToFileURL, fileURLToPath } from 'node:url';
@@ -102,11 +104,14 @@ async function withRetry(fn, timeoutMs, { retry = true } = {}) {
102
104
  const MAX_CHARS_DEFAULT = 30000;
103
105
  const OUTPUT_DIR = join(process.cwd(), '.barebrowse');
104
106
 
105
- function saveSnapshot(text) {
106
- mkdirSync(OUTPUT_DIR, { recursive: true });
107
+ export function saveSnapshot(text, { prefix = 'page', ext = 'yml' } = {}) {
108
+ // Owner-only: snapshots/articles can hold authenticated page content
109
+ // (logged-in text, reflected session data). Matches the daemon's 0600/0700
110
+ // invariant — and is umask-independent because the modes are explicit.
111
+ mkdirSync(OUTPUT_DIR, { recursive: true, mode: 0o700 });
107
112
  const ts = new Date().toISOString().replace(/[:.]/g, '-');
108
- const file = join(OUTPUT_DIR, `page-${ts}.yml`);
109
- writeFileSync(file, text);
113
+ const file = join(OUTPUT_DIR, `${prefix}-${ts}.${ext}`);
114
+ writeFileSync(file, text, { mode: 0o600 });
110
115
  return file;
111
116
  }
112
117
 
@@ -178,6 +183,16 @@ export const TOOLS = [
178
183
  },
179
184
  },
180
185
  },
186
+ {
187
+ name: 'readable',
188
+ description: 'Extract the main article of the current page as clean reading text (title + body prose, nav/ads/sidebars stripped — the Firefox Reader View engine). Use ONLY when your goal is to READ or SUMMARISE article-like content (news, blog posts, docs, wiki). For clicking/typing/forms, or for non-article pages (home pages, search results, app UIs), use snapshot instead. On a non-article page this returns a low-confidence result with a hint to use snapshot.',
189
+ inputSchema: {
190
+ type: 'object',
191
+ properties: {
192
+ maxChars: { type: 'number', description: 'Max chars to return inline. Longer articles are saved to .barebrowse/ and a file path is returned instead. Default: 30000.' },
193
+ },
194
+ },
195
+ },
181
196
  {
182
197
  name: 'click',
183
198
  description: 'Click an element by its ref from the snapshot. Returns ok — call snapshot to observe.',
@@ -403,6 +418,18 @@ async function handleToolCall(name, args) {
403
418
  }
404
419
  return text;
405
420
  }, TIMEOUTS.snapshot);
421
+ case 'readable': return withRetry(async () => {
422
+ const page = await getPage();
423
+ const r = await page.readable();
424
+ if (!r.ok) return r.hint;
425
+ const body = formatReadable(r);
426
+ const limit = args.maxChars ?? MAX_CHARS_DEFAULT;
427
+ if (body.length > limit) {
428
+ const file = saveSnapshot(body, { prefix: 'article', ext: 'txt' });
429
+ return `Article "${r.title}" (${r.text.length} chars, confidence: ${r.confidence}) saved to ${file}`;
430
+ }
431
+ return body;
432
+ }, TIMEOUTS.snapshot);
406
433
  case 'click': return withRetry(async () => {
407
434
  const page = await getPage();
408
435
  await page.click(args.ref);
@@ -463,10 +490,11 @@ async function handleToolCall(name, args) {
463
490
  const page = await getPage();
464
491
  const format = args.format || 'png';
465
492
  const b64 = await page.screenshot({ format, quality: args.quality });
466
- mkdirSync(OUTPUT_DIR, { recursive: true });
493
+ mkdirSync(OUTPUT_DIR, { recursive: true, mode: 0o700 });
467
494
  const ts = new Date().toISOString().replace(/[:.]/g, '-');
468
495
  const file = join(OUTPUT_DIR, `screenshot-${ts}.${format}`);
469
- writeFileSync(file, Buffer.from(b64, 'base64'));
496
+ // Owner-only: a screenshot of an authenticated page is sensitive too.
497
+ writeFileSync(file, Buffer.from(b64, 'base64'), { mode: 0o600 });
470
498
  return file;
471
499
  }, TIMEOUTS.screenshot);
472
500
  case 'wait_for': return withRetry(async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "barebrowse",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "Authenticated web browsing for autonomous agents via CDP. URL in, pruned ARIA snapshot out.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -10,18 +10,40 @@
10
10
  "bugs": "https://github.com/hamr0/barebrowse/issues",
11
11
  "type": "module",
12
12
  "main": "src/index.js",
13
+ "types": "./types/index.d.ts",
13
14
  "exports": {
14
- ".": "./src/index.js",
15
- "./bareagent": "./src/bareagent.js"
15
+ ".": {
16
+ "types": "./types/index.d.ts",
17
+ "default": "./src/index.js"
18
+ },
19
+ "./bareagent": {
20
+ "types": "./types/bareagent.d.ts",
21
+ "default": "./src/bareagent.js"
22
+ }
16
23
  },
17
24
  "bin": {
18
25
  "barebrowse": "./cli.js"
19
26
  },
27
+ "files": [
28
+ "src/",
29
+ "types/",
30
+ "cli.js",
31
+ "mcp-server.js",
32
+ "barebrowse.context.md",
33
+ "README.md",
34
+ "CHANGELOG.md",
35
+ "NOTICE"
36
+ ],
20
37
  "engines": {
21
38
  "node": ">=22"
22
39
  },
23
40
  "scripts": {
24
- "test": "node --test test/unit/*.test.js test/integration/*.test.js"
41
+ "test": "node --test test/unit/*.test.js test/integration/*.test.js",
42
+ "test:unit": "node --test test/unit/*.test.js",
43
+ "test:integration": "node --test test/integration/*.test.js",
44
+ "typecheck": "tsc --noEmit",
45
+ "build:types": "tsc",
46
+ "prepublishOnly": "npm run build:types"
25
47
  },
26
48
  "keywords": [
27
49
  "browser",
@@ -37,5 +59,13 @@
37
59
  "optionalDependencies": {
38
60
  "wearehere": "1.0.0"
39
61
  },
40
- "license": "Apache-2.0"
62
+ "license": "Apache-2.0",
63
+ "devDependencies": {
64
+ "@types/node": "^25.9.1",
65
+ "typescript": "^6.0.3"
66
+ },
67
+ "dependencies": {
68
+ "@mozilla/readability": "^0.6.0",
69
+ "ws": "^8.21.0"
70
+ }
41
71
  }
package/src/auth.js CHANGED
@@ -126,7 +126,7 @@ function extractChromiumCookies(dbPath, domain) {
126
126
  const aesKey = deriveKey(password);
127
127
 
128
128
  // immutable=1 bypasses WAL lock on live databases
129
- const db = new DatabaseSync(`file://${dbPath}?immutable=1`, { readonly: true });
129
+ const db = new DatabaseSync(`file://${dbPath}?immutable=1`, { readOnly: true });
130
130
 
131
131
  let sql = `SELECT host_key, name, value, encrypted_value, path,
132
132
  CAST(expires_utc AS TEXT) AS expires_utc, is_secure, is_httponly, samesite
@@ -144,7 +144,8 @@ function extractChromiumCookies(dbPath, domain) {
144
144
  const SAMESITE = { 0: 'None', 1: 'Lax', 2: 'Strict' };
145
145
 
146
146
  return rows.map((row) => {
147
- const enc = Buffer.from(row.encrypted_value);
147
+ const rawEnc = row.encrypted_value;
148
+ const enc = rawEnc instanceof Uint8Array ? Buffer.from(rawEnc) : Buffer.alloc(0);
148
149
  let value;
149
150
  try {
150
151
  value = enc.length > 0 ? decryptCookie(enc, aesKey) : row.value;
@@ -154,7 +155,9 @@ function extractChromiumCookies(dbPath, domain) {
154
155
 
155
156
  // Chrome timestamp: microseconds since 1601-01-01
156
157
  const CHROME_EPOCH = 11644473600000000n;
157
- const expiresUtc = row.expires_utc ? BigInt(row.expires_utc) : 0n;
158
+ const expiresUtc = typeof row.expires_utc === 'string' || typeof row.expires_utc === 'number'
159
+ ? BigInt(row.expires_utc)
160
+ : 0n;
158
161
  const expires = expiresUtc > 0n
159
162
  ? Number((expiresUtc - CHROME_EPOCH) / 1000000n)
160
163
  : -1;
@@ -179,7 +182,7 @@ function extractChromiumCookies(dbPath, domain) {
179
182
  * @returns {Array<object>} Cookies in CDP Network.setCookie format
180
183
  */
181
184
  function extractFirefoxCookies(dbPath, domain) {
182
- const db = new DatabaseSync(`file://${dbPath}?immutable=1`, { readonly: true });
185
+ const db = new DatabaseSync(`file://${dbPath}?immutable=1`, { readOnly: true });
183
186
 
184
187
  let sql = `SELECT host, name, value, path, expiry, isSecure, isHttpOnly, sameSite
185
188
  FROM moz_cookies`;
package/src/bareagent.js CHANGED
@@ -11,7 +11,10 @@
11
11
  * 300ms settle delay after actions for DOM updates.
12
12
  */
13
13
 
14
+ /// <reference path="./wearehere.d.ts" />
15
+
14
16
  import { browse, connect } from './index.js';
17
+ import { formatReadable } from './readable.js';
15
18
 
16
19
  // Optional: privacy assessment via wearehere
17
20
  let assessFn = null;
@@ -22,6 +25,14 @@ try {
22
25
  const SETTLE_MS = 300;
23
26
  const settle = () => new Promise((r) => setTimeout(r, SETTLE_MS));
24
27
 
28
+ /**
29
+ * @typedef {object} BrowseTool
30
+ * @property {string} name
31
+ * @property {string} description
32
+ * @property {object} parameters - JSON-schema-shaped parameter spec
33
+ * @property {(args?: any) => Promise<any>} execute
34
+ */
35
+
25
36
  /**
26
37
  * Create bareagent-compatible browse tools.
27
38
  * @param {object} [opts] - Options passed to connect() for session tools
@@ -42,6 +53,7 @@ export function createBrowseTools(opts = {}) {
42
53
  return await page.snapshot();
43
54
  }
44
55
 
56
+ /** @type {BrowseTool[]} */
45
57
  const tools = [
46
58
  {
47
59
  name: 'browse',
@@ -77,11 +89,20 @@ export function createBrowseTools(opts = {}) {
77
89
  pruneMode: { type: 'string', enum: ['act', 'read'], description: '"act" (default) for interactive elements only; "read" for paragraphs and long text (articles/docs).' },
78
90
  },
79
91
  },
80
- execute: async ({ pruneMode } = {}) => {
92
+ execute: async (/** @type {{ pruneMode?: string }} */ { pruneMode } = {}) => {
81
93
  const page = await getPage();
82
94
  return await page.snapshot(pruneMode ? { mode: pruneMode } : undefined);
83
95
  },
84
96
  },
97
+ {
98
+ name: 'readable',
99
+ description: 'Extract the main article as clean reading text (title + body prose, chrome stripped — Firefox Reader View engine). Use ONLY to READ/SUMMARISE article-like pages (news, blogs, docs, wiki). For interacting, or for non-article pages, use snapshot. Returns a low-confidence hint to use snapshot when the page is not an article.',
100
+ parameters: { type: 'object', properties: {} },
101
+ execute: async () => {
102
+ const page = await getPage();
103
+ return formatReadable(await page.readable());
104
+ },
105
+ },
85
106
  {
86
107
  name: 'click',
87
108
  description: 'Click an element by its ref from the snapshot. Returns the updated snapshot.',
@@ -231,7 +252,7 @@ export function createBrowseTools(opts = {}) {
231
252
  landscape: { type: 'boolean', description: 'Landscape orientation (default: false)' },
232
253
  },
233
254
  },
234
- execute: async ({ landscape } = {}) => {
255
+ execute: async (/** @type {{ landscape?: boolean }} */ { landscape } = {}) => {
235
256
  const page = await getPage();
236
257
  return await page.pdf({ landscape });
237
258
  },
@@ -245,7 +266,7 @@ export function createBrowseTools(opts = {}) {
245
266
  format: { type: 'string', enum: ['png', 'jpeg', 'webp'], description: 'Image format (default: png)' },
246
267
  },
247
268
  },
248
- execute: async ({ format } = {}) => {
269
+ execute: async (/** @type {{ format?: string }} */ { format } = {}) => {
249
270
  const page = await getPage();
250
271
  return await page.screenshot({ format });
251
272
  },
@@ -259,7 +280,7 @@ export function createBrowseTools(opts = {}) {
259
280
  ignoreCache: { type: 'boolean', description: 'Bypass HTTP cache (hard reload). Default: false.' },
260
281
  },
261
282
  },
262
- execute: async ({ ignoreCache } = {}) => actionAndSnapshot((page) => page.reload({ ignoreCache })),
283
+ execute: async (/** @type {{ ignoreCache?: boolean }} */ { ignoreCache } = {}) => actionAndSnapshot((page) => page.reload({ ignoreCache })),
263
284
  },
264
285
  {
265
286
  name: 'wait_for',
@@ -272,7 +293,7 @@ export function createBrowseTools(opts = {}) {
272
293
  timeout: { type: 'number', description: 'Timeout in ms (default: 30000)' },
273
294
  },
274
295
  },
275
- execute: async ({ text, selector, timeout } = {}) => actionAndSnapshot((page) => page.waitFor({ text, selector, timeout })),
296
+ execute: async (/** @type {{ text?: string, selector?: string, timeout?: number }} */ { text, selector, timeout } = {}) => actionAndSnapshot((page) => page.waitFor({ text, selector, timeout })),
276
297
  },
277
298
  {
278
299
  name: 'downloads',
package/src/cdp.js CHANGED
@@ -2,25 +2,35 @@
2
2
  * cdp.js — Minimal Chrome DevTools Protocol client over WebSocket.
3
3
  *
4
4
  * Sends JSON-RPC commands, receives responses and events.
5
- * Uses Node 22's built-in WebSocket (no external deps).
5
+ * Uses the `ws` package (not Node's built-in WebSocket): the built-in
6
+ * silently caps decompressed messages at ~3 MB and permanently kills the
7
+ * socket when a single CDP response (e.g. Accessibility.getFullAXTree on a
8
+ * large page) exceeds it — with no way to raise the limit. `ws` exposes
9
+ * maxPayload, so we lift the ceiling and disable compression.
6
10
  *
7
11
  * Supports flattened sessions: when a sessionId is provided,
8
12
  * it's sent at the top level of the message (not inside params).
9
13
  * Events from sessions are also dispatched by sessionId.
10
14
  */
11
15
 
16
+ import WebSocket from 'ws';
17
+
18
+ /** Lift the message ceiling well past any realistic AX/DOM payload. */
19
+ const MAX_PAYLOAD = 256 * 1024 * 1024; // 256 MB
20
+
12
21
  /**
13
22
  * Create a CDP client connected to the given WebSocket URL.
14
23
  * @param {string} wsUrl - WebSocket URL (ws://127.0.0.1:PORT/devtools/...)
15
- * @returns {Promise<CDPClient>}
24
+ * @returns {Promise<object>} CDP client ({ send, on, once, session, close })
16
25
  */
17
26
  export async function createCDP(wsUrl) {
18
- const ws = new WebSocket(wsUrl);
27
+ const ws = new WebSocket(wsUrl, { maxPayload: MAX_PAYLOAD, perMessageDeflate: false });
19
28
  let nextId = 1;
20
29
  const pending = new Map(); // id → { resolve, reject }
21
30
  const listeners = new Map(); // "method" or "sessionId:method" → Set<callback>
22
31
 
23
- await new Promise((resolve, reject) => {
32
+ /** @type {Promise<void>} */
33
+ const connected = new Promise((resolve, reject) => {
24
34
  const timeout = setTimeout(() => reject(new Error('CDP connection timeout (5s)')), 5000);
25
35
  ws.onopen = () => { clearTimeout(timeout); resolve(); };
26
36
  ws.onerror = (e) => {
@@ -28,6 +38,7 @@ export async function createCDP(wsUrl) {
28
38
  reject(new Error(`CDP WebSocket connection failed: ${e.message || 'unknown error'}`));
29
39
  };
30
40
  });
41
+ await connected;
31
42
 
32
43
  ws.onmessage = (event) => {
33
44
  const msg = JSON.parse(typeof event.data === 'string' ? event.data : event.data.toString());
package/src/chromium.js CHANGED
@@ -112,7 +112,8 @@ export function findBrowser() {
112
112
  * @param {number} [opts.port=0] - CDP port (0 = random available port)
113
113
  * @param {string} [opts.userDataDir] - Browser profile directory
114
114
  * @param {boolean} [opts.headed=false] - Launch in headed mode (with visible window)
115
- * @returns {Promise<{wsUrl: string, process: ChildProcess, port: number}>}
115
+ * @param {string} [opts.proxy] - Proxy server (e.g. 'http://host:port')
116
+ * @returns {Promise<{wsUrl: string, process: import('node:child_process').ChildProcess, port: number}>}
116
117
  */
117
118
  export async function launch(opts = {}) {
118
119
  const binary = opts.binary || findBrowser();
@@ -235,6 +236,7 @@ export async function cleanupBrowser(browser) {
235
236
  if (!browser) return;
236
237
  activeBrowsers.delete(browser);
237
238
  if (browser.process && !browser.process.killed && browser.process.exitCode === null) {
239
+ /** @type {Promise<void>} */
238
240
  const exited = new Promise((resolve) => {
239
241
  const timer = setTimeout(resolve, 2000);
240
242
  browser.process.once('exit', () => { clearTimeout(timer); resolve(); });
@@ -282,7 +284,10 @@ export async function cleanupBrowser(browser) {
282
284
  export async function getDebugUrl(port) {
283
285
  const res = await fetch(`http://127.0.0.1:${port}/json/version`);
284
286
  if (!res.ok) throw new Error(`Cannot reach browser debug port at ${port}: ${res.status}`);
285
- const data = await res.json();
287
+ const data = /** @type {{ webSocketDebuggerUrl?: string }} */ (await res.json());
288
+ if (!data.webSocketDebuggerUrl) {
289
+ throw new Error(`Browser debug port at ${port} returned no webSocketDebuggerUrl`);
290
+ }
286
291
  return data.webSocketDebuggerUrl;
287
292
  }
288
293