barebrowse 0.1.0 → 0.2.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/src/auth.js ADDED
@@ -0,0 +1,279 @@
1
+ /**
2
+ * auth.js — Cookie extraction from browser profiles + CDP injection.
3
+ *
4
+ * Extracts cookies from Chromium/Firefox SQLite databases,
5
+ * decrypts Chromium cookies via OS keyring (KWallet or GNOME keyring),
6
+ * and injects them into a CDP session via Network.setCookie.
7
+ *
8
+ * Requires Node >= 22 (node:sqlite built-in).
9
+ */
10
+
11
+ import { DatabaseSync } from 'node:sqlite';
12
+ import { pbkdf2Sync, createDecipheriv } from 'node:crypto';
13
+ import { execSync } from 'node:child_process';
14
+ import { existsSync, readdirSync } from 'node:fs';
15
+ import { homedir } from 'node:os';
16
+
17
+ // --- Browser profile paths ---
18
+
19
+ const HOME = homedir();
20
+
21
+ const CHROMIUM_PATHS = {
22
+ chromium: `${HOME}/.config/chromium/Default/Cookies`,
23
+ chrome: `${HOME}/.config/google-chrome/Default/Cookies`,
24
+ brave: `${HOME}/.config/BraveSoftware/Brave-Browser/Default/Cookies`,
25
+ edge: `${HOME}/.config/microsoft-edge/Default/Cookies`,
26
+ vivaldi: `${HOME}/.config/vivaldi/Default/Cookies`,
27
+ };
28
+
29
+ /**
30
+ * Find first available Chromium cookie database.
31
+ * @returns {{ path: string, browser: string } | null}
32
+ */
33
+ function findChromiumCookieDb() {
34
+ for (const [browser, path] of Object.entries(CHROMIUM_PATHS)) {
35
+ if (existsSync(path)) return { path, browser };
36
+ }
37
+ return null;
38
+ }
39
+
40
+ /**
41
+ * Find Firefox default profile cookies.
42
+ * @returns {string | null} Path to cookies.sqlite
43
+ */
44
+ function findFirefoxCookieDb() {
45
+ const base = `${HOME}/.mozilla/firefox`;
46
+ try {
47
+ for (const entry of readdirSync(base)) {
48
+ if (entry.endsWith('.default-release') || entry.endsWith('.default')) {
49
+ const p = `${base}/${entry}/cookies.sqlite`;
50
+ if (existsSync(p)) return p;
51
+ }
52
+ }
53
+ } catch { /* no firefox */ }
54
+ return null;
55
+ }
56
+
57
+ // --- Chromium cookie decryption (Linux) ---
58
+
59
+ /**
60
+ * Get Chromium encryption password from OS keyring.
61
+ * Tries KWallet (KDE) first, then GNOME keyring, then fallback.
62
+ */
63
+ function getChromiumPassword() {
64
+ // KDE / KWallet
65
+ try {
66
+ const b64 = execSync(
67
+ 'kwallet-query -r "Chromium Safe Storage" -f "Chromium Keys" kdewallet',
68
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
69
+ ).trim();
70
+ if (b64) return Buffer.from(b64, 'base64').toString('binary');
71
+ } catch { /* not KDE or no entry */ }
72
+
73
+ // GNOME keyring / libsecret
74
+ try {
75
+ const pw = execSync(
76
+ 'secret-tool lookup application chrome',
77
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
78
+ ).trim();
79
+ if (pw) return pw;
80
+ } catch { /* not GNOME */ }
81
+
82
+ // Fallback when no keyring is configured
83
+ return 'peanuts';
84
+ }
85
+
86
+ /**
87
+ * Derive AES key from Chromium keyring password.
88
+ * Chrome Linux: PBKDF2-SHA1, salt='saltysalt', 1 iteration, 16-byte key.
89
+ */
90
+ function deriveKey(password) {
91
+ return pbkdf2Sync(password, 'saltysalt', 1, 16, 'sha1');
92
+ }
93
+
94
+ /**
95
+ * Decrypt a Chromium encrypted cookie value.
96
+ * @param {Uint8Array} encrypted - encrypted_value from SQLite
97
+ * @param {Buffer} aesKey - Derived AES key
98
+ * @returns {string} Decrypted cookie value
99
+ */
100
+ function decryptCookie(encrypted, aesKey) {
101
+ const buf = Buffer.from(encrypted);
102
+ if (buf.length === 0) return '';
103
+
104
+ const prefix = buf.subarray(0, 3).toString('ascii');
105
+ if (prefix !== 'v10' && prefix !== 'v11') {
106
+ // Not encrypted
107
+ return buf.toString('utf8');
108
+ }
109
+
110
+ const iv = Buffer.alloc(16, ' '); // 16 space characters
111
+ const decipher = createDecipheriv('aes-128-cbc', aesKey, iv);
112
+ const payload = buf.subarray(3);
113
+ return Buffer.concat([decipher.update(payload), decipher.final()]).toString('utf8');
114
+ }
115
+
116
+ // --- Extractors ---
117
+
118
+ /**
119
+ * Extract cookies from a Chromium-based browser.
120
+ * @param {string} dbPath - Path to Cookies SQLite database
121
+ * @param {string} [domain] - Filter by domain (e.g. '.github.com')
122
+ * @returns {Array<object>} Cookies in CDP Network.setCookie format
123
+ */
124
+ function extractChromiumCookies(dbPath, domain) {
125
+ const password = getChromiumPassword();
126
+ const aesKey = deriveKey(password);
127
+
128
+ // immutable=1 bypasses WAL lock on live databases
129
+ const db = new DatabaseSync(`file://${dbPath}?immutable=1`, { readonly: true });
130
+
131
+ let sql = `SELECT host_key, name, value, encrypted_value, path,
132
+ CAST(expires_utc AS TEXT) AS expires_utc, is_secure, is_httponly, samesite
133
+ FROM cookies`;
134
+ const params = [];
135
+ if (domain) {
136
+ sql += ` WHERE host_key LIKE ?`;
137
+ params.push(`%${domain}%`);
138
+ }
139
+
140
+ const stmt = db.prepare(sql);
141
+ const rows = params.length ? stmt.all(...params) : stmt.all();
142
+ db.close();
143
+
144
+ const SAMESITE = { 0: 'None', 1: 'Lax', 2: 'Strict' };
145
+
146
+ return rows.map((row) => {
147
+ const enc = Buffer.from(row.encrypted_value);
148
+ let value;
149
+ try {
150
+ value = enc.length > 0 ? decryptCookie(enc, aesKey) : row.value;
151
+ } catch {
152
+ value = row.value || '';
153
+ }
154
+
155
+ // Chrome timestamp: microseconds since 1601-01-01
156
+ const CHROME_EPOCH = 11644473600000000n;
157
+ const expiresUtc = row.expires_utc ? BigInt(row.expires_utc) : 0n;
158
+ const expires = expiresUtc > 0n
159
+ ? Number((expiresUtc - CHROME_EPOCH) / 1000000n)
160
+ : -1;
161
+
162
+ return {
163
+ name: row.name,
164
+ value,
165
+ domain: row.host_key,
166
+ path: row.path,
167
+ expires,
168
+ secure: row.is_secure === 1,
169
+ httpOnly: row.is_httponly === 1,
170
+ sameSite: SAMESITE[row.samesite] || 'Lax',
171
+ };
172
+ }).filter((c) => c.value); // drop empty cookies
173
+ }
174
+
175
+ /**
176
+ * Extract cookies from Firefox (no encryption).
177
+ * @param {string} dbPath - Path to cookies.sqlite
178
+ * @param {string} [domain] - Filter by domain
179
+ * @returns {Array<object>} Cookies in CDP Network.setCookie format
180
+ */
181
+ function extractFirefoxCookies(dbPath, domain) {
182
+ const db = new DatabaseSync(`file://${dbPath}?immutable=1`, { readonly: true });
183
+
184
+ let sql = `SELECT host, name, value, path, expiry, isSecure, isHttpOnly, sameSite
185
+ FROM moz_cookies`;
186
+ const params = [];
187
+ if (domain) {
188
+ sql += ` WHERE host LIKE ?`;
189
+ params.push(`%${domain}%`);
190
+ }
191
+
192
+ const stmt = db.prepare(sql);
193
+ const rows = params.length ? stmt.all(...params) : stmt.all();
194
+ db.close();
195
+
196
+ const SAMESITE = { 0: 'None', 1: 'Lax', 2: 'Strict' };
197
+
198
+ return rows.map((row) => ({
199
+ name: row.name,
200
+ value: row.value,
201
+ domain: row.host,
202
+ path: row.path,
203
+ expires: row.expiry || -1,
204
+ secure: row.isSecure === 1,
205
+ httpOnly: row.isHttpOnly === 1,
206
+ sameSite: SAMESITE[row.sameSite] || 'Lax',
207
+ })).filter((c) => c.value);
208
+ }
209
+
210
+ // --- Public API ---
211
+
212
+ /**
213
+ * Extract cookies from the user's browser, auto-detecting which browser to use.
214
+ * @param {object} [opts]
215
+ * @param {string} [opts.browser] - 'chromium', 'chrome', 'brave', 'edge', 'firefox', or 'auto'
216
+ * @param {string} [opts.domain] - Filter by domain
217
+ * @returns {Array<object>} Cookies in CDP-compatible format
218
+ */
219
+ export function extractCookies(opts = {}) {
220
+ const browser = opts.browser || 'auto';
221
+ const domain = opts.domain;
222
+
223
+ if (browser === 'firefox') {
224
+ const db = findFirefoxCookieDb();
225
+ if (!db) throw new Error('Firefox cookie database not found');
226
+ return extractFirefoxCookies(db, domain);
227
+ }
228
+
229
+ if (browser !== 'auto' && CHROMIUM_PATHS[browser]) {
230
+ const path = CHROMIUM_PATHS[browser];
231
+ if (!existsSync(path)) throw new Error(`${browser} cookie database not found at ${path}`);
232
+ return extractChromiumCookies(path, domain);
233
+ }
234
+
235
+ // Auto-detect: try Chromium browsers first, then Firefox
236
+ const chromium = findChromiumCookieDb();
237
+ if (chromium) return extractChromiumCookies(chromium.path, domain);
238
+
239
+ const firefox = findFirefoxCookieDb();
240
+ if (firefox) return extractFirefoxCookies(firefox, domain);
241
+
242
+ throw new Error('No browser cookie database found');
243
+ }
244
+
245
+ /**
246
+ * Inject cookies into a CDP session via Network.setCookie.
247
+ * @param {object} session - CDP session handle (from cdp.session())
248
+ * @param {Array<object>} cookies - Cookies from extractCookies()
249
+ */
250
+ export async function injectCookies(session, cookies) {
251
+ for (const cookie of cookies) {
252
+ await session.send('Network.setCookie', {
253
+ name: cookie.name,
254
+ value: cookie.value,
255
+ domain: cookie.domain,
256
+ path: cookie.path,
257
+ secure: cookie.secure,
258
+ httpOnly: cookie.httpOnly,
259
+ sameSite: cookie.sameSite,
260
+ expires: cookie.expires > 0 ? cookie.expires : undefined,
261
+ });
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Extract cookies for a URL and inject them into a CDP session.
267
+ * Convenience function combining extractCookies + injectCookies.
268
+ * @param {object} session - CDP session handle
269
+ * @param {string} url - URL to extract cookies for
270
+ * @param {object} [opts] - Options passed to extractCookies
271
+ */
272
+ export async function authenticate(session, url, opts = {}) {
273
+ const domain = new URL(url).hostname.replace(/^www\./, '');
274
+ const cookies = extractCookies({ ...opts, domain });
275
+ if (cookies.length > 0) {
276
+ await injectCookies(session, cookies);
277
+ }
278
+ return cookies.length;
279
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * bareagent.js — Tool adapter for bareagent's Loop.
3
+ *
4
+ * Usage:
5
+ * import { createBrowseTools } from 'barebrowse/src/bareagent.js';
6
+ * const { tools, close } = createBrowseTools();
7
+ * const result = await loop.run(messages, tools);
8
+ * await close();
9
+ *
10
+ * Action tools auto-return a fresh snapshot so the LLM always sees the result.
11
+ * 300ms settle delay after actions for DOM updates.
12
+ */
13
+
14
+ import { browse, connect } from './index.js';
15
+
16
+ const SETTLE_MS = 300;
17
+ const settle = () => new Promise((r) => setTimeout(r, SETTLE_MS));
18
+
19
+ /**
20
+ * Create bareagent-compatible browse tools.
21
+ * @param {object} [opts] - Options passed to connect() for session tools
22
+ * @returns {{ tools: Array, close: () => Promise<void> }}
23
+ */
24
+ export function createBrowseTools(opts = {}) {
25
+ let _page = null;
26
+
27
+ async function getPage() {
28
+ if (!_page) _page = await connect(opts);
29
+ return _page;
30
+ }
31
+
32
+ async function actionAndSnapshot(fn) {
33
+ const page = await getPage();
34
+ await fn(page);
35
+ await settle();
36
+ return await page.snapshot();
37
+ }
38
+
39
+ const tools = [
40
+ {
41
+ name: 'browse',
42
+ description: 'One-shot: navigate to a URL and return a pruned ARIA snapshot. Stateless.',
43
+ parameters: {
44
+ type: 'object',
45
+ properties: {
46
+ url: { type: 'string', description: 'URL to browse' },
47
+ },
48
+ required: ['url'],
49
+ },
50
+ execute: async ({ url }) => await browse(url, opts),
51
+ },
52
+ {
53
+ name: 'goto',
54
+ description: 'Navigate to a URL and return the page snapshot.',
55
+ parameters: {
56
+ type: 'object',
57
+ properties: {
58
+ url: { type: 'string', description: 'URL to navigate to' },
59
+ },
60
+ required: ['url'],
61
+ },
62
+ execute: async ({ url }) => actionAndSnapshot((page) => page.goto(url)),
63
+ },
64
+ {
65
+ name: 'snapshot',
66
+ description: 'Get the current ARIA snapshot. Returns a YAML-like tree with [ref=N] markers on interactive elements.',
67
+ parameters: { type: 'object', properties: {} },
68
+ execute: async () => {
69
+ const page = await getPage();
70
+ return await page.snapshot();
71
+ },
72
+ },
73
+ {
74
+ name: 'click',
75
+ description: 'Click an element by its ref from the snapshot. Returns the updated snapshot.',
76
+ parameters: {
77
+ type: 'object',
78
+ properties: {
79
+ ref: { type: 'string', description: 'Element ref from snapshot' },
80
+ },
81
+ required: ['ref'],
82
+ },
83
+ execute: async ({ ref }) => actionAndSnapshot((page) => page.click(ref)),
84
+ },
85
+ {
86
+ name: 'type',
87
+ description: 'Type text into an element by its ref. Returns the updated snapshot.',
88
+ parameters: {
89
+ type: 'object',
90
+ properties: {
91
+ ref: { type: 'string', description: 'Element ref from snapshot' },
92
+ text: { type: 'string', description: 'Text to type' },
93
+ clear: { type: 'boolean', description: 'Clear existing content first' },
94
+ },
95
+ required: ['ref', 'text'],
96
+ },
97
+ execute: async ({ ref, text, clear }) => actionAndSnapshot((page) => page.type(ref, text, { clear })),
98
+ },
99
+ {
100
+ name: 'press',
101
+ description: 'Press a special key (Enter, Tab, Escape, etc.). Returns the updated snapshot.',
102
+ parameters: {
103
+ type: 'object',
104
+ properties: {
105
+ key: { type: 'string', description: 'Key name (Enter, Tab, Escape, Backspace, Delete, arrows, Home, End, PageUp, PageDown, Space)' },
106
+ },
107
+ required: ['key'],
108
+ },
109
+ execute: async ({ key }) => actionAndSnapshot((page) => page.press(key)),
110
+ },
111
+ {
112
+ name: 'scroll',
113
+ description: 'Scroll the page. Returns the updated snapshot.',
114
+ parameters: {
115
+ type: 'object',
116
+ properties: {
117
+ deltaY: { type: 'number', description: 'Pixels to scroll (positive=down, negative=up)' },
118
+ },
119
+ required: ['deltaY'],
120
+ },
121
+ execute: async ({ deltaY }) => actionAndSnapshot((page) => page.scroll(deltaY)),
122
+ },
123
+ {
124
+ name: 'select',
125
+ description: 'Select a value in a dropdown/select element. Returns the updated snapshot.',
126
+ parameters: {
127
+ type: 'object',
128
+ properties: {
129
+ ref: { type: 'string', description: 'Element ref from snapshot' },
130
+ value: { type: 'string', description: 'Value or visible text to select' },
131
+ },
132
+ required: ['ref', 'value'],
133
+ },
134
+ execute: async ({ ref, value }) => actionAndSnapshot((page) => page.select(ref, value)),
135
+ },
136
+ {
137
+ name: 'screenshot',
138
+ description: 'Take a screenshot of the current page. Returns base64-encoded image.',
139
+ parameters: {
140
+ type: 'object',
141
+ properties: {
142
+ format: { type: 'string', enum: ['png', 'jpeg', 'webp'], description: 'Image format (default: png)' },
143
+ },
144
+ },
145
+ execute: async ({ format } = {}) => {
146
+ const page = await getPage();
147
+ return await page.screenshot({ format });
148
+ },
149
+ },
150
+ ];
151
+
152
+ return {
153
+ tools,
154
+ async close() {
155
+ if (_page) {
156
+ await _page.close();
157
+ _page = null;
158
+ }
159
+ },
160
+ };
161
+ }
package/src/cdp.js ADDED
@@ -0,0 +1,148 @@
1
+ /**
2
+ * cdp.js — Minimal Chrome DevTools Protocol client over WebSocket.
3
+ *
4
+ * Sends JSON-RPC commands, receives responses and events.
5
+ * Uses Node 22's built-in WebSocket (no external deps).
6
+ *
7
+ * Supports flattened sessions: when a sessionId is provided,
8
+ * it's sent at the top level of the message (not inside params).
9
+ * Events from sessions are also dispatched by sessionId.
10
+ */
11
+
12
+ /**
13
+ * Create a CDP client connected to the given WebSocket URL.
14
+ * @param {string} wsUrl - WebSocket URL (ws://127.0.0.1:PORT/devtools/...)
15
+ * @returns {Promise<CDPClient>}
16
+ */
17
+ export async function createCDP(wsUrl) {
18
+ const ws = new WebSocket(wsUrl);
19
+ let nextId = 1;
20
+ const pending = new Map(); // id → { resolve, reject }
21
+ const listeners = new Map(); // "method" or "sessionId:method" → Set<callback>
22
+
23
+ await new Promise((resolve, reject) => {
24
+ const timeout = setTimeout(() => reject(new Error('CDP connection timeout (5s)')), 5000);
25
+ ws.onopen = () => { clearTimeout(timeout); resolve(); };
26
+ ws.onerror = (e) => {
27
+ clearTimeout(timeout);
28
+ reject(new Error(`CDP WebSocket connection failed: ${e.message || 'unknown error'}`));
29
+ };
30
+ });
31
+
32
+ ws.onmessage = (event) => {
33
+ const msg = JSON.parse(typeof event.data === 'string' ? event.data : event.data.toString());
34
+
35
+ // Response to a command (has id)
36
+ if (msg.id !== undefined) {
37
+ const handler = pending.get(msg.id);
38
+ if (handler) {
39
+ pending.delete(msg.id);
40
+ if (msg.error) {
41
+ handler.reject(new Error(`CDP error: ${msg.error.message} (${msg.error.code})`));
42
+ } else {
43
+ handler.resolve(msg.result);
44
+ }
45
+ }
46
+ return;
47
+ }
48
+
49
+ // Event (has method, optionally sessionId for flattened sessions)
50
+ if (msg.method) {
51
+ // Dispatch to session-scoped listeners first
52
+ if (msg.sessionId) {
53
+ const key = `${msg.sessionId}:${msg.method}`;
54
+ const scoped = listeners.get(key);
55
+ if (scoped) {
56
+ for (const cb of scoped) cb(msg.params);
57
+ }
58
+ }
59
+ // Also dispatch to global listeners
60
+ const global = listeners.get(msg.method);
61
+ if (global) {
62
+ for (const cb of global) cb(msg.params, msg.sessionId);
63
+ }
64
+ }
65
+ };
66
+
67
+ ws.onerror = (e) => {
68
+ for (const [id, handler] of pending) {
69
+ handler.reject(new Error(`CDP WebSocket error: ${e.message || 'unknown'}`));
70
+ pending.delete(id);
71
+ }
72
+ };
73
+
74
+ const client = {
75
+ /**
76
+ * Send a CDP command and wait for the response.
77
+ * @param {string} method - CDP method (e.g. 'Page.navigate')
78
+ * @param {object} [params] - Command parameters
79
+ * @param {string} [sessionId] - Target session (for flattened mode)
80
+ * @returns {Promise<object>} Response result
81
+ */
82
+ send(method, params = {}, sessionId) {
83
+ const id = nextId++;
84
+ const msg = { id, method, params };
85
+ if (sessionId) msg.sessionId = sessionId;
86
+ return new Promise((resolve, reject) => {
87
+ pending.set(id, { resolve, reject });
88
+ ws.send(JSON.stringify(msg));
89
+ });
90
+ },
91
+
92
+ /**
93
+ * Subscribe to a CDP event.
94
+ * @param {string} method - Event name (e.g. 'Page.loadEventFired')
95
+ * @param {function} callback - Event handler
96
+ * @param {string} [sessionId] - Scope to a specific session
97
+ * @returns {function} Unsubscribe function
98
+ */
99
+ on(method, callback, sessionId) {
100
+ const key = sessionId ? `${sessionId}:${method}` : method;
101
+ if (!listeners.has(key)) listeners.set(key, new Set());
102
+ listeners.get(key).add(callback);
103
+ return () => listeners.get(key).delete(callback);
104
+ },
105
+
106
+ /**
107
+ * Wait for a specific CDP event to fire once.
108
+ * @param {string} method - Event name
109
+ * @param {number} [timeout=30000] - Timeout in ms
110
+ * @param {string} [sessionId] - Scope to a specific session
111
+ * @returns {Promise<object>} Event params
112
+ */
113
+ once(method, timeout = 30000, sessionId) {
114
+ return new Promise((resolve, reject) => {
115
+ const timer = setTimeout(() => {
116
+ unsub();
117
+ reject(new Error(`Timeout waiting for CDP event: ${method}`));
118
+ }, timeout);
119
+ const unsub = client.on(method, (params) => {
120
+ clearTimeout(timer);
121
+ unsub();
122
+ resolve(params);
123
+ }, sessionId);
124
+ });
125
+ },
126
+
127
+ /**
128
+ * Create a session-scoped handle for a specific target.
129
+ * All send/on/once calls are automatically scoped to the session.
130
+ * @param {string} sessionId
131
+ * @returns {object} Session-scoped CDP handle
132
+ */
133
+ session(sessionId) {
134
+ return {
135
+ send: (method, params = {}) => client.send(method, params, sessionId),
136
+ on: (method, callback) => client.on(method, callback, sessionId),
137
+ once: (method, timeout = 30000) => client.once(method, timeout, sessionId),
138
+ };
139
+ },
140
+
141
+ /** Close the WebSocket connection. */
142
+ close() {
143
+ ws.close();
144
+ },
145
+ };
146
+
147
+ return client;
148
+ }