browser-commander 0.14.0 → 0.16.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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.16.0
4
+
5
+ ### Minor Changes
6
+
7
+ - a23c0fa: Add installed-browser profile discovery and privacy-preserving cookie import for Chrome, Edge, Brave, Chromium, and Firefox, including OS credential caching and Playwright/Puppeteer-compatible output.
8
+
9
+ ## 0.15.0
10
+
11
+ ### Minor Changes
12
+
13
+ - b3560c8: Apply automation-friendly Chromium launch defaults, including `--password-store=basic`, and add append/opt-out launch argument controls.
14
+
3
15
  ## 0.14.0
4
16
 
5
17
  ### Minor Changes
package/README.md CHANGED
@@ -103,9 +103,17 @@ const { browser, page } = await launchBrowser({
103
103
  channel: 'chrome',
104
104
  // Or: executablePath: '/usr/bin/google-chrome',
105
105
  headless: true,
106
+ extraArgs: ['--lang=en-US'],
107
+ // Per-flag escape hatch; `args` remains a compatible append-only alias.
108
+ ignoreDefaultArgs: ['--disable-infobars'],
106
109
  });
107
110
  ```
108
111
 
112
+ Browser Commander applies the documented
113
+ [automation-friendly launch defaults](../docs/feature-parity.md#automation-friendly-launch-defaults),
114
+ including `--password-store=basic` to avoid an additional OS credential
115
+ dialog. `ignoreDefaultArgs: true` omits every Browser Commander default.
116
+
109
117
  Attach to a Chrome-family browser that is already listening for CDP connections:
110
118
 
111
119
  ```javascript
@@ -118,6 +126,10 @@ const { browser, page } = await connectBrowser({
118
126
  const commander = makeBrowserCommander({ page });
119
127
  ```
120
128
 
129
+ Attachment cannot change the existing process's launch arguments. Start it
130
+ with a loopback remote-debugging port, a dedicated `--user-data-dir`, and the
131
+ automation-friendly defaults above, or use `launchRealBrowser()`.
132
+
121
133
  `launchRealBrowser()` can find and start a genuine installed Chrome,
122
134
  Edge, Brave, or Chromium with a loopback CDP endpoint and then attach to it. It
123
135
  always uses a dedicated profile; Chrome 136 and newer do not honor remote
@@ -131,6 +143,8 @@ const connection = await launchRealBrowser({
131
143
  engine: 'puppeteer',
132
144
  channel: 'chrome',
133
145
  userDataDir: '/tmp/my-automation-profile',
146
+ extraArgs: ['--lang=en-US'],
147
+ ignoreDefaultArgs: ['--disable-infobars'],
134
148
  seedCookies: [
135
149
  { name: 'session', value: 'saved', url: 'https://example.com' },
136
150
  ],
@@ -138,9 +152,44 @@ const connection = await launchRealBrowser({
138
152
  await connection.browser.close();
139
153
  ```
140
154
 
141
- Cookie seeding copies only cookies you explicitly provide; the helper does not
142
- read, decrypt, or expose cookies from a browser's default profile.
155
+ Cookie seeding copies only cookies you explicitly provide. To seed a dedicated
156
+ profile from one of your installed browser profiles, use the explicit local
157
+ cookie-import helper:
158
+
159
+ ```javascript
160
+ import {
161
+ launchAndConnectRealBrowser,
162
+ listBrowserProfiles,
163
+ readBrowserCookies,
164
+ } from 'browser-commander';
165
+
166
+ console.log(await listBrowserProfiles({ browser: 'chrome' }));
167
+
168
+ const cookies = await readBrowserCookies({
169
+ browser: 'chrome', // chrome, edge, brave, chromium, or firefox
170
+ profile: 'Default',
171
+ domainFilter: 'example.com',
172
+ cache: { ttlMinutes: 60 },
173
+ });
174
+
175
+ const connection = await launchAndConnectRealBrowser({
176
+ engine: 'playwright',
177
+ channel: 'chrome',
178
+ userDataDir: '/tmp/my-dedicated-profile',
179
+ seedCookies: cookies,
180
+ });
181
+ ```
182
+
183
+ The import stays on the local machine and runs only when called. It never sends
184
+ cookie data anywhere. Decrypted result and derived-key cache files are stored
185
+ under `~/.browser-commander/cookie-cache/` with owner-only permissions. The
186
+ default 60-minute TTL and a cross-process lock ensure that concurrent or repeated
187
+ scripts touch Keychain, libsecret/KWallet, or DPAPI at most once per TTL window.
188
+ Set `refresh: true` to force a new read, customize `cache.dir`/`ttlMinutes`, or
189
+ set `cache: false` to opt out of disk caching.
143
190
  `launchAndConnectRealBrowser()` remains available as a descriptive alias.
191
+ Remote-debugging, loopback, and profile arguments remain managed even when all
192
+ optional defaults are ignored; `headless: true` adds `--headless=new`.
144
193
 
145
194
  Reuse a saved authenticated session by passing Playwright-compatible storage
146
195
  state as a JSON file path or object. Cookies and localStorage are restored for
@@ -371,6 +420,51 @@ default browser-profile paths, protects its remote-debugging arguments, and
371
420
  returns the spawned `browserProcess`, resolved `cdpEndpoint`, executable path,
372
421
  and profile path alongside `{ browser, page }`.
373
422
  `launchAndConnectRealBrowser()` is an alias with identical behavior.
423
+ `extraArgs` appends custom switches, while `ignoreDefaultArgs` accepts a list
424
+ of defaults to omit (or `true` to omit all optional defaults). The older
425
+ `args` append option remains supported.
426
+
427
+ ### listBrowserProfiles(options)
428
+
429
+ Discover cookie-bearing profiles for Chrome, Edge, Brave, Chromium, and Firefox.
430
+ Pass an optional `browser` to narrow discovery. Each result contains
431
+ `{ browser, name, displayName, path, isDefault }`.
432
+
433
+ ### readBrowserCookies(options)
434
+
435
+ Read cookies from an installed browser profile and return the exact
436
+ `{ name, value, domain, path, expires, httpOnly, secure, sameSite }` shape used by
437
+ Playwright and Puppeteer:
438
+
439
+ ```javascript
440
+ const cookies = await readBrowserCookies({
441
+ browser: 'firefox',
442
+ profile: 'default-release', // optional; the default profile is selected first
443
+ domainFilter: 'example.com', // optional substring match
444
+ cache: { dir: './private-cookie-cache', ttlMinutes: 30 },
445
+ refresh: false,
446
+ });
447
+ ```
448
+
449
+ Platform support:
450
+
451
+ | Browser family | macOS | Linux | Windows |
452
+ | ----------------------------- | ------------------------------------ | --------------------------------------------------------------------------- | --------------------------------------------- |
453
+ | Chrome, Edge, Brave, Chromium | Keychain + AES-128-CBC (`v10`/`v11`) | libsecret/KWallet + AES-128-CBC (`v11`), or the Chromium `v10` fallback key | DPAPI-protected AES-256-GCM key (`v10`/`v11`) |
454
+ | Firefox | `cookies.sqlite` | `cookies.sqlite` | `cookies.sqlite` |
455
+
456
+ Firefox cookie values are stored directly in its local cookie database. Chromium
457
+ database version 24 domain hashes and Chrome's 1601-based timestamps are handled
458
+ automatically. Current Windows Chromium can use app-bound `v20` encryption,
459
+ which intentionally requires the browser's privileged elevation service and
460
+ cannot be decrypted by an ordinary external process. The helper reports that
461
+ boundary instead of bypassing it; use a browser-supported export or an existing
462
+ Browser Commander storage-state file for those cookies. Set
463
+ `ignoreDecryptionErrors: true` only when returning the remaining decryptable
464
+ cookies is acceptable.
465
+
466
+ Treat imported cookies like passwords: keep cache directories private, use a
467
+ short TTL, never commit them, and seed only a dedicated automation profile.
374
468
 
375
469
  ### saveStorageState(page, filePath)
376
470
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "browser-commander",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "Universal browser automation library that supports both Playwright and Puppeteer with a unified API",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -63,6 +63,7 @@
63
63
  }
64
64
  },
65
65
  "dependencies": {
66
+ "better-sqlite3": "^12.11.1",
66
67
  "log-lazy": "^1.0.4",
67
68
  "test-anywhere": "^0.9.1"
68
69
  },
@@ -72,8 +73,8 @@
72
73
  "eslint-config-prettier": "^10.1.8",
73
74
  "eslint-plugin-prettier": "^5.5.4",
74
75
  "husky": "^9.1.7",
75
- "jsdoc": "^4.0.4",
76
76
  "jscpd": "^4.0.5",
77
+ "jsdoc": "^4.0.4",
77
78
  "lint-staged": "^16.2.6",
78
79
  "playwright": "^1.56.1",
79
80
  "prettier": "^3.6.2",
@@ -0,0 +1,278 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { execFile as execFileCallback } from 'node:child_process';
3
+ import {
4
+ chmod,
5
+ mkdir,
6
+ open,
7
+ readFile,
8
+ rename,
9
+ rm,
10
+ stat,
11
+ } from 'node:fs/promises';
12
+ import path from 'node:path';
13
+ import { promisify } from 'node:util';
14
+
15
+ const DEFAULT_TTL_MINUTES = 60;
16
+ const LOCK_STALE_MILLISECONDS = 30_000;
17
+ const LOCK_WAIT_MILLISECONDS = 30_000;
18
+ const credentialPromises = new Map();
19
+ const execFile = promisify(execFileCallback);
20
+ let windowsPrincipalPromise;
21
+
22
+ function hash(value) {
23
+ return createHash('sha256').update(value).digest('hex');
24
+ }
25
+
26
+ export function clearBrowserCookieMemoryCache() {
27
+ credentialPromises.clear();
28
+ }
29
+
30
+ export function normalizeCookieCache(cache, homeDir, ttlMinutes) {
31
+ const selectedTtl = ttlMinutes ?? cache?.ttlMinutes ?? DEFAULT_TTL_MINUTES;
32
+ if (!Number.isFinite(selectedTtl) || selectedTtl < 0) {
33
+ throw new RangeError(
34
+ 'cookie cache ttlMinutes must be a non-negative number'
35
+ );
36
+ }
37
+ if (cache === false) {
38
+ return { enabled: false, ttlSeconds: selectedTtl * 60 };
39
+ }
40
+ return {
41
+ enabled: true,
42
+ dir: path.resolve(
43
+ cache?.dir ?? path.join(homeDir, '.browser-commander', 'cookie-cache')
44
+ ),
45
+ ttlSeconds: selectedTtl * 60,
46
+ };
47
+ }
48
+
49
+ function windowsPrincipal() {
50
+ windowsPrincipalPromise ??= execFile('whoami', [], {
51
+ windowsHide: true,
52
+ }).then(({ stdout }) => stdout.trim());
53
+ return windowsPrincipalPromise;
54
+ }
55
+
56
+ async function restrictOwnerOnly(targetPath, directory) {
57
+ if (process.platform === 'win32') {
58
+ const principal = await windowsPrincipal();
59
+ if (!principal) {
60
+ throw new Error('Could not identify the current Windows user');
61
+ }
62
+ const permission = directory ? '(OI)(CI)F' : 'F';
63
+ await execFile(
64
+ 'icacls',
65
+ [
66
+ targetPath,
67
+ '/inheritance:r',
68
+ '/grant:r',
69
+ `${principal}:${permission}`,
70
+ '/q',
71
+ ],
72
+ { windowsHide: true }
73
+ );
74
+ return;
75
+ }
76
+ await chmod(targetPath, directory ? 0o700 : 0o600);
77
+ }
78
+
79
+ async function ensureCacheDirectory(cacheDir) {
80
+ await mkdir(cacheDir, { recursive: true, mode: 0o700 });
81
+ await restrictOwnerOnly(cacheDir, true);
82
+ }
83
+
84
+ async function readFreshJson(filePath, ttlSeconds, now) {
85
+ try {
86
+ const value = JSON.parse(await readFile(filePath, 'utf8'));
87
+ const age = now() / 1000 - value.savedAt;
88
+ return age >= 0 && age <= ttlSeconds ? value : null;
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
93
+
94
+ async function writeOwnerOnlyJson(filePath, value) {
95
+ const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
96
+ const handle = await open(temporaryPath, 'wx', 0o600);
97
+ try {
98
+ await handle.writeFile(`${JSON.stringify(value)}\n`, 'utf8');
99
+ await handle.sync();
100
+ } finally {
101
+ await handle.close();
102
+ }
103
+ try {
104
+ await rename(temporaryPath, filePath);
105
+ } catch (error) {
106
+ await rm(temporaryPath, { force: true });
107
+ throw error;
108
+ }
109
+ await restrictOwnerOnly(filePath, false);
110
+ }
111
+
112
+ function cachePath(cacheDir, kind, identity) {
113
+ return path.join(cacheDir, `${kind}-${hash(identity)}.json`);
114
+ }
115
+
116
+ export async function readCookieResultCache({
117
+ cache,
118
+ identity,
119
+ refresh,
120
+ now = Date.now,
121
+ }) {
122
+ if (!cache.enabled || refresh) {
123
+ return null;
124
+ }
125
+ const cached = await readFreshJson(
126
+ cachePath(cache.dir, 'cookies', identity),
127
+ cache.ttlSeconds,
128
+ now
129
+ );
130
+ return cached?.kind === 'cookies' && Array.isArray(cached.cookies)
131
+ ? cached.cookies
132
+ : null;
133
+ }
134
+
135
+ export async function writeCookieResultCache({
136
+ cache,
137
+ identity,
138
+ cookies,
139
+ now = Date.now,
140
+ }) {
141
+ if (!cache.enabled) {
142
+ return;
143
+ }
144
+ await ensureCacheDirectory(cache.dir);
145
+ await writeOwnerOnlyJson(cachePath(cache.dir, 'cookies', identity), {
146
+ version: 1,
147
+ kind: 'cookies',
148
+ savedAt: now() / 1000,
149
+ cookies,
150
+ });
151
+ }
152
+
153
+ async function removeStaleLock(lockPath, now) {
154
+ try {
155
+ const lockStat = await stat(lockPath);
156
+ if (now() - lockStat.mtimeMs > LOCK_STALE_MILLISECONDS) {
157
+ await rm(lockPath, { force: true });
158
+ }
159
+ } catch {
160
+ // Another process may have released it already.
161
+ }
162
+ }
163
+
164
+ async function wait(milliseconds) {
165
+ await new Promise((resolve) => setTimeout(resolve, milliseconds));
166
+ }
167
+
168
+ async function acquireCredentialLock(lockPath, cachedPath, options) {
169
+ const startedAt = options.now();
170
+ while (options.now() - startedAt <= LOCK_WAIT_MILLISECONDS) {
171
+ try {
172
+ return await open(lockPath, 'wx', 0o600);
173
+ } catch (error) {
174
+ if (error.code !== 'EEXIST') {
175
+ throw error;
176
+ }
177
+ const cached = await readFreshJson(
178
+ cachedPath,
179
+ options.ttlSeconds,
180
+ options.now
181
+ );
182
+ if (
183
+ cached?.kind === 'derived-key' &&
184
+ (!options.refresh || cached.savedAt !== options.initialSavedAt)
185
+ ) {
186
+ return { cached };
187
+ }
188
+ await removeStaleLock(lockPath, options.now);
189
+ await wait(50);
190
+ }
191
+ }
192
+ throw new Error('timed out waiting for another cookie credential reader');
193
+ }
194
+
195
+ async function loadOrCreateCredential({
196
+ cache,
197
+ identity,
198
+ refresh,
199
+ create,
200
+ now,
201
+ metadata,
202
+ }) {
203
+ if (!cache.enabled) {
204
+ return { key: Buffer.from(await create()), savedAt: now() / 1000 };
205
+ }
206
+ await ensureCacheDirectory(cache.dir);
207
+ const cachedPath = cachePath(cache.dir, 'credential', identity);
208
+ const lockPath = `${cachedPath}.lock`;
209
+ const initial = await readFreshJson(cachedPath, cache.ttlSeconds, now);
210
+ if (!refresh && initial?.kind === 'derived-key') {
211
+ return {
212
+ key: Buffer.from(initial.key, 'base64'),
213
+ savedAt: initial.savedAt,
214
+ };
215
+ }
216
+
217
+ const acquired = await acquireCredentialLock(lockPath, cachedPath, {
218
+ initialSavedAt: initial?.savedAt,
219
+ now,
220
+ refresh,
221
+ ttlSeconds: cache.ttlSeconds,
222
+ });
223
+ if (acquired.cached) {
224
+ return {
225
+ key: Buffer.from(acquired.cached.key, 'base64'),
226
+ savedAt: acquired.cached.savedAt,
227
+ };
228
+ }
229
+
230
+ try {
231
+ const afterLock = await readFreshJson(cachedPath, cache.ttlSeconds, now);
232
+ if (
233
+ afterLock?.kind === 'derived-key' &&
234
+ (!refresh || afterLock.savedAt !== initial?.savedAt)
235
+ ) {
236
+ return {
237
+ key: Buffer.from(afterLock.key, 'base64'),
238
+ savedAt: afterLock.savedAt,
239
+ };
240
+ }
241
+ const key = Buffer.from(await create());
242
+ const savedAt = now() / 1000;
243
+ await writeOwnerOnlyJson(cachedPath, {
244
+ version: 1,
245
+ kind: 'derived-key',
246
+ savedAt,
247
+ key: key.toString('base64'),
248
+ ...metadata,
249
+ });
250
+ return { key, savedAt };
251
+ } finally {
252
+ await acquired.close();
253
+ await rm(lockPath, { force: true });
254
+ }
255
+ }
256
+
257
+ export async function getCachedCredential(options) {
258
+ const memoryIdentity = `${options.cache.dir ?? 'disabled'}:${options.identity}`;
259
+ const now = options.now ?? Date.now;
260
+ if (!options.refresh && credentialPromises.has(memoryIdentity)) {
261
+ const cached = await credentialPromises.get(memoryIdentity);
262
+ const age = now() / 1000 - cached.savedAt;
263
+ if (age >= 0 && age <= options.cache.ttlSeconds) {
264
+ return cached.key;
265
+ }
266
+ credentialPromises.delete(memoryIdentity);
267
+ }
268
+ const promise = loadOrCreateCredential({ ...options, now });
269
+ credentialPromises.set(memoryIdentity, promise);
270
+ try {
271
+ return (await promise).key;
272
+ } catch (error) {
273
+ if (credentialPromises.get(memoryIdentity) === promise) {
274
+ credentialPromises.delete(memoryIdentity);
275
+ }
276
+ throw error;
277
+ }
278
+ }
@@ -0,0 +1,148 @@
1
+ import { execFile as execFileCallback } from 'node:child_process';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { promisify } from 'node:util';
4
+
5
+ const execFile = promisify(execFileCallback);
6
+
7
+ const SAFE_STORAGE = {
8
+ brave: {
9
+ application: 'brave',
10
+ folder: 'Brave Keys',
11
+ service: 'Brave Safe Storage',
12
+ },
13
+ chrome: {
14
+ application: 'chrome',
15
+ folder: 'Chrome Keys',
16
+ service: 'Chrome Safe Storage',
17
+ },
18
+ chromium: {
19
+ application: 'chromium',
20
+ folder: 'Chromium Keys',
21
+ service: 'Chromium Safe Storage',
22
+ },
23
+ edge: {
24
+ application: 'microsoft-edge',
25
+ folder: 'Microsoft Edge Keys',
26
+ service: 'Microsoft Edge Safe Storage',
27
+ },
28
+ };
29
+
30
+ async function runCredentialCommand(command, args, environment) {
31
+ const { stdout } = await execFile(command, args, {
32
+ encoding: 'utf8',
33
+ env: environment,
34
+ maxBuffer: 1024 * 1024,
35
+ windowsHide: true,
36
+ });
37
+ return stdout.trim();
38
+ }
39
+
40
+ async function readLinuxSafeStoragePassword(browser, environment, runCommand) {
41
+ const identity = SAFE_STORAGE[browser];
42
+ try {
43
+ const password = await runCommand(
44
+ 'secret-tool',
45
+ ['lookup', 'application', identity.application],
46
+ environment
47
+ );
48
+ if (password) {
49
+ return password;
50
+ }
51
+ } catch {
52
+ // Try KWallet before reporting that v11 key storage is unavailable.
53
+ }
54
+ try {
55
+ const password = await runCommand(
56
+ 'kwallet-query',
57
+ ['-r', identity.service, '-f', identity.folder, 'kdewallet'],
58
+ environment
59
+ );
60
+ if (password) {
61
+ return password;
62
+ }
63
+ } catch {
64
+ // Fall through to the actionable error below.
65
+ }
66
+ throw new Error(
67
+ `Could not read ${identity.service} from libsecret or KWallet; install secret-tool or unlock the browser key store`
68
+ );
69
+ }
70
+
71
+ /** Read a Chromium Safe Storage password from the platform credential store. */
72
+ export async function readSafeStoragePassword({
73
+ browser,
74
+ platform = process.platform,
75
+ environment = process.env,
76
+ runCredentialCommand: runCommand = runCredentialCommand,
77
+ }) {
78
+ const identity = SAFE_STORAGE[browser];
79
+ if (!identity) {
80
+ throw new Error(`No Safe Storage identity is known for ${browser}`);
81
+ }
82
+ if (platform === 'darwin') {
83
+ const password = await runCommand(
84
+ 'security',
85
+ ['find-generic-password', '-w', '-s', identity.service],
86
+ environment
87
+ );
88
+ if (!password) {
89
+ throw new Error(`${identity.service} returned an empty password`);
90
+ }
91
+ return password;
92
+ }
93
+ if (platform === 'linux') {
94
+ return readLinuxSafeStoragePassword(browser, environment, runCommand);
95
+ }
96
+ throw new Error(`Safe Storage passwords are not used on ${platform}`);
97
+ }
98
+
99
+ const DPAPI_SCRIPT = [
100
+ '$inputBytes=[Convert]::FromBase64String($args[0])',
101
+ '$outputBytes=[Security.Cryptography.ProtectedData]::Unprotect(',
102
+ ' $inputBytes,$null,',
103
+ ' [Security.Cryptography.DataProtectionScope]::CurrentUser)',
104
+ '[Convert]::ToBase64String($outputBytes)',
105
+ ].join(';');
106
+
107
+ /** Decrypt bytes with Windows DPAPI in the current user's security context. */
108
+ export async function decryptWindowsDpapi(
109
+ encryptedValue,
110
+ { environment = process.env } = {}
111
+ ) {
112
+ const output = await runCredentialCommand(
113
+ 'powershell.exe',
114
+ [
115
+ '-NoLogo',
116
+ '-NoProfile',
117
+ '-NonInteractive',
118
+ '-Command',
119
+ DPAPI_SCRIPT,
120
+ Buffer.from(encryptedValue).toString('base64'),
121
+ ],
122
+ environment
123
+ );
124
+ return Buffer.from(output, 'base64');
125
+ }
126
+
127
+ /** Recover the legacy AES-GCM key from a Chromium Local State file. */
128
+ export async function readWindowsEncryptionKey({
129
+ localStatePath,
130
+ environment = process.env,
131
+ decryptDpapi = decryptWindowsDpapi,
132
+ }) {
133
+ let state;
134
+ try {
135
+ state = JSON.parse(await readFile(localStatePath, 'utf8'));
136
+ } catch (error) {
137
+ throw new Error(`Could not read Chromium Local State: ${error.message}`);
138
+ }
139
+ const encoded = state.os_crypt?.encrypted_key;
140
+ if (!encoded) {
141
+ throw new Error('Chromium Local State has no os_crypt.encrypted_key');
142
+ }
143
+ const encryptedKey = Buffer.from(encoded, 'base64');
144
+ if (encryptedKey.subarray(0, 5).toString('ascii') !== 'DPAPI') {
145
+ throw new Error('Chromium Local State key does not have a DPAPI prefix');
146
+ }
147
+ return decryptDpapi(encryptedKey.subarray(5), { environment });
148
+ }