barebrowse 0.14.0 → 0.17.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,162 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.17.0] - 2026-07-10
4
+
5
+ ### Added
6
+
7
+ - **Firefox/BiDi observability parity (parity plan Phase 2).** The Firefox
8
+ engine now backs the same console/network capture and network-idle wait the
9
+ Chromium/CDP engine has, over WebDriver BiDi events instead of CDP:
10
+ - **`page.waitForNetworkIdle()`** works on Firefox (was a CDP-only stub that
11
+ threw). Wired to `network.beforeRequestSent` / `responseCompleted` /
12
+ `fetchError`, reusing the same orphan-resilient idle core as the CDP path
13
+ (`waitForNetworkIdleBiDi` in `network-idle.js`).
14
+ - **Daemon console + network log capture** on Firefox: the `console-logs`,
15
+ `network-log`, and `wait-idle` CLI commands now return real data for a
16
+ `--engine firefox` session (previously empty / unsupported). Backed by
17
+ `log.entryAdded` + `network.*` events (`attachBiDiCapture` in `daemon.js`),
18
+ mirroring the CDP `Runtime.consoleAPICalled` / `Network.*` capture. BiDi's
19
+ `warn` console level is normalized to CDP's `warning` so `--level` filters
20
+ match cross-engine.
21
+
22
+ Event shapes were measured against real Firefox before wiring (per the Phase 1
23
+ lesson). Unit-tested against those shapes in `test/unit/network-idle.test.js`
24
+ and `test/unit/daemon.test.js`; live-verified in `test/integration/firefox.test.js`.
25
+ Still Firefox gaps (Phase 3+): ad/tracker block, dialog capture, `saveState`,
26
+ `waitForNavigation`, hybrid mode.
27
+
28
+ ## [0.16.1] - 2026-07-10
29
+
30
+ ### Fixed
31
+
32
+ - **Firefox consent auto-dismiss no longer risks clicking an unrelated button.**
33
+ A code review of v0.16.0 found (and reproduced) a false-positive: when a
34
+ consent *dialog* was detected but contained no in-dialog accept button, the
35
+ Firefox walker fell back to a page-wide "accept" scan that could auto-click an
36
+ unrelated `Accept all …` control elsewhere on the page (e.g. a ToS/signup
37
+ button) during `goto()`. The page-wide scan now runs **only** for banner-style
38
+ consent (no dialog container at all). Trade-off: an accept button rendered
39
+ *outside* its own dialog (some SourcePoint deployments) is no longer
40
+ auto-dismissed on Firefox — we accept that miss rather than risk a wrong
41
+ click. Regression-tested in `test/unit/consent-firefox.test.js`.
42
+
43
+ ### Documentation
44
+
45
+ - **Firefox stealth + consent known limitations** captured in the PRD
46
+ (`docs/01-product/prd.md`), slated for the Phase 5 cross-engine fidelity
47
+ harness: the consent double AX-build per navigate (bounded latency, parity
48
+ with CDP), the single-click-without-re-verify behavior (`consent: false` to
49
+ opt out), and the latent `navigator.webdriver` own-property fallback. All
50
+ three were validated in the same review; none is a correctness defect on
51
+ current engines.
52
+
53
+ ## [0.16.0] - 2026-07-10
54
+
55
+ ### Added
56
+
57
+ - **Firefox anti-detection + consent auto-dismiss (BiDi parity Phase 1).** The
58
+ two most agent-breaking Firefox gaps are closed: headless bot-detection and
59
+ cookie-consent dialogs that were never dismissed. Both now work on
60
+ `connect({ engine: 'firefox' })` (and therefore the CLI/daemon/MCP Firefox
61
+ paths), behind the same flags as the CDP engine.
62
+ - **Stealth** (`src/stealth-firefox.js`, headless only, via BiDi
63
+ `script.addPreloadScript`). Deliberately *not* a port of Chromium's
64
+ `STEALTH_SCRIPT`: a POC measured stock Firefox-under-BiDi and found it
65
+ already exposes a realistic surface (no `window.chrome`, a real plugin set,
66
+ the real GPU, a UA with no "Headless" marker) — injecting Chromium's
67
+ Chrome-shims would have *created* a spoof tell worse than the one removed.
68
+ Firefox's only real tell is `navigator.webdriver` (`true`), so it gets a
69
+ minimal script: `navigator.webdriver` hiding + canvas-fingerprint noise.
70
+ `WEBDRIVER_PATCH` and `CANVAS_NOISE_PATCH` are now exported from
71
+ `stealth.js` and shared by both engines (single-sourcing the canvas
72
+ double-XOR fix).
73
+ - **Hardened `navigator.webdriver` hiding on both engines.** The shared
74
+ `WEBDRIVER_PATCH` now *deletes* the getter off `Navigator.prototype` instead
75
+ of shadowing it with an own property. A naive override hid the value but left
76
+ `navigator.hasOwnProperty('webdriver') === true` — a tell that advanced
77
+ anti-bot checks (e.g. sannysoft's "WebDriver New") detect. After the change
78
+ `navigator.webdriver` is `undefined` and both `hasOwnProperty` and
79
+ `'webdriver' in navigator` read `false`, matching a stock browser
80
+ (POC-verified on Chromium and Firefox). This improves the Chromium engine
81
+ too, not just Firefox.
82
+ - **Consent** (`src/consent-firefox.js`). A walker over the reconstructed
83
+ nested AX tree that clicks the "accept" button via the existing BiDi
84
+ `pointerClick`. The multilingual accept/dialog patterns were extracted to
85
+ `src/consent-patterns.js`, now imported by both the CDP (`consent.js`) and
86
+ BiDi walkers, so language coverage stays single-sourced. Runs after
87
+ `goto()` behind the same `consent !== false` flag as CDP.
88
+ - **Tests:** `test/unit/consent-firefox.test.js` (pure walker: dialog
89
+ scoping, pattern priority, non-English, banner fallback, false-positive
90
+ guard) plus Firefox integration tests (webdriver hidden at page-parse time,
91
+ no `window.chrome` spoof, consent auto-dismiss + a `consent: false` control
92
+ that proves the test can fail).
93
+
94
+ ### Known gaps (Firefox engine)
95
+
96
+ - `hybrid` mode, ad/tracker blocking, and console/network log capture remain
97
+ Chromium-only (BiDi parity Phases 2–4). `saveState`, `waitForNavigation`,
98
+ and `waitForNetworkIdle` still throw a clear "not supported on the
99
+ Firefox/BiDi engine" error; download/dialog logs read empty.
100
+ - `reload()` still cannot honour `ignoreCache` (Firefox BiDi does not support
101
+ it yet).
102
+ - Consent auto-dismiss clicks once without re-verifying dismissal (the CDP
103
+ path's synthetic→real retry is unnecessary here — BiDi `pointerClick` is
104
+ already a real pointer event). Accessible-name computation remains a
105
+ high-value subset of the full W3C accname spec.
106
+
107
+ ## [0.15.0] - 2026-07-10
108
+
109
+ ### Added
110
+
111
+ - **Firefox support via WebDriver BiDi (`connect({ engine: 'firefox' })`).** CDP
112
+ is deprecated in Firefox, so Firefox is driven over the W3C-standard BiDi
113
+ protocol — a second transport (`src/bidi.js`) over the *same* `ws` dependency,
114
+ with no geckodriver and no new package. Selectable from the CLI
115
+ (`barebrowse open <url> --engine firefox`) and MCP (`BAREBROWSE_ENGINE=firefox`).
116
+ - New modules: `bidi.js` (BiDi JSON-RPC transport), `firefox.js` (launch/find/
117
+ reap), `ax-snapshot.js` (reconstructs a CDP-vocabulary ARIA tree in-page —
118
+ BiDi has no `getFullAXTree` — with implicit roles, accessible-name
119
+ computation, `aria-hidden`/visibility filtering, and shadow-DOM/`<slot>`
120
+ traversal), `firefox-page.js` (the BiDi-backed page object).
121
+ - `prune.js`, `aria.js`, and `readable.js` are reused unchanged across engines
122
+ (readable.js refactored to share `EXTRACT_EXPRESSION` + `finalizeReadable`).
123
+ - Covers `goto`, `snapshot`, `click`, `type`, `press`, `scroll`, `hover`,
124
+ `select`, `drag`, `upload`, `goBack`/`goForward`, `reload`, `screenshot`,
125
+ `pdf`, `tabs`/`switchTab`, `waitFor`, `readable`, `injectCookies`, `close`.
126
+ The navigation guard and upload sandbox are enforced on the Firefox path for
127
+ parity with CDP.
128
+ - Fidelity validated against real CDP snapshots; iframes (incl. nested +
129
+ multi-tab), shadow DOM, CSP, and SPA timing covered in
130
+ `test/integration/firefox.test.js` (18 tests).
131
+ - `goto`/`reload`/history navigation honour a timeout (a page that never
132
+ finishes loading rejects instead of hanging). Proxy honours the URL scheme
133
+ (`http`/`https` → HTTP+SSL, `socks`/`socks5`/`socks4` → SOCKS). Cookie
134
+ injection is host-scoped like CDP (shared `scopedCookiesForUrl`, not the
135
+ whole jar). Snapshot fidelity: a collapsed `<select>` surfaces as its value
136
+ (not every `<option>`), and bare text directly under `<body>` is kept.
137
+
138
+ - **Incognito mode — a clean, unauthenticated session (`incognito: true`).**
139
+ Skips ALL auth injection: no cookie extraction/injection and no
140
+ `storageState` loading, so the agent browses logged-out even though the
141
+ default throwaway profile is unchanged. The gate lives at the page-object
142
+ level, so it holds even when a caller (MCP `goto`, the daemon) injects
143
+ unconditionally. Available on `browse()`, `connect()`, both engines, MCP
144
+ (`browse` tool arg + `BAREBROWSE_INCOGNITO=1`), and CLI (`--incognito`).
145
+ Cookie injection is scoped to the target host on **both** engines via a
146
+ shared `scopedCookiesForUrl` (the Firefox path no longer loads the whole
147
+ cookie jar).
148
+
149
+ ### Known gaps (Firefox engine)
150
+
151
+ - Consent auto-dismiss, stealth patches, and `hybrid` mode remain Chromium-only.
152
+ - `reload()` cannot honour `ignoreCache` (Firefox BiDi does not support it yet).
153
+ - The CLI daemon's console/network log capture is CDP-only, so those logs are
154
+ empty for a Firefox session. `saveState`, `waitForNavigation`, and
155
+ `waitForNetworkIdle` are CDP-only too — on Firefox they throw a clear
156
+ "not supported on the Firefox/BiDi engine" error, and download/dialog logs
157
+ read empty. Accessible-name computation is a high-value subset of the full
158
+ W3C accname spec.
159
+
3
160
  ## [0.14.0] - 2026-06-15
4
161
 
5
162
  ### Documentation
package/README.md CHANGED
@@ -132,8 +132,40 @@ const snap = await page.snapshot();
132
132
  await page.close(); // closes only the tab barebrowse opened — your browser keeps running
133
133
  ```
134
134
 
135
+ ### Firefox (WebDriver BiDi)
136
+
137
+ CDP is deprecated in Firefox, so barebrowse drives it over the W3C WebDriver
138
+ BiDi protocol instead — a second transport over the same `ws` dependency, no
139
+ extra download. Same `page.*` API (the ARIA snapshot is reconstructed in-page
140
+ since BiDi has no `getFullAXTree`):
141
+
142
+ ```js
143
+ const page = await connect({ engine: 'firefox' }); // headless by default
144
+ ```
145
+
146
+ From the CLI: `barebrowse open <url> --engine firefox`. From MCP: set
147
+ `BAREBROWSE_ENGINE=firefox`. Firefox cookies (plaintext) reuse into the same
148
+ engine. Consent auto-dismiss and headless stealth work on Firefox too (as of
149
+ v0.16.0). Chromium (CDP) remains the default; `hybrid` mode, ad/tracker
150
+ blocking, and the daemon's console/network capture are still Chromium-only.
151
+
135
152
  No clone profile, no fresh cookies — the agent sees what you see.
136
153
 
154
+ ### Incognito (clean session)
155
+
156
+ Pass `incognito: true` for a clean, **unauthenticated** session — barebrowse
157
+ skips *all* auth injection (cookies + storage state), so the agent browses
158
+ logged out. It is not Chrome's `--incognito` flag: the session already runs in
159
+ a throwaway profile, so this gates the *other* auth source — your real browser
160
+ cookies. Works on both engines:
161
+
162
+ ```js
163
+ const page = await connect({ incognito: true });
164
+ ```
165
+
166
+ From the CLI: `barebrowse open <url> --incognito`. From MCP: set
167
+ `BAREBROWSE_INCOGNITO=1`.
168
+
137
169
  ## What it handles automatically
138
170
 
139
171
  Cookie consent walls (29 languages, with real mouse click fallback for stubborn CMPs), login walls (cookie extraction from your browsers), bot detection (ARIA node count heuristic + stealth patches + automatic headed fallback — snapshot shows `[BOT CHALLENGE DETECTED]` warning when blocked), permission prompts, SPA navigation, JS dialogs, off-screen elements, pre-filled inputs, ARIA noise, and profile locking. The agent doesn't think about any of it.
@@ -223,7 +255,7 @@ URL -> find/launch browser (chromium.js)
223
255
  ## Requirements
224
256
 
225
257
  - Node.js >= 22 (built-in WebSocket, built-in SQLite)
226
- - Any Chromium-based browser installed (Chrome, Chromium, Brave, Edge, Vivaldi)
258
+ - Any Chromium-based browser installed (Chrome, Chromium, Brave, Edge, Vivaldi) — or Firefox (>= 121) for the `engine: 'firefox'` BiDi path
227
259
  - Linux tested (Fedora/KDE). macOS/Windows cookie paths exist but untested.
228
260
 
229
261
  ## The bare ecosystem
@@ -46,6 +46,7 @@ const snapshot = await browse('https://example.com');
46
46
  const snapshot = await browse('https://example.com', {
47
47
  mode: 'headless', // 'headless' | 'headed' | 'hybrid'
48
48
  cookies: true, // inject user's browser cookies
49
+ incognito: false, // clean, unauthenticated session: skip ALL auth injection
49
50
  browser: 'firefox', // cookie source: 'firefox' | 'chromium' (auto-detected)
50
51
  prune: true, // apply ARIA pruning (47-95% token reduction)
51
52
  pruneMode: 'act', // 'act' (interactive elements) | 'read' (all content)
@@ -94,10 +95,12 @@ const snapshot = await browse('https://example.com', {
94
95
  | `close()` | -- | void | Close page, disconnect CDP, kill browser (if headless) |
95
96
 
96
97
  **connect() options** (in addition to mode/port/consent):
98
+ - `engine: 'chromium'|'firefox'` — Browser engine. Default `chromium` (CDP). `firefox` drives Firefox over WebDriver BiDi (CDP is deprecated there) — a separate transport with the same `page.*` API; the ARIA snapshot is reconstructed in-page since BiDi has no `getFullAXTree`. Consent auto-dismiss and stealth (headless anti-detection) work on Firefox as of v0.16.0; the CLI daemon's console/network capture and `waitForNetworkIdle` work as of v0.17.0. Still Chromium-only: `hybrid` mode and ad/tracker blocking. `reload()` ignores `ignoreCache` on Firefox (BiDi limitation).
97
99
  - `port: 9222` — Attach to a Chromium already running with `--remote-debugging-port=N` instead of spawning one. The browser keeps running on `close()`. Stealth + permission denial + download capture are skipped to avoid mutating the user's running browser.
98
100
  - `proxy: 'http://...'` — HTTP/SOCKS proxy for browser
99
101
  - `viewport: '1280x720'` — Set viewport dimensions
100
102
  - `storageState: 'file.json'` — Load cookies/localStorage from saved state
103
+ - `incognito: true|false` — Default `false`. When `true`, run a clean, unauthenticated session: skips `storageState` loading AND makes `injectCookies()` a no-op, so no auth ever enters the session even though callers (MCP `goto`, daemon) inject unconditionally. The temp profile is already throwaway; this gates the *other* auth source — the user's real browser cookies. Not Chrome's `--incognito` flag. Surfaced everywhere: library, MCP (`browse` tool param + `BAREBROWSE_INCOGNITO=1` env for the session), CLI (`--incognito`), bareagent (via `connect` opts).
101
104
  - `downloadPath: '/abs/dir'` — Where downloads land. Default: per-session `mkdtemp` under `/tmp/barebrowse-dl-*` that gets removed on `close()`. Caller-supplied paths are not cleaned up — caller owns the lifecycle.
102
105
  - `blockAds: true|false` — CDP-level URL blocking of 128 common ad/tracker patterns (Google ads/analytics, FB/Amazon/MS/Adobe ad+analytics, Segment/Amplitude/Mixpanel/Heap/PostHog, Hotjar/FullStory/LogRocket, Criteo/Taboola/Outbrain, the consumer-pixel cluster, AppNexus/Rubicon/PubMatic supply, marketing automation; v0.10.1 added AppsFlyer/Branch/Adjust, Cloudflare Web Analytics, Matomo Cloud). Default `true` for launched browsers, `false` in attach mode (would affect any tab in the user's running browser). Explicit `true` in attach mode is honored and follows the session across `switchTab()` (regression-tested). Shrinks ARIA snapshots and speeds page loads. On legacy Chromium lacking `Network.setBlockedURLs` a one-time `console.warn` surfaces the fallback.
103
106
  - `blockUrls: ['*://foo.com/*', ...]` — Extra glob patterns (CDP `Network.setBlockedURLs` format) to block in addition to the default. Merged with the default unless `blockAds: false`.
@@ -186,8 +189,8 @@ barebrowse can inject cookies from the user's real browser sessions, bypassing l
186
189
 
187
190
  | Obstacle | How | Mode |
188
191
  |---|---|---|
189
- | Cookie consent | ARIA scan + jsClick accept button, 29 languages | Both |
190
- | Consent behind iframes | JS `.click()` via DOM.resolveNode bypasses overlays, real mouse click fallback for CMPs that ignore synthetic clicks | Both |
192
+ | Cookie consent | ARIA scan for the accept button, 29 languages. CDP: `jsClick` (overlay-bypassing) then a real-mouse-click fallback for CMPs that ignore synthetic clicks. Firefox/BiDi: a single real pointer click. A page-wide accept scan runs only for banner-style consent (no dialog) — when a dialog is detected but has no in-dialog accept button, no page-wide scan runs, to avoid clicking an unrelated `Accept all …` control | Both engines |
193
+ | Consent behind iframes | Scanned/clicked in the owning frame (CDP `DOM.resolveNode`; Firefox resolves the ref in its browsing context) | Both engines |
191
194
  | Permission prompts | Launch flags + CDP Browser.setPermission auto-deny | Both |
192
195
  | Media autoplay blocked | `--autoplay-policy=no-user-gesture-required` | Both |
193
196
  | Login walls | Cookie extraction from Firefox/Chromium + CDP injection | Both |
package/cli.js CHANGED
@@ -108,9 +108,11 @@ async function cmdOpen() {
108
108
 
109
109
  const url = args[1] && !args[1].startsWith('--') ? args[1] : undefined;
110
110
  const opts = {
111
+ engine: parseFlag('--engine'),
111
112
  mode: parseFlag('--mode') || 'headless',
112
113
  port: parseFlag('--port'),
113
114
  cookies: !hasFlag('--no-cookies'),
115
+ incognito: hasFlag('--incognito') || undefined,
114
116
  browser: parseFlag('--browser'),
115
117
  timeout: parseFlag('--timeout'),
116
118
  pruneMode: parseFlag('--prune-mode') || 'act',
@@ -201,7 +203,7 @@ async function oneShot() {
201
203
  const url = args[1];
202
204
  const mode = args[2] || 'headless';
203
205
  try {
204
- const snapshot = await browse(url, { mode });
206
+ const snapshot = await browse(url, { mode, incognito: hasFlag('--incognito') || undefined });
205
207
  process.stdout.write(snapshot + '\n');
206
208
  process.exit(0);
207
209
  } catch (err) {
@@ -213,9 +215,11 @@ async function oneShot() {
213
215
  async function runDaemonInternal() {
214
216
  const { runDaemon } = await import('./src/daemon.js');
215
217
  const opts = {
218
+ engine: parseFlag('--engine'),
216
219
  mode: parseFlag('--mode') || 'headless',
217
220
  port: parseFlag('--port'),
218
221
  cookies: !hasFlag('--no-cookies'),
222
+ incognito: hasFlag('--incognito') || undefined,
219
223
  browser: parseFlag('--browser'),
220
224
  timeout: parseFlag('--timeout'),
221
225
  pruneMode: parseFlag('--prune-mode') || 'act',
@@ -480,9 +484,13 @@ Session:
480
484
  barebrowse status Check session status
481
485
 
482
486
  Open flags:
483
- --mode=headless|headed|hybrid Browser mode (default: headless)
487
+ --engine=chromium|firefox Browser engine (default: chromium/CDP;
488
+ firefox drives over WebDriver BiDi)
489
+ --mode=headless|headed|hybrid Browser mode (default: headless;
490
+ hybrid is chromium-only)
484
491
  --port=N CDP port for headed mode
485
492
  --no-cookies Skip cookie injection
493
+ --incognito Clean, unauthenticated session (skip all auth injection)
486
494
  --browser=firefox|chromium Cookie source browser
487
495
  --timeout=N Navigation timeout in ms
488
496
  --prune-mode=act|read Default pruning mode
package/mcp-server.js CHANGED
@@ -121,7 +121,18 @@ let _pageConnecting = null;
121
121
  async function getPage() {
122
122
  if (_page) return _page;
123
123
  if (_pageConnecting) return _pageConnecting;
124
- _pageConnecting = connect({ mode: 'hybrid' });
124
+ // Engine selection is a launch-time setting (the browser persists across
125
+ // tool calls), so it's an env var, not a per-request flag. Firefox is driven
126
+ // over WebDriver BiDi and has no hybrid fallback — default it to headless
127
+ // (override with BAREBROWSE_MODE=headed).
128
+ // Incognito is a launch-time setting like engine/mode: the session persists
129
+ // across tool calls, so it's an env var (BAREBROWSE_INCOGNITO=1), not a
130
+ // per-request flag. Skips all auth injection for a clean, logged-out session.
131
+ const incognito = process.env.BAREBROWSE_INCOGNITO === '1';
132
+ const connectOpts = process.env.BAREBROWSE_ENGINE === 'firefox'
133
+ ? { engine: 'firefox', mode: process.env.BAREBROWSE_MODE || 'headless', incognito }
134
+ : { mode: 'hybrid', incognito };
135
+ _pageConnecting = connect(connectOpts);
125
136
  try {
126
137
  _page = await _pageConnecting;
127
138
  return _page;
@@ -156,6 +167,7 @@ export const TOOLS = [
156
167
  url: { type: 'string', description: 'URL to browse' },
157
168
  mode: { type: 'string', enum: ['headless', 'headed', 'hybrid'], description: 'Browser mode (default: headless)' },
158
169
  pruneMode: { type: 'string', enum: ['act', 'read'], description: 'Pruning mode. "act" (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. If the page is content-heavy and act-mode returns mostly empty, retry with "read".' },
170
+ incognito: { type: 'boolean', description: 'Clean, unauthenticated session: skip cookie injection so the page loads logged-out (default: false).' },
159
171
  maxChars: { type: 'number', description: 'Max chars to return inline. Larger snapshots are saved to .barebrowse/ and a file path is returned instead. Default: 30000.' },
160
172
  },
161
173
  required: ['url'],
@@ -391,7 +403,7 @@ async function handleToolCall(name, args) {
391
403
  case 'browse': {
392
404
  let timer;
393
405
  const text = await Promise.race([
394
- browse(args.url, { mode: args.mode, pruneMode: args.pruneMode }),
406
+ browse(args.url, { mode: args.mode, pruneMode: args.pruneMode, incognito: args.incognito }),
395
407
  new Promise((_, rej) => { timer = setTimeout(() => rej(new Error('browse timed out after 60s')), 60000); }),
396
408
  ]);
397
409
  clearTimeout(timer);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "barebrowse",
3
- "version": "0.14.0",
3
+ "version": "0.17.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",
package/src/auth.js CHANGED
@@ -288,25 +288,39 @@ export function cookieDomainMatch(host, cookieDomain) {
288
288
  }
289
289
 
290
290
  /**
291
- * Extract cookies for a URL and inject them into a CDP session.
292
- * Convenience function combining extractCookies + injectCookies.
293
- * @param {object} session - CDP session handle
294
- * @param {string} url - URL to extract cookies for
295
- * @param {object} [opts] - Options passed to extractCookies
291
+ * Select the user's cookies that are in-scope for `url`. Shared by BOTH engines
292
+ * (CDP `authenticate` and Firefox/BiDi `injectCookies`) so they can't drift on
293
+ * scoping a divergence here re-opens loading the entire cookie jar into an
294
+ * agent session. Returns the filtered cookie array; never injects.
295
+ *
296
+ * Coarse SQL pre-filter strips to a registrable-ish domain so the LIKE query
297
+ * returns a superset (incl. parent-domain cookies). slice(-2) is a cheap
298
+ * heuristic — it over-selects for multi-part eTLDs (co.uk) and as a substring
299
+ * match, so the precise RFC-6265 domain-match is what actually decides which
300
+ * cookies pass. Without it, browsing apple.com would match apple.com.evil.org
301
+ * and every *.co.uk site (verified).
302
+ * @param {string} url - URL whose host the cookies must match
303
+ * @param {object} [opts] - passed to extractCookies (e.g. { browser })
304
+ * @returns {Array<object>} cookies whose domain matches the URL host
296
305
  */
297
- export async function authenticate(session, url, opts = {}) {
306
+ export function scopedCookiesForUrl(url, opts = {}) {
298
307
  const fullHost = new URL(url).hostname.toLowerCase();
299
- // Coarse SQL pre-filter: strip to a registrable-ish domain so the LIKE query
300
- // returns a superset (incl. parent-domain cookies). slice(-2) is a cheap
301
- // heuristic — it over-selects for multi-part eTLDs (co.uk) and as a substring
302
- // match, so the precise RFC-6265 domain-match below is what actually decides
303
- // which cookies get injected. Without it, browsing apple.com would inject
304
- // cookies for apple.com.evil.org and every *.co.uk site (verified).
305
308
  const noWww = fullHost.replace(/^www\./, '');
306
309
  const parts = noWww.split('.');
307
310
  const coarseDomain = parts.length > 2 ? parts.slice(-2).join('.') : noWww;
308
311
  const candidates = extractCookies({ ...opts, domain: coarseDomain });
309
- const cookies = candidates.filter((c) => cookieDomainMatch(fullHost, c.domain));
312
+ return candidates.filter((c) => cookieDomainMatch(fullHost, c.domain));
313
+ }
314
+
315
+ /**
316
+ * Extract cookies for a URL and inject them into a CDP session.
317
+ * Convenience function combining scopedCookiesForUrl + injectCookies.
318
+ * @param {object} session - CDP session handle
319
+ * @param {string} url - URL to extract cookies for
320
+ * @param {object} [opts] - Options passed to extractCookies
321
+ */
322
+ export async function authenticate(session, url, opts = {}) {
323
+ const cookies = scopedCookiesForUrl(url, opts);
310
324
  if (cookies.length > 0) {
311
325
  await injectCookies(session, cookies);
312
326
  }