@mefistofelix/cdp.js 0.1.2 → 0.3.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/README.md CHANGED
@@ -1,12 +1,16 @@
1
1
  # cdp.js
2
2
 
3
- A low-level Chromium DevTools Protocol library for Node.js. One ES module,
3
+ A low-level Chromium DevTools Protocol library for Node.js, Deno and Bun, with an
4
+ optional native Deno/Bun server for Chrome extension reverse connections. One ES module,
4
5
  standard-library imports, no runtime dependencies or root `package.json`.
5
6
 
6
7
  Use Node.js 22.19+ and an installed Chrome, Chromium or Edge. Import `cdp.js`
7
8
  directly from an ES module, for example `example.mjs`, and run `node example.mjs`.
8
9
  Browser discovery covers common Windows, macOS and Linux installation paths;
9
10
  use `executable_path` or the `CDP_BROWSER` environment variable for other paths.
11
+ The same module also runs with `bun example.mjs` or `deno run -A example.mjs`.
12
+ Direct and reverse modes are tested with Bun 1.4.3; no Bun-specific package,
13
+ configuration file or installation step is required by the library.
10
14
 
11
15
  For a published npm release:
12
16
 
@@ -20,24 +24,39 @@ import api, { util, jsrpc, browser, cdp } from '@mefistofelix/cdp.js'
20
24
 
21
25
  The npm package exports the same file and API. Direct file imports in the examples
22
26
  below remain supported. Publication metadata is isolated in `npm/`.
27
+ The package includes both `cdp_ext/` files. With `cdp_ext: true`, the manager
28
+ writes a configured extension copy into the browser's profile directory and loads
29
+ it automatically; no manual extraction or build is needed. Reverse mode still
30
+ requires Deno or Bun. In Deno, import from `npm:@mefistofelix/cdp.js`.
31
+ In Bun, use `bun add @mefistofelix/cdp.js` and the ordinary package import above.
23
32
 
24
33
  ## Features and scope
25
34
 
26
35
  - Native CDP requests through `call({ method, params })`, with native results.
27
36
  - Named browsers and targets, created and connected on demand.
37
+ - Browser proxies and named direct-mode contexts with independent proxies/storage
38
+ and automatic cleanup after their last target closes.
39
+ - Incognito targets in direct and reverse modes.
28
40
  - Attachment through WebSocket, HTTP discovery or a debugging port.
41
+ - Optional Chrome extension reverse connections through one native Deno/Bun server,
42
+ without browser remote-debugging arguments.
43
+ - Generic `chrome.x.y` extension API calls on that connection, including scripting,
44
+ tabs/windows, cookies, storage, downloads and browser settings.
29
45
  - Shared initialization for concurrent calls; reconnection and target recreation
30
46
  on subsequent use, without replaying failed commands.
31
47
  - Public object mappings and directly replaceable custom method handlers.
32
48
  - Configurable target initialization, JavaScript bindings and direct events.
49
+ - Reverse network notifications through CDP Network or extension webRequest,
50
+ including observation without debugger attachment.
33
51
  - XPath `_.click` and `_.find`, including two XPath expression rewrites.
34
52
  - Configurable browser launch arguments and profile preferences.
35
53
  - Raw WebSocket JSON-RPC transport and reusable static helpers.
36
54
 
37
55
  There is no Page class, batch API, image-processing helper, event buffer/filter
38
- API or database. Browser, target and session mappings live in memory. Chromium
56
+ API or database. Browser, context, target and session mappings live in memory. Chromium
39
57
  profiles and their preferences are ordinary files on disk. Any native CDP method
40
- supported by the connected browser remains available through `call`.
58
+ supported by the connected browser remains available through direct `call`.
59
+ The optional extension transport has the command/domain limits documented below.
41
60
 
42
61
  ## Quick start
43
62
 
@@ -100,13 +119,16 @@ import api, { util, jsrpc, browser, cdp } from './cdp.js'
100
119
  | `new cdp(options = {})` | Create a manager; does not launch a browser yet. |
101
120
  | `client.call({ method, params = {} })` | Resolve routing and return a promise for the native CDP result, or a custom handler's result. |
102
121
  | `client.evaluate_xpath(params, fn, options)` | Execute a serialized function with a normalized XPath in a target; return the native `Runtime.evaluate` result. |
103
- | `client.close()` | Close owned browser processes, disconnect attached browsers, then clear `client.browsers`. Profile directories remain. |
104
- | `new jsrpc(url, options = {})` | Open a raw WebSocket JSON-RPC connection. |
122
+ | `client.close()` | Close owned processes, disconnect attached/reverse browsers, stop the reverse server and clear browser/socket mappings. Profiles and external browsers remain. |
123
+ | `new jsrpc(urlOrSocket, options = {})` | Open a raw WebSocket JSON-RPC connection or equip an accepted socket with the same RPC methods. |
124
+ | `client.listen_reverse(options = {})` | Start the native Deno or Bun WebSocket server; returns that server with common lifecycle fields documented below. |
105
125
  | `socket.req(request, timeout_ms)` | Assign a request ID and await its native result. |
106
126
  | `socket.notify(request)` | Serialize and send a raw message; no ID allocation or response wait. |
107
127
  | `socket.close()` | Native WebSocket close; does not terminate the browser. |
108
128
  | `browser.find_executable_path()` | Synchronously return an executable path from the environment or common locations. |
109
129
  | `browser.build_args(options = {})` | Return a fresh, editable object of launch switches. |
130
+ | `browser.extension_id(directory)` | Compute Chromium's path-derived unpacked extension ID. Use the canonical absolute directory without a manifest `key`. |
131
+ | `browser.profile_extension_paths(args)` | Read the effective profile and return a promise for an object keyed by extension ID, with `{ path, enabled }` values for enabled and disabled extensions. |
110
132
  | `browser.update_profile_preferences(filename, preferences)` | Update dotted keys in a JSON preferences file; return a promise. |
111
133
  | `browser.launch(options)` | Start a local browser and return a promise for `{ proc, url }`. The caller owns the process. |
112
134
  | `util.normalize_xpath(xpath)` | Return an XPath string with the supported rewrites. |
@@ -122,7 +144,7 @@ connections or target/session routing.
122
144
 
123
145
  | Field | Meaning |
124
146
  | --- | --- |
125
- | `method` | Native name such as `Page.navigate`, or a custom name beginning with `_.`. |
147
+ | `method` | Native name such as `Page.navigate`, a custom name beginning with `_.`, or an extension API name such as `chrome.tabs.query` in reverse mode. |
126
148
  | `params` | Native parameters plus the two routing fields below. Defaults to `{}`. |
127
149
  | `params.browser` | Browser label or configuration object with `name`. Defaults to `'main'`. |
128
150
  | `params.target` | Target label or configuration object with `name`. Omitted or `null` means a browser-level request. |
@@ -170,7 +192,8 @@ For a new browser record, configuration is assembled in this order:
170
192
  2. Constructor browser options.
171
193
  3. The `params.browser` configuration object.
172
194
 
173
- `args` and `preferences` are shallow-merged separately between steps 2 and 3.
195
+ `args`, `preferences` and `local_state` are shallow-merged separately between
196
+ steps 2 and 3.
174
197
  For duplicate keys, the per-browser value wins. Arrays replace previous arrays;
175
198
  they are not concatenated during this merge.
176
199
 
@@ -179,6 +202,10 @@ At local launch, another two independent merges happen:
179
202
  - Generated CLI defaults, adjusted by high-level browser options, then raw `args`.
180
203
  - Generated profile preference defaults, then raw `preferences`.
181
204
 
205
+ After the CLI merge, a non-null/non-false `disable-extensions-except` adds
206
+ `DisableDisableExtensionsExceptCommandLineSwitch` to the final `disable-features`
207
+ list. This requested argument convention also applies to raw feature overrides.
208
+
182
209
  There is no conflict resolver across CLI flags and preferences. Each is passed
183
210
  to Chromium through its own mechanism. Raw settings can therefore contradict a
184
211
  high-level option; the library does not silently reconcile them.
@@ -195,21 +222,28 @@ local configuration.
195
222
  | Option | Type / default | Meaning |
196
223
  | --- | --- | --- |
197
224
  | `base_path` | string, `'.cdp'` | Constructor-only profile root, resolved against the working directory. |
225
+ | `reverse_server` | object, `{}` | Constructor-only settings for the shared lazy listener: `hostname` defaults to `'127.0.0.1'`, `port` to `9223`. Independent of browser attachment `host`/`port`. |
198
226
  | `name` | string, `'main'` when routing is omitted | Required in a browser configuration object; dictionary key and default profile directory label. |
199
- | `user_data_dir` | absolute path string, `<base_path>/<name>` | Chromium user-data directory containing profiles such as `Default`. The generated default is absolute; explicit overrides should be absolute too. |
227
+ | `user_data_dir` | absolute path string or `null`, `<base_path>/<name>` | Chromium user-data directory containing profiles such as `Default`. Explicit paths should be absolute. `null` omits the switch and skips manager preference-file writes; Chromium selects its system directory. |
200
228
  | `executable_path` | string, automatic discovery | Executable to spawn; takes precedence over `CDP_BROWSER`. |
201
229
  | `headless` | boolean, `false` | When true, add `headless: 'new'` and default `window-size: '1440,900'`. |
202
230
  | `windowsHide` | boolean, effective headless mode | Hide the spawned process's console on Windows. Defaults to true when the final arguments include `--headless`, false otherwise; an explicit value wins. Does not hide the parent terminal. |
203
- | `extensions` | boolean, `false` | When true, omit the two extension-disabling switches; does not install extensions. |
231
+ | `extensions` | boolean or array of directory strings, `false` | True enables normal profile extensions. An array also generates `disable-extensions-except` to load the listed local directories at startup; include `'*'` to retain enabled profile extensions. False adds the two extension-disabling switches. |
204
232
  | `images` | boolean, `true` | When false, request `blink-settings: 'imagesEnabled=false'`; controls loading, not image processing. |
233
+ | `proxy` | string or `false`, unset | A string generates `proxy-server` using native Chromium syntax; false generates `no-proxy-server`. Unset/null adds neither flag. Applies at launch, including managed extension launches. |
205
234
  | `translations` | boolean, `false` | Set translation preferences and, unless true, disable translation feature IDs. |
206
235
  | `login` | boolean, `false` | Set browser sign-in preferences and, unless true, disable sync and sign-in promotions. Does not prevent website login. |
207
236
  | `args` | object, `{}` | Raw CLI overrides applied after generated defaults; keys omit `--`. |
237
+ | `update_preferences` | boolean, presence of an effective user-data directory | Manager-only switch for writing `Preferences` and `Local State` before launch. `false` skips both files. No files are written without a directory, including when explicitly true. |
208
238
  | `preferences` | object, `{}` | Dotted JSON preference overrides applied after generated preference defaults. |
239
+ | `local_state` | object, `{}` | Dotted JSON overrides for `<user-data-dir>/Local State`, shared by profiles in that directory. No generated defaults. |
209
240
  | `connect_timeout_ms` | number, `15000` | Timeout for each discovery, launch-readiness or socket-opening wait; not one combined deadline. |
210
241
  | `request_timeout_ms` | number, `30000` | Default timeout for each protocol request on this browser's socket. |
211
242
  | `waitForDebuggerOnStart` | boolean, `false` | Ask Chromium to pause newly auto-attached targets. Applied to `Target.setAutoAttach` when connecting; not a CLI argument or target option. |
212
243
  | `websocket_url` | string, unset | Attach directly to a browser WebSocket endpoint. |
244
+ | `cdp_ext` | boolean, `false` | Launch with the reverse extension under Deno or Bun. Start one shared server automatically if needed, prepare a profile-specific extension copy and wait for its connection. |
245
+ | `reverse` | boolean, `false` | Attach to an externally launched extension instead of launching or discovering a browser. Starts the shared listener on first use under Deno or Bun. |
246
+ | `extension_id` | string, browser name for external reverse attachment | Incoming extension ID to associate with this logical browser. Set automatically for managed `cdp_ext` launches. |
213
247
  | `http_url` | string, unset | Discover the endpoint from an HTTP(S) base URL or exact `/json/version` URL. |
214
248
  | `port` | number, unset | Discover an existing browser at `http://<host>:<port>/json/version`. |
215
249
  | `host` | string, `'127.0.0.1'` | Host used with `port`. |
@@ -240,8 +274,10 @@ arbitrary locations in `PATH`.
240
274
 
241
275
  ### Attach to an existing browser
242
276
 
243
- `websocket_url` takes precedence over HTTP discovery; `http_url` takes precedence
244
- over `host`/`port`. Use one endpoint form:
277
+ With `reverse: true`, the manager waits for the extension and ignores attachment
278
+ endpoints and launch options. With `cdp_ext: true`, it launches through the extension
279
+ and ignores attachment endpoints. Otherwise, `websocket_url` takes precedence over
280
+ HTTP discovery; `http_url` takes precedence over `host`/`port`. Use one endpoint form:
245
281
 
246
282
  ```js
247
283
  await client.call({
@@ -255,13 +291,607 @@ await client.call({
255
291
  })
256
292
  ```
257
293
 
258
- The existing browser must already expose CDP. Launch flags and profile
259
- preferences are not applied to it. `client.close()` disconnects its socket and
260
- leaves that browser running.
294
+ The existing browser must already expose CDP. Launch flags, profile preferences
295
+ and Local State overrides are not applied to it. `client.close()` disconnects its
296
+ socket and leaves that browser running.
261
297
 
262
298
  `port: 9222` means **attach**. To **launch** on that port, use
263
299
  `args: { 'remote-debugging-port': 9222 }` without an attachment endpoint.
264
300
 
301
+ ## Proxy configuration
302
+
303
+ Set `proxy` in constructor browser defaults, a per-browser `params.browser`
304
+ object, or standalone `browser.launch` options:
305
+
306
+ ```js
307
+ const client = new cdp({
308
+ proxy: 'http://127.0.0.1:8080',
309
+ })
310
+ await client.call({
311
+ method: 'Page.navigate',
312
+ params: {
313
+ browser: {
314
+ name: 'socks',
315
+ proxy: 'socks5://127.0.0.1:1080',
316
+ },
317
+ target: 'page',
318
+ url: 'https://example.com/',
319
+ },
320
+ })
321
+ await client.close()
322
+ ```
323
+
324
+ `proxy: false` requests direct connections through `--no-proxy-server`.
325
+ Unset/null adds no proxy flags, preserving Chrome's system/profile selection.
326
+ Configuration is recorded on first use of a browser label and applies at launch;
327
+ it does not change an already running or externally attached browser.
328
+ HTTP, HTTPS and SOCKS proxy syntax passes through unchanged. Credentials embedded
329
+ in proxy URLs are not supported by Chrome; use native authentication instead.
330
+ The library does not add an authentication responder.
331
+
332
+ Raw `args` override generated keys independently. Use them for other proxy
333
+ controls, including bypass rules and PAC:
334
+
335
+ ```js
336
+ const client = new cdp({
337
+ args: {
338
+ 'proxy-server': 'http://127.0.0.1:8080',
339
+ 'proxy-bypass-list': '*.internal.example;localhost',
340
+ },
341
+ })
342
+ // Alternatively: args: { 'proxy-pac-url': 'https://example.com/proxy.pac' }
343
+ await client.close()
344
+ ```
345
+
346
+ Native proxy rules can use semicolon-separated strings; the generic CLI converter
347
+ still comma-joins every array. Bypass lists accept both separators. Chrome gives
348
+ `no-proxy-server` precedence, so overriding `proxy-server` alone does not cancel
349
+ that flag; override `args['no-proxy-server']: false` too if needed.
350
+ Browser proxy options affect Chrome traffic, not the runtime's endpoint discovery
351
+ or CDP transport. Chrome normally bypasses loopback addresses. To proxy them,
352
+ use `args['proxy-bypass-list']: '<-loopback>'`; in reverse mode keep the extension's
353
+ local WebSocket listener reachable. The library inserts no extra bypass rule.
354
+ See [Chromium proxy configuration](https://chromium.googlesource.com/chromium/src/+/HEAD/net/docs/proxy.md)
355
+ and [proxy switches](https://www.chromium.org/developers/design-documents/network-settings/).
356
+
357
+ ### Named contexts and different tab proxies
358
+
359
+ Direct CDP supports separate proxies and storage through browser contexts.
360
+ Configure a named context on its first target, then reference that name on other
361
+ targets in the same browser:
362
+
363
+ ```js
364
+ await client.call({
365
+ method: 'Page.navigate',
366
+ params: {
367
+ browser: 'main',
368
+ target: {
369
+ name: 'proxied-tab',
370
+ context: {
371
+ name: 'tenant-a',
372
+ proxy: 'http://127.0.0.1:8081',
373
+ create_params: {
374
+ proxyBypassList: '*.internal.example',
375
+ },
376
+ },
377
+ },
378
+ url: 'https://example.com/',
379
+ },
380
+ })
381
+ await client.call({
382
+ method: 'Page.navigate',
383
+ params: {
384
+ browser: 'main',
385
+ target: {
386
+ name: 'another-tab',
387
+ context: 'tenant-a',
388
+ },
389
+ url: 'https://example.com/another',
390
+ },
391
+ })
392
+ ```
393
+
394
+ | Context option | Default | Meaning |
395
+ | --- | --- | --- |
396
+ | `name` | required in an object | Label in this browser's public `contexts` object. A previously unused string label creates a context with native defaults. |
397
+ | `proxy` | unset | Native context proxy override. String sets `proxyServer`; false sets `'direct://'`; unset/null uses Chrome's native selection. |
398
+ | `create_params` | `{}` | Native `Target.createBrowserContext` parameters, overriding the generated `proxyServer`. |
399
+
400
+ Configuration is recorded on first use of the context label. Context names are
401
+ scoped to a browser; constructor options remain browser defaults. Concurrent
402
+ targets sharing a name create one native context. There is no `target.proxy`
403
+ option: put proxy settings on `target.context`.
404
+
405
+ Contexts have private cookies/storage and do not inherit ordinary-profile website
406
+ logins. Tabs sharing a context share its storage and proxy. Existing physical tabs
407
+ cannot move between contexts; specifying a context for an existing target in
408
+ another context rejects. Native proxy fields are experimental and unsupported
409
+ browsers return normal CDP errors.
410
+
411
+ The manager disposes a named context after its last application target disappears.
412
+ It checks native targets, including unselected popups/workers, and waits for pending
413
+ target initialization. Internal Chrome `browser_ui` targets do not retain it.
414
+ Cleanup follows destruction events and can finish after `Target.closeTarget`
415
+ returns. The `context_closed` event signals completion. Cleanup failures emit
416
+ `error` with `{ browser, error }`; the context remains available for a later attempt.
417
+ The logical record remains in `record.contexts`; its `browserContextId` becomes
418
+ null. Reusing the name creates a fresh private context with the recorded options.
419
+ Socket reconnection preserves a surviving native context; browser restart creates
420
+ a new one. Context maps are in memory, not written to profile files.
421
+
422
+ `client.close()` disposes managed contexts, including their tabs in externally
423
+ attached browsers, while preserving other external tabs and unmanaged contexts.
424
+ You can also call `Target.disposeBrowserContext` with the ID from
425
+ `client.browsers.main.contexts['tenant-a'].browserContextId`; managed state is
426
+ invalidated when that call succeeds. Raw native context creation remains available:
427
+ pass its ID through `target.create_params.browserContextId` and dispose it yourself.
428
+ Do not combine that raw ID with `target.context`. No automatic `disposeOnDetach`
429
+ option is added. See the native
430
+ [Target context API](https://chromedevtools.github.io/devtools-protocol/tot/Target/#method-createBrowserContext).
431
+
432
+ Named contexts reject explicitly in both reverse attachment and managed extension
433
+ mode. Chrome's extension/debugger APIs cannot create independent browser contexts.
434
+ Launch-time `proxy`/raw CLI flags work with `cdp_ext: true`. For changes through
435
+ an already connected extension, the existing generic `chrome.proxy.settings`
436
+ methods accept native `params.args`:
437
+
438
+ ```js
439
+ await client.call({
440
+ method: 'chrome.proxy.settings.set',
441
+ params: {
442
+ browser: 'work',
443
+ args: [{
444
+ value: {
445
+ mode: 'fixed_servers',
446
+ rules: {
447
+ singleProxy: {
448
+ scheme: 'http',
449
+ host: '127.0.0.1',
450
+ port: 8080,
451
+ },
452
+ },
453
+ },
454
+ scope: 'regular',
455
+ }],
456
+ },
457
+ })
458
+ ```
459
+
460
+ The extension already declares `proxy` permission. ChromeSetting scopes apply to
461
+ profiles/incognito, not individual tabs. `scope: 'regular'` can persist the setting
462
+ in the profile. Use the appropriate native scope or explicitly call
463
+ `chrome.proxy.settings.clear` with the same scope. Policy/extension conflicts are
464
+ reported by `chrome.proxy.settings.get`. See the
465
+ [Chrome proxy API](https://developer.chrome.com/docs/extensions/reference/api/proxy).
466
+
467
+ ### Incognito targets
468
+
469
+ Set `target: { name: 'private', incognito: true }` to create a private target.
470
+ The default is false. In direct mode, this selects the shared named context
471
+ `'incognito'` unless an explicit `context` or raw `create_params.browserContextId`
472
+ was supplied. All named CDP contexts are already private. Use different context
473
+ names for independent private sessions or proxies.
474
+
475
+ In reverse mode, this creates a native incognito tab, reusing an existing incognito
476
+ window or opening one. Enable **Allow in incognito** for the bridge in
477
+ `chrome://extensions` first; otherwise creation returns an explicit error.
478
+ The manifest's `incognito: 'spanning'` setting does not grant this user permission,
479
+ and the library never enables it automatically. Existing regular targets cannot
480
+ be changed into incognito targets. `create_params.incognito` is the native reverse
481
+ creation override; the high-level `incognito` flag also checks existing targets.
482
+
483
+ All native Chrome incognito windows share one private session. Storage clears
484
+ when the last incognito window closes; these are not independent named contexts.
485
+ Reverse incognito does not add per-tab proxies. The generic `chrome.proxy` API
486
+ can configure its native incognito scope. See
487
+ [Chrome incognito behavior](https://support.google.com/chrome/answer/95464).
488
+
489
+ ## Reverse Chrome extension transport
490
+
491
+ [cdp_ext/manifest.json](cdp_ext/manifest.json) and
492
+ [cdp_ext/cdp_ext.js](cdp_ext/cdp_ext.js) form a Manifest V3 extension for Chrome
493
+ 125+. The extension connects out to one WebSocket server. The server serves all
494
+ browser instances on the same port; each extension announces its native ID
495
+ in its first message. No remote-debugging argument is needed.
496
+
497
+ Run the server under Deno using its native
498
+ [WebSocket upgrade](https://docs.deno.com/api/deno/websockets/) or under Bun using
499
+ [Bun.serve](https://bun.sh/docs/runtime/http/websockets).
500
+ Node's direct CDP mode remains supported; `listen_reverse()` under Node rejects
501
+ with an explicit Deno/Bun requirement. There is no WebSocket framing implementation,
502
+ third-party server dependency or additional build step.
503
+
504
+ Run `deno run -A examples/reverse.mjs` or `bun examples/reverse.mjs` to launch Chrome with the extension, create
505
+ a target and evaluate `21 * 2`. Ctrl+C closes the owned browser and server.
506
+ The same mode is available per browser:
507
+
508
+ ```js
509
+ const result = await client.call({
510
+ method: 'Runtime.evaluate',
511
+ params: {
512
+ browser: {
513
+ name: 'work',
514
+ cdp_ext: true,
515
+ headless: true,
516
+ },
517
+ target: 'demo',
518
+ expression: '21 * 2',
519
+ },
520
+ })
521
+ ```
522
+
523
+ The manager copies the two extension files into `cdp_ext/` inside the effective
524
+ profile directory, sets the shared server URL in that copy's manifest and loads
525
+ it before browser startup. Without an effective user-data directory, the copy
526
+ lives under `base_path/<name>/<profile-directory>/` instead (`Default` when unset);
527
+ Chromium still uses its system profile. Profile preferences follow the existing
528
+ `update_preferences` rules.
529
+ The generated launch arguments omit remote debugging and enable this extension.
530
+ An `extensions` array also selects those directories; `extensions: true` keeps
531
+ normally enabled profile extensions through `'*'`. Raw `args` still win and can
532
+ prevent the bridge from loading or connecting, causing a connection timeout.
533
+
534
+ For external attachment, load unpacked `cdp_ext/` through `chrome://extensions`
535
+ or the [local-extension argument options](#local-extensions-at-startup).
536
+ Use its ID shown in Chrome, or `browser.extension_id(directory)`, as the
537
+ browser option `extension_id`. The logical `name` stays in the manager.
538
+
539
+ The extension configuration is read from `chrome.runtime.getManifest()`:
540
+
541
+ | Manifest `cdp` key | Default | Meaning |
542
+ | --- | --- | --- |
543
+ | `websocket_url` | `ws://127.0.0.1:9223` | The shared server endpoint. Port 9223 avoids the usual direct CDP port 9222. |
544
+ | `reconnect_ms` | `5000` | Connection retry interval while the extension worker is awake; also the empty-message heartbeat interval while connected. Use a positive value below 30000. |
545
+
546
+ The first message uses `chrome.runtime.id`, not a configured logical browser name.
547
+ For an unpacked extension without a manifest `key`, Chromium derives this ID from
548
+ its directory. Managed launches use a separate directory for each profile, so
549
+ the ID is stable across reconnects and restarts, and the manager knows which
550
+ browser it belongs to. External instances must likewise use distinct extension
551
+ directories. Moving the directory changes the ID. The server rejects duplicate
552
+ live IDs and announcements that differ from the WebSocket's `chrome-extension://`
553
+ origin. This is routing information, not an authentication credential.
554
+
555
+ ```js
556
+ import { browser, cdp } from './cdp.js'
557
+
558
+ const client = new cdp({
559
+ reverse: true,
560
+ connect_timeout_ms: 60000,
561
+ reverse_server: {
562
+ hostname: '127.0.0.1',
563
+ port: 9223,
564
+ },
565
+ })
566
+ const result = await client.call({
567
+ method: 'Runtime.evaluate',
568
+ params: {
569
+ browser: {
570
+ name: 'other',
571
+ reverse: true,
572
+ extension_id: browser.extension_id('/absolute/path/to/cdp_ext'),
573
+ },
574
+ target: 'demo',
575
+ expression: 'document.title',
576
+ },
577
+ })
578
+ console.log(result.result.value)
579
+ await client.close()
580
+ ```
581
+
582
+ Construction and direct CDP calls do not start a listener. The first browser call
583
+ using `reverse: true` or `cdp_ext: true` starts the shared server lazily, with the
584
+ constructor's `reverse_server` settings. To mix direct and reverse browsers, leave the
585
+ constructor's `reverse` default false and specify `reverse: true` in that browser's
586
+ `params.browser` object. Constructor `port` remains a direct attachment option.
587
+
588
+ The listener closes when no reverse connection, pending reverse initialization
589
+ or live owned reverse process needs it. A live owned browser keeps it available
590
+ through temporary disconnects. External disconnects can release it when nothing
591
+ else needs it; the next reverse call restarts it and waits for the extension's
592
+ retry. Direct browsers do not keep this listener alive.
593
+
594
+ `client.reverse_server` is the public shared configuration object. After startup
595
+ it contains the effective listening port, including when `port: 0` originally
596
+ requested an available port. Restarts reuse that port so existing extensions can
597
+ still reconnect. `client.server` contains the current native Deno `HttpServer` or Bun `Server`,
598
+ or is unset/null while stopped. A call arriving during shutdown waits for it to
599
+ finish before starting a replacement server.
600
+
601
+ `listen_reverse(options = client.reverse_server)` remains available to start the
602
+ listener explicitly ahead of a browser call. It returns the native runtime server;
603
+ calling it while a server is running rejects. Explicit options replace the stored
604
+ settings, with omitted fields taking the hostname/port defaults above.
605
+
606
+ Both runtimes expose `server.addr.hostname`, `server.addr.port`,
607
+ `server.shutdown()` and `server.finished`. On Bun, the library adds these fields:
608
+ `shutdown()` calls native `stop()` and resolves `finished` after shutdown.
609
+ Use `client.close()` to also close owned browsers and incoming sockets.
610
+ Bun's callback-based server sockets receive a small EventTarget adapter with
611
+ native send/close and live ready state. The same `jsrpc` handles both runtimes;
612
+ registration, request IDs, pending waits, events and session routing are shared.
613
+ Native Bun WebSocket limits still apply.
614
+
615
+ Reverse calls reuse the normal target setup, custom handlers, mappings, routed
616
+ events, request correlation, timeout and error behavior. `reverse: true` alone
617
+ attaches externally: the manager neither launches nor updates that browser.
618
+ `cdp_ext: true` owns the launched process and applies normal launch preferences.
619
+ Closing the manager closes owned processes and the server, while external
620
+ browsers and tabs remain open. The extension retries when the server returns;
621
+ the next call rebuilds discovery and sessions, preserving surviving target IDs.
622
+ Commands interrupted by disconnect are never replayed.
623
+
624
+ A WebSocket close is not proof that Chrome exited. A managed reverse connection
625
+ loss or connection timeout leaves its owned process alive. Subsequent calls wait
626
+ for that extension to reconnect; they do not spawn another process. Only the
627
+ child process's confirmed exit permits a new launch. Explicit manager `close()`
628
+ still terminates owned browsers.
629
+
630
+ The current reverse registration uses a profile-specific extension copy. It does
631
+ not assign a new logical name to an already running Chrome using a shared extension
632
+ directory. Unpacked `chrome.runtime.id` identifies the extension path, not the
633
+ user-data directory or browser executable. Shared-path registration and URL-based
634
+ bootstrap are tracked in [TODO.md](TODO.md), not implemented behavior.
635
+
636
+ Chrome's [process singleton](https://github.com/chromium/chromium/blob/main/chrome/browser/process_singleton.h)
637
+ uses the user-data directory to decide whether to forward a new launch to an
638
+ existing instance. `no-default-browser-check` only suppresses the default-browser
639
+ prompt. The current Chromium [notification callback](https://github.com/chromium/chromium/blob/main/chrome/browser/chrome_browser_main.cc)
640
+ rejects forwarding when either command line enables automation or uses headless
641
+ mode. This library generates `enable-automation` by default; a normal browser
642
+ started with `args['enable-automation']: false` can behave differently. Do not
643
+ infer ownership of the real browser from a short-lived forwarding process.
644
+
645
+ The [service-worker lifecycle](https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/lifecycle)
646
+ allows termination while offline. A 30-second Chrome alarm wakes the worker for
647
+ another connection attempt; the configured interval applies while it is awake.
648
+ Connected empty-text heartbeats keep an otherwise idle WebSocket worker active.
649
+ These are transport messages, not event buffering or polling of browser state.
650
+
651
+ ### Reverse protocol and supported commands
652
+
653
+ The first extension-to-server message is `{ "browser": "<extension ID>" }`. Subsequent
654
+ messages use existing CDP envelopes:
655
+
656
+ - Request: `{ id, method, params, sessionId? }`.
657
+ - Response: `{ id, result }` or `{ id, error: { code, message } }`.
658
+ - Notification: `{ method, params, sessionId? }`.
659
+ - Empty text: heartbeat, ignored by `jsrpc`.
660
+
661
+ `jsrpc` assigns increasing request IDs per socket, shared by all its targets and
662
+ sessions. The extension echoes each request's ID in its response; IDs internal
663
+ to `chrome.debugger` are not exposed. Replies can arrive out of order. A timed-out
664
+ request's late reply cannot resolve a newer request. A new socket starts a new
665
+ ID sequence; the extension sends replies only on the connection that received
666
+ the request, so old completions cannot enter the replacement connection.
667
+
668
+ Top-level target IDs are stringified Chrome tab IDs, stable while a tab survives.
669
+ For root commands, the envelope's `sessionId` is that tab ID: the extension sends
670
+ to its registered `{ tabId }` debuggee without a generated UUID.
671
+ `Target.attachToTarget` returns the same tab ID in `sessionId`, and root
672
+ notifications use it too. This keeps the shared manager and RPC flow unchanged;
673
+ it is a reverse routing reference, not a native root CDP session.
674
+
675
+ Worker and out-of-process iframe commands still use real flattened CDP sessions.
676
+ Their native session IDs pass through unchanged, without prefixes. The extension
677
+ uses one `sessions` object for both roots and children: a tab ID maps to `{ tabId }`,
678
+ and a native child session ID maps to `{ tabId, sessionId }`. Every target command
679
+ looks up that object and passes the debuggee directly to `chrome.debugger.sendCommand`;
680
+ there is no fallback that interprets unknown session IDs as tab IDs.
681
+ Explicit detach takes `params.sessionId` for either a tab or child
682
+ session. Logical target labels remain inside the manager. Reconnecting invalidates
683
+ attachment/setup state even though a surviving root tab's routing ID stays the same.
684
+
685
+ The extension adapts browser-level `Target.getTargets`, `getTargetInfo`,
686
+ `setDiscoverTargets`, `setAutoAttach`, `createTarget`, `attachToTarget`,
687
+ `detachFromTarget`, `closeTarget` and `activateTarget` to Chrome tab/debugger APIs.
688
+ Discovery lists tabs; attaching a selected tab exposes its native child target
689
+ events and flattened sessions. It does not attach every unselected tab.
690
+ `createTarget` accepts only `url`, `background` and `incognito`; `activateTarget` selects the
691
+ tab and focuses its window. `Browser.getVersion` returns product/user-agent data;
692
+ its protocol version is `1.3`, with empty `revision` and `jsVersion` fields.
693
+ Other browser-level commands return explicit unsupported-command errors.
694
+
695
+ Target/session commands go through
696
+ [chrome.debugger](https://developer.chrome.com/docs/extensions/reference/api/debugger),
697
+ which supports a restricted set of CDP domains. Runtime, Page, Network, DOM,
698
+ Input and Emulation are available; ServiceWorker and BackgroundService are not.
699
+ Their default setup attempts therefore appear in `target.setup_errors`; disable
700
+ those setup flags if desired. Chrome restrictions, policies and debugger detach
701
+ events remain visible. Opening DevTools on an attached tab can detach this debugger.
702
+ Firefox/Camoufox support remains planned.
703
+
704
+ Browser-level `waitForDebuggerOnStart: true` returns an explicit error in reverse
705
+ mode: the extension cannot pause newly created root tabs before attachment.
706
+ Keep its default false and create `about:blank`, finish setup, then navigate.
707
+ Normal target-side `Target.setAutoAttach` is still native pass-through for children.
708
+
709
+ ### Chrome extension API commands
710
+
711
+ In reverse mode, `chrome.<namespace>.<method>` calls the corresponding Chrome
712
+ extension method directly, including nested objects such as
713
+ `chrome.storage.session.get` and `chrome.privacy.websites.referrersEnabled.get`.
714
+ There is no method registry or CDP-name translation. A name such as
715
+ `Runtime.evaluate` still uses `chrome.debugger`; a name such as
716
+ `chrome.debugger.getTargets` calls the extension API with its native signature.
717
+ The same connection, request IDs, timeouts and RPC error envelopes handle both.
718
+ These names are extension-only; direct CDP connections do not implement them.
719
+
720
+ | Parameter | Meaning |
721
+ | --- | --- |
722
+ | `browser` | Usual manager browser label/configuration. |
723
+ | `args` | Positional native arguments as a JSON array; defaults to `[]`. One object argument is `[object]`; two arguments are `[first, second]`. |
724
+ | `callback` | Default `false`: await a native Promise or return a synchronous result. Set `true` for callback APIs: append a callback, await its first result and reject on `chrome.runtime.lastError`. |
725
+ | `target` | Optional manager target selection/setup. It does not supply a native tab ID to the extension API. Put native `target` objects inside `args`. |
726
+
727
+ ```js
728
+ const tabs = await client.call({
729
+ method: 'chrome.tabs.query',
730
+ params: {
731
+ browser: 'work',
732
+ args: [{}],
733
+ },
734
+ })
735
+ await client.call({
736
+ method: 'chrome.scripting.insertCSS',
737
+ params: {
738
+ browser: 'work',
739
+ args: [{
740
+ target: {
741
+ tabId: tabs[0].id,
742
+ },
743
+ css: 'body { background: white; }',
744
+ }],
745
+ },
746
+ })
747
+ await client.call({
748
+ method: 'chrome.storage.session.set',
749
+ params: {
750
+ browser: 'work',
751
+ args: [{
752
+ example: true,
753
+ }],
754
+ },
755
+ })
756
+ ```
757
+
758
+ Native arrays, objects, strings, booleans, numbers and `null` are returned directly;
759
+ only `undefined` becomes `{}`. The method is called on its owning object, so nested
760
+ API objects retain their native receiver. Arguments and results must be JSON data.
761
+ Callbacks can only be supplied through `callback: true`; functions, event listeners,
762
+ Ports, Blobs and other live/non-JSON objects have no remote representation.
763
+ The bridge forwards the network notifications documented below. It does not
764
+ provide generic remote registration of other extension event listeners.
765
+
766
+ `chrome.scripting.executeScript` supports `files` relative to the loaded extension
767
+ directory and returns the native array of frame results. The bridge also accepts
768
+ `func` as a function-expression string, including arrow and async functions:
769
+
770
+ ```js
771
+ const frames = await client.call({
772
+ method: 'chrome.scripting.executeScript',
773
+ params: {
774
+ browser: 'work',
775
+ args: [{
776
+ target: {
777
+ tabId,
778
+ },
779
+ world: 'MAIN',
780
+ func: '(first, second) => first + second',
781
+ args: [19, 23],
782
+ }],
783
+ },
784
+ })
785
+ console.log(frames[0].result) // 42
786
+ ```
787
+
788
+ Only string-valued `func` is adapted: a fixed native function evaluates that source
789
+ inside the destination world, then calls it with the supplied injection `args`.
790
+ The worker does not evaluate incoming source. Other injection fields remain native;
791
+ Chrome awaits returned Promises and returns its frame-result array. A bare body
792
+ such as `return 42` is not a function expression. To execute arbitrary script text,
793
+ use `func: 'code => (0, eval)(code)'` and `args: [code]`; the last expression or
794
+ resolved Promise supplies the result. Both paths have access to the destination DOM.
795
+
796
+ The native world default remains `ISOLATED`, whose content-script CSP blocks `eval`
797
+ and `new Function`. Select `MAIN` explicitly for string functions; the page's CSP
798
+ must allow string evaluation there. Both evaluators require the same `unsafe-eval`
799
+ permission, so switching between them does not bypass CSP. Native execution errors
800
+ may leave a frame result without `result`; consult the tab console as well. See
801
+ [content-script CSP](https://developer.chrome.com/docs/extensions/develop/concepts/content-scripts#content_security_policy).
802
+
803
+ CSP changes are explicit calls, never automatic. Two existing API paths are:
804
+
805
+ - With a debugger attachment, call `Page.setBypassCSP` with `enabled: true` on the
806
+ selected target **before navigation or reload**, then inject into `MAIN`.
807
+ Set `enabled: false` and reload to restore normal enforcement. This native CDP
808
+ method works through the reverse extension too; `Runtime.evaluate` already
809
+ accepts script text through the same debugger connection.
810
+ - Without a debugger, call `chrome.declarativeNetRequest.updateSessionRules` with a
811
+ `modifyHeaders` rule removing the `content-security-policy` response header.
812
+ Scope its `condition.tabIds` to the intended tab and its `resourceTypes` to
813
+ `main_frame` (include `sub_frame` for iframe navigations), then reload the tab.
814
+ Remove the rule through `removeRuleIds` when finished and reload again. Session
815
+ rules expire on browser shutdown. Header removal does not remove a CSP supplied
816
+ by HTML `<meta>`; the policy already applied to a loaded document also remains
817
+ until navigation. CSP affects the page's scripts as well as the injection.
818
+
819
+ See [Page.setBypassCSP](https://chromedevtools.github.io/devtools-protocol/tot/Page/#method-setBypassCSP)
820
+ and [declarative request rules](https://developer.chrome.com/docs/extensions/reference/api/declarativeNetRequest).
821
+ Tested on Chrome 154: both explicit paths allow `MAIN` string evaluation and DOM
822
+ access on a page served with `script-src 'none'`.
823
+
824
+ These operations are observable. Page JavaScript can probe `eval` or `new Function`
825
+ and compare the outcome with its intended CSP. Code in `MAIN` shares the page's
826
+ globals, including any replacements of those functions. There is no standard page
827
+ API reporting which extension attached a debugger, but inspector operations can
828
+ have observable effects; attachment is not guaranteed to be invisible. V8 explicitly
829
+ treats prevention of [inspector detection](https://chromium.googlesource.com/v8/v8/+/c195efb1d03b5171c376a02bca885c68792ae0f7/src/inspector/SECURITY.md)
830
+ as a non-goal. A current behavioral probe does not provide a reliable history of
831
+ every past attachment or identify the caller by itself.
832
+
833
+ For native script-text injection without changing the page's CSP,
834
+ `chrome.userScripts.execute` (Chrome 135+) accepts `js[].code`. Its default
835
+ `USER_SCRIPT` world shares the DOM but has separate JavaScript globals and is exempt
836
+ from the page's CSP. `MAIN` instead shares the page's globals and CSP.
837
+
838
+ ```js
839
+ const frames = await client.call({
840
+ method: 'chrome.userScripts.execute',
841
+ params: {
842
+ browser: 'work',
843
+ args: [{
844
+ target: {
845
+ tabId,
846
+ },
847
+ js: [{
848
+ code: 'document.querySelector("h1").dataset.injected = "yes"; Promise.resolve(42)',
849
+ }],
850
+ }],
851
+ },
852
+ })
853
+ console.log(frames[0].result) // 42
854
+ ```
855
+
856
+ The manifest includes the permission, but Chrome also requires explicit user
857
+ activation: Developer mode before Chrome 138, or **Allow User Scripts** on the
858
+ extension's details page from Chrome 138 onward. Open
859
+ `chrome://extensions/?id=<extension_id>` using `client.browsers.work.extension_id`.
860
+ If necessary, reload the extension after enabling the toggle. Neither `cdp_ext: true`
861
+ nor `extensions` enables it or adds a default profile setting. A previously enabled
862
+ profile retains its choice. Chrome stores the extension-specific
863
+ `extensions.settings.<extension_id>.user_scripts_enabled` setting; on Windows it
864
+ is in protected `Secure Preferences`, and simply writing the key into a fresh
865
+ profile did not enable access in our test. Use Chrome's toggle rather than assuming
866
+ a normal `preferences` entry can update protected extension settings.
867
+ See the native [userScripts API and activation steps](https://developer.chrome.com/docs/extensions/reference/api/userScripts).
868
+
869
+ Chrome's native `scripting.executeScript` still rejects strings supplied directly
870
+ as `func`; the bridge's fixed function makes this adaptation possible. Overriding
871
+ an actual function's `toString`, `Symbol.toPrimitive` or `Function.prototype.toString`
872
+ does not replace the source Chrome serializes. Tests on Chrome 154 confirmed that
873
+ these overrides were never called and the original function executed.
874
+
875
+ The manifest grants `<all_urls>` host access and broad desktop extension API
876
+ permissions for scripting/debugging, tabs/groups/sessions, bookmarks/history/reading
877
+ list/search, cookies/content settings, storage, downloads, management, privacy/proxy,
878
+ accessibility/font settings, alarms/idle/power, system information, notifications,
879
+ context menus, capture, clipboard/geolocation access, identity, native messaging,
880
+ messaging through GCM, offscreen documents, side panels, speech/engines,
881
+ navigation/network requests and declarative rules.
882
+ Its `action` declaration also enables `chrome.action` methods. The exact permission
883
+ list is in [cdp_ext/manifest.json](cdp_ext/manifest.json); managed launches copy it.
884
+ There are no per-method permission prompts added by the bridge.
885
+
886
+ Permissions do not bypass Chrome's native restrictions: platform-specific,
887
+ enterprise-only and channel-specific APIs are not enabled by this manifest, and
888
+ Manifest V3's policy-only `webRequestBlocking` is not requested. User gestures,
889
+ incognito/file-access grants, capture dialogs, native messaging host installations
890
+ and required extension resources still apply. Unavailable methods and native errors
891
+ reject normally. See the official [extension API reference](https://developer.chrome.com/docs/extensions/reference/api),
892
+ [permission list](https://developer.chrome.com/docs/extensions/reference/permissions-list)
893
+ and [scripting API](https://developer.chrome.com/docs/extensions/reference/api/scripting).
894
+
265
895
  ## Command-line arguments
266
896
 
267
897
  The library exposes a JavaScript API, not a command-line program. `args` refers
@@ -351,9 +981,12 @@ and [content switches](https://raw.githubusercontent.com/chromium/chromium/main/
351
981
  | `disable-features` | array below | Disable named Chromium features. |
352
982
  | `headless` | `'new'` when `headless` is true | Run without a visible browser window. |
353
983
  | `window-size` | `'1440,900'` when headless | Set initial window dimensions. |
354
- | `disable-extensions` | `true` unless `extensions` is true | Disable browser extensions. |
984
+ | `disable-extensions` | `true` unless `extensions` is true or an array | Disable browser extensions. |
355
985
  | `disable-component-extensions-with-background-pages` | same condition | Disable those component extensions. |
986
+ | `disable-extensions-except` | the `extensions` array, otherwise unset | Load local directories and exclude other normal extensions. Raw overrides win. |
356
987
  | `blink-settings` | `'imagesEnabled=false'` when `images` is false | Disable image loading through Blink settings. |
988
+ | `proxy-server` | `proxy` when it is a string | Configure the launched browser's proxy using native Chromium syntax. |
989
+ | `no-proxy-server` | `true` when `proxy` is false | Request direct connections; Chrome gives this flag precedence over other proxy flags. |
357
990
  | `disable-signin-promo-on-avatar-pill-for-testing` | `true` unless `login` is true | Suppress the avatar sign-in promotion. |
358
991
  | `disable-sync` | same condition | Disable browser sync. |
359
992
 
@@ -395,12 +1028,74 @@ These are native Chromium feature identifiers, not additional library options.
395
1028
  Unless `translations` is true, the library appends `Translate`, `TranslateToast`
396
1029
  and `EnableTranslatePdf`. Unless `login` is true, it appends
397
1030
  `DiceWebSigninInterception` and `SigninPromoOnAvatarPill`. A raw
398
- `args['disable-features']` replaces that entire generated array.
1031
+ `args['disable-features']` replaces that generated array; an active
1032
+ `disable-extensions-except` then appends its required feature ID as described below.
1033
+
1034
+ ## Local extensions at startup
1035
+
1036
+ These conventions operate before Chromium starts, without CDP installation calls
1037
+ or manual extension registration in preference files:
1038
+
1039
+ 1. An `extensions` array generates `disable-extensions-except` and omits the two
1040
+ normal extension-disabling defaults. `extensions: true` alone keeps normal
1041
+ profile loading without generating an exception list.
1042
+ 2. Raw `args` override generated switches. A final non-null/non-false
1043
+ `disable-extensions-except` automatically appends
1044
+ `DisableDisableExtensionsExceptCommandLineSwitch` to `disable-features`, once.
1045
+ Raw feature arrays or comma-separated strings retain their entries.
1046
+ 3. At launch, an exact `'*'` entry expands to enabled extension paths from the
1047
+ effective profile; duplicate paths are removed. Explicit paths remain selected.
1048
+
1049
+ ```js
1050
+ const client = new cdp({
1051
+ extensions: [
1052
+ '*',
1053
+ '/absolute/path/to/local-extension',
1054
+ ],
1055
+ })
1056
+ ```
1057
+
1058
+ The equivalent raw selection is `args['disable-extensions-except']` containing
1059
+ that array or a comma-separated string. It works independently of `extensions`;
1060
+ other raw switches remain effective. A null/false selector suppresses the generated
1061
+ exception list and its automatic feature addition. An empty array selects no
1062
+ normal extensions. Arrays passed by the caller are not mutated.
1063
+
1064
+ `browser.build_args` leaves `'*'` unexpanded. `browser.launch` reads
1065
+ `<user-data-dir>/<profile-directory or Default>/Preferences` and `Secure Preferences`
1066
+ before spawning, using the final raw directory arguments. Secure records take
1067
+ precedence for duplicate IDs. `browser.profile_extension_paths(args)` returns a
1068
+ plain object keyed by extension ID, containing both enabled and disabled records
1069
+ as `{ path, enabled }`. Paths refer to the current recorded version, including
1070
+ absolute unpacked paths. Disable reasons and legacy disabled state set
1071
+ `enabled: false`; internal component extensions and records without a path are
1072
+ omitted. Wildcard expansion takes only the entries with `enabled: true`, without
1073
+ scanning obsolete versions or activating disabled extensions. Missing files contribute no
1074
+ entries; malformed JSON and other read errors reject launch. `'*'` requires an
1075
+ explicit effective user-data directory; the system directory is not inferred.
1076
+ `update_preferences: false` does not disable this read-only expansion.
1077
+
1078
+ ```js
1079
+ const extensions = await browser.profile_extension_paths({
1080
+ 'user-data-dir': '/absolute/path/to/profile-root',
1081
+ 'profile-directory': 'Default',
1082
+ })
1083
+ // extensions[extensionId] contains { path, enabled }.
1084
+ const disabled = Object.entries(extensions).filter(([, extension]) => !extension.enabled)
1085
+ ```
1086
+
1087
+ Explicit directories must contain unpacked extensions with `manifest.json`.
1088
+ The native argument excludes other normal extensions, so include `'*'` when
1089
+ preserving enabled profile extensions. Chromium still decides whether an extension
1090
+ can load. The feature override was verified on Chrome 154; it depends on Chrome
1091
+ retaining that internal switch, rather than a stable extension-installation API.
1092
+ See [Chromium's startup loader](https://github.com/chromium/chromium/blob/main/chrome/browser/extensions/extension_service.cc).
399
1093
 
400
1094
  ## Profile preferences
401
1095
 
402
- Local launch edits the profile's JSON `Preferences` file before spawning
403
- Chromium. The effective path is:
1096
+ The `cdp` manager edits the profile's JSON `Preferences` file before spawning
1097
+ Chromium when the effective `user-data-dir` argument specifies a directory and
1098
+ `update_preferences` is not false. The path is:
404
1099
 
405
1100
  ```text
406
1101
  <args['user-data-dir']>/<args['profile-directory'] or Default>/Preferences
@@ -417,6 +1112,10 @@ preference defaults are:
417
1112
 
418
1113
  `options.preferences` overwrites these defaults by key. Preferences and switches
419
1114
  are separate layers; overriding one does not remove the other.
1115
+ `update_preferences: false` skips generated and raw preference writes, including
1116
+ `local_state`. It does not change generated CLI arguments. The default follows
1117
+ the effective directory, including raw overrides; the manager's generated named
1118
+ directory counts as a configured directory.
420
1119
 
421
1120
  ```js
422
1121
  await client.call({
@@ -454,6 +1153,49 @@ supplied object. `null` is stored as JSON null. Malformed JSON, incompatible
454
1153
  existing path values and filesystem errors propagate. It does not coordinate
455
1154
  writes with a running browser, which may overwrite the file itself.
456
1155
 
1156
+ ### Local State and the remote-debugging checkbox
1157
+
1158
+ Chrome stores settings shared by profiles in the same user-data directory in
1159
+ `<user-data-dir>/Local State`, outside `Default` or `Profile 1`. `local_state`
1160
+ instructs the `cdp` manager to update this file before launch using the same
1161
+ `browser.update_profile_preferences` helper and preservation rules.
1162
+ The effective raw `args['user-data-dir']` override
1163
+ selects its location; `profile-directory` does not affect it.
1164
+
1165
+ For Chrome's `chrome://inspect/#remote-debugging` checkbox:
1166
+
1167
+ ```js
1168
+ const client = new cdp({
1169
+ local_state: {
1170
+ 'devtools.remote_debugging.user-enabled': true,
1171
+ },
1172
+ })
1173
+ ```
1174
+
1175
+ Set the key to `false` to disable it. The helper can also edit this file directly,
1176
+ before starting Chrome:
1177
+
1178
+ ```js
1179
+ await browser.update_profile_preferences('/absolute/user-data-dir/Local State', {
1180
+ 'devtools.remote_debugging.user-enabled': true,
1181
+ })
1182
+ ```
1183
+
1184
+ This is the user-enabled setting, distinct from the administrator policy
1185
+ `devtools.remote_debugging.allowed`. Chrome's native mode requires approval for
1186
+ incoming connections; setting the preference does not remove that prompt.
1187
+ The library's normal launch still uses `--remote-debugging-port`, which takes
1188
+ precedence over this mode. See the [Chromium implementation](https://github.com/chromium/chromium/blob/main/chrome/browser/devtools/remote_debugging_server.cc).
1189
+
1190
+ `user_data_dir: null`, or a raw `args['user-data-dir']: null`/`false`, omits the
1191
+ directory switch. Direct `browser.launch` also omits it when no directory is
1192
+ provided. The manager skips both `preferences` and `local_state` writes in that
1193
+ case; it does not discover or edit the system directory. Chrome 136+ ignores
1194
+ the debugging-port switch for its system directory, so omitting the directory
1195
+ does not provide a working CDP connection through the normal launcher.
1196
+ Attach using an existing endpoint when using Chrome's native approval mode.
1197
+ See [Chrome's debugging-port restrictions](https://developer.chrome.com/blog/remote-debugging-port).
1198
+
457
1199
  ## Overlaps and override examples
458
1200
 
459
1201
  | Settings | Effective library behavior |
@@ -464,10 +1206,15 @@ writes with a running browser, which may overwrite the file itself.
464
1206
  | Explicit `windowsHide: true` or `false` | Pass that value to Node's `spawn`, regardless of headless mode. |
465
1207
  | `extensions: true` plus `args['disable-extensions']: true` | The raw extension-disabling switch still wins. |
466
1208
  | `extensions: false` plus only `args['disable-extensions']: false` | Remove that flag, but retain `disable-component-extensions-with-background-pages`. Use `extensions: true` to omit both generated flags. |
1209
+ | `extensions: ['*', directory]` | Select enabled profile extensions plus the explicit local directory before startup. |
1210
+ | An `extensions` array plus a raw `disable-extensions-except` | The raw selection replaces the generated array; null/false suppresses that selection. |
467
1211
  | `images: false` plus an overridden `blink-settings` | Replace the whole string; image disabling is not appended to the caller's value. |
1212
+ | A string `proxy` plus `args['proxy-server']` | The raw proxy-server value replaces the generated one. |
1213
+ | `proxy: false` plus only `args['proxy-server']` | Retain `no-proxy-server`; Chrome still selects direct connections. Override `args['no-proxy-server']: false` to remove it. |
1214
+ | `proxy` plus an attachment endpoint | The launch option is unused; no running browser proxy is changed. |
468
1215
  | `translations: false` plus `preferences['translate.enabled']: true` | Store true in the profile, while generated translation-disabling feature IDs remain on the CLI. |
469
1216
  | `login: true` plus `args['disable-sync']: true` | Allow profile sign-in through generated preferences while still requesting sync to be disabled. |
470
- | Any high-level settings plus a raw `disable-features` array | Replace the whole generated list, including translation/sign-in additions. |
1217
+ | Any high-level settings plus a raw `disable-features` array | Replace the generated list, including translation/sign-in additions; an active `disable-extensions-except` still appends its required feature ID. |
471
1218
  | `user_data_dir` plus `args['user-data-dir']` | The raw argument determines both the profile write location and the launched browser directory. |
472
1219
  | `port` plus `args['remote-debugging-port']` | Attach using `port`; no launch occurs, so the raw launch argument is unused. |
473
1220
  | Constructor options plus an existing browser label | Reuse the stored record; configuration is not merged again on every call. |
@@ -524,6 +1271,8 @@ await client.call({
524
1271
  | --- | --- | --- |
525
1272
  | `name` | required in an object | Logical label in this browser's `targets` mapping. |
526
1273
  | `targetId` | `null` | Optional existing physical target ID. Reused if discovered; otherwise create a new target. |
1274
+ | `context` | unset | Direct-mode context name or first-use configuration object; see named contexts above. Reverse mode rejects this option. |
1275
+ | `incognito` | `false` | Create/select a private target; direct mode uses the shared `'incognito'` context by default, reverse uses a native incognito window with the user's extension grant. |
527
1276
  | `create_params` | `{}` | Native creation overrides, merged over `url: 'about:blank'` and `background: true`; start from a blank target without requesting foreground activation. |
528
1277
  | `initialize` | `true` | Run the convenience setup sequence; false skips it. |
529
1278
  | `runtime` | `'bootstrap'` | Enable Runtime for setup, then disable reporting. `true` leaves it enabled; `false` skips both operations. |
@@ -696,6 +1445,46 @@ browser/target resolution, may be synchronous or asynchronous, and define their
696
1445
  own return value. Their exceptions propagate. An unregistered `_.` name rejects
697
1446
  without launching a browser.
698
1447
 
1448
+ ## Reverse network notifications
1449
+
1450
+ Reverse mode exposes two independent streams through the same JSON-RPC connection:
1451
+
1452
+ | Stream | Debugger required | Data |
1453
+ | --- | --- | --- |
1454
+ | `Network.*` | Yes | Native CDP events after `Network.enable`, normally enabled by target setup. Native commands can retrieve response bodies where supported. |
1455
+ | `chrome.webRequest.*` | No | Native extension request lifecycle details for accessible tabs, including request/response headers. No response bodies or WebSocket frames. |
1456
+
1457
+ The extension always forwards `onBeforeRequest`, `onBeforeSendHeaders`,
1458
+ `onSendHeaders`, `onHeadersReceived`, `onAuthRequired`, `onResponseStarted`,
1459
+ `onBeforeRedirect`, `onCompleted` and `onErrorOccurred` under the
1460
+ `chrome.webRequest.` prefix while connected. `network: false` skips debugger
1461
+ Network setup but does not disable this extension stream. No debugger attachment
1462
+ is required to observe a tab created/navigated through `chrome.tabs` calls.
1463
+
1464
+ ```js
1465
+ client.addEventListener('chrome.webRequest.onCompleted', event => {
1466
+ const { browser, target, tabId, requestId, url, statusCode } = event.detail
1467
+ console.log(browser, target, tabId, requestId, url, statusCode)
1468
+ })
1469
+ ```
1470
+
1471
+ Raw notifications have `{ method, params, sessionId }` and no request `id`.
1472
+ `params` contains native Chrome details. When `tabId >= 0`, `sessionId` is its
1473
+ string form, allowing normal manager routing even without a debugger session.
1474
+ Requests with `tabId: -1` have no target routing reference. Browser labels are
1475
+ added by the manager; target labels are added only for known logical targets.
1476
+ `requestId` belongs to the extension API and is not a CDP Network request ID.
1477
+ Raw socket notifications remain unchanged.
1478
+
1479
+ The manifest includes `webRequest`, `webRequestAuthProvider` and `<all_urls>`.
1480
+ `onAuthRequired` is observation only: no authentication response is supplied.
1481
+ Incognito traffic requires the bridge's **Allow in incognito** grant. Chrome
1482
+ restrictions, cached requests and protected URLs can limit visibility. Sensitive
1483
+ headers and some preflights require native `extraHeaders`, which this stream does
1484
+ not request. There is no library filtering, buffering, replay, interception or
1485
+ generic remote event registration; disconnected notifications are dropped.
1486
+ See the [Chrome webRequest API](https://developer.chrome.com/docs/extensions/reference/api/webRequest).
1487
+
699
1488
  ## Events and bindings
700
1489
 
701
1490
  | Emitter / event | `event.detail` |
@@ -703,6 +1492,9 @@ without launching a browser.
703
1492
  | `client`, `notify` | CDP message with browser and, when known, target labels added to `params`. |
704
1493
  | `client`, native method such as `Runtime.bindingCalled` | That message's routed parameters. |
705
1494
  | `client`, `close` | `{ browser, code }` for closure of the current browser socket. |
1495
+ | `client`, `context_closed` | `{ browser, context, browserContextId }` after successful managed context disposal. |
1496
+ | `client`, `error` | `{ browser, error }` when asynchronous managed context cleanup fails. |
1497
+ | `client`, `chrome.webRequest.*` | Native extension request details with browser and, when known, target labels. Reverse only; does not require debugger attachment. |
706
1498
  | `socket`, `notify` | The unmodified CDP notification from the raw transport. |
707
1499
 
708
1500
  Labels are strings, not record objects. Native `sessionId` remains available on
@@ -748,13 +1540,20 @@ dictionary containers have a null prototype.
748
1540
  | Path | Contents |
749
1541
  | --- | --- |
750
1542
  | `client.base_path` | Resolved profile root. |
751
- | `client.options` | Constructor browser defaults, excluding `base_path`. |
1543
+ | `client.options` | Constructor browser defaults, excluding `base_path` and `reverse_server`. |
1544
+ | `client.reverse_server` | Shared listener configuration; the effective hostname/port are stored after startup. |
752
1545
  | `client.custom_methods[name]` | Handler function. |
753
1546
  | `client.browsers[name]` | Browser configuration fields plus live state. |
1547
+ | `client.reverse_sockets[extension_id]` | Announced incoming socket, available even before the first call; native on Deno, an EventTarget adapter on Bun. |
1548
+ | `record.extension_id`, `record.extension_path` | Expected extension identity and the managed copy's canonical directory. |
1549
+ | `client.server` | Current native Deno/Bun reverse server with shared lifecycle fields, or unset/null when not listening. |
1550
+ | `client.server_closing` | Shutdown promise while the reverse listener is stopping, otherwise unset/null. |
754
1551
  | `record.socket` | Current `jsrpc` connection once created. |
755
1552
  | `record.proc` | Owned child process; absent for external attachment. |
756
1553
  | `record.connecting` | In-progress connection promise, cleared after settlement. |
757
1554
  | `record.targets[label]` | Target options, physical `targetId`, `sessionId`, `connecting` and `setup_errors` after setup. |
1555
+ | `record.contexts[name]` | Recorded context options, native `browserContextId` or null, and pending `connecting`/`closing` promises. Direct mode only. |
1556
+ | `record.context_checking` | Pending automatic empty-context cleanup, otherwise unset/null. |
758
1557
  | `record.target_info[targetId]` | Discovered native target info plus cached `sessionId` when attached. |
759
1558
  | `record.session_targets[sessionId]` | Physical `targetId` for a session. |
760
1559
  | `socket.pending[id]` | Response-wait promise, removed on completion. |
@@ -800,15 +1599,29 @@ try {
800
1599
  }
801
1600
  ```
802
1601
 
803
- `browser.launch` requires a usable absolute directory through `user_data_dir` or
804
- `args['user-data-dir']`; this helper does not resolve that path for you. It writes
805
- preferences, starts the process, reads the
806
- DevTools WebSocket URL from stderr and returns `{ proc, url }`. It does not open
807
- a CDP connection. Readiness timeout or launch failure rejects and attempts to
1602
+ `browser.launch` builds arguments, starts the process, reads the
1603
+ DevTools WebSocket URL from stderr and returns `{ proc, url }`. With `cdp_ext: true`,
1604
+ it waits for process spawn instead and returns `url: undefined`; the caller must
1605
+ prepare the extension and supply its directory as `extension_path`.
1606
+ It does not open a CDP connection or write preference files; `update_preferences`, `preferences`
1607
+ and `local_state` are manager options, ignored by this helper.
1608
+ To prepare files independently, call
1609
+ `browser.update_profile_preferences` explicitly before launch. The helper does
1610
+ not resolve `user_data_dir` or `args['user-data-dir']`; explicit paths should be
1611
+ absolute. Omitting the directory lets Chromium select its system directory,
1612
+ subject to the debugging-port restrictions described above.
1613
+ Readiness timeout or launch failure rejects and attempts to
808
1614
  terminate the spawned process.
809
1615
 
810
- `jsrpc` takes a WebSocket URL and an options object whose recognized option is
1616
+ `jsrpc` takes a WebSocket URL or an accepted EventTarget socket and an options
1617
+ object whose recognized option is
811
1618
  `request_timeout_ms` (default `30000`). Wait for the socket to open before `req`.
1619
+ For an accepted socket, it returns that same object with RPC fields/methods.
1620
+ The object supplies `send`, `close`, live `readyState` and normal message/close/error
1621
+ events. Deno sockets remain native WebSockets; Bun uses the adapter described
1622
+ above. Accepted sockets need not be instances of the `jsrpc` subclass.
1623
+ The reverse server consumes the initial browser announcement before equipping
1624
+ the socket with RPC behavior. Empty-text heartbeats are ignored.
812
1625
  The request is a raw envelope: `method`, optional `params`, and optional top-level
813
1626
  `sessionId`. `req` assigns its own incrementing ID even if the input contains one.
814
1627
  Its second argument overrides the timeout for that request. There are no browser
@@ -826,6 +1639,9 @@ labels, target labels, custom methods or reconnection policy in this layer.
826
1639
  | Unknown `_.` handler | Reject before browser creation. |
827
1640
  | Invalid profile JSON or filesystem failure | Propagate the parsing/filesystem error. |
828
1641
  | Failed discovery or local launch | Reject the call establishing the connection. |
1642
+ | Named context in reverse mode | Reject explicitly; no separate process emulation. |
1643
+ | Reverse incognito without the extension's user grant | Reject with an instruction to enable Allow in incognito. |
1644
+ | Automatic context cleanup fails | Emit the manager's `error` event with browser label and original error. |
829
1645
 
830
1646
  ```js
831
1647
  try {
@@ -845,6 +1661,7 @@ try {
845
1661
 
846
1662
  Contributors and coding agents should read [AGENTS.md](AGENTS.md) and
847
1663
  [DEV_PREF.md](DEV_PREF.md). The maintained implementation is [cdp.js](cdp.js).
1664
+ Planned work is tracked in [TODO.md](TODO.md).
848
1665
  `extra/`, when present locally, holds ignored reference files and is not part of
849
1666
  the tracked source or runtime dependencies.
850
1667
 
@@ -853,14 +1670,41 @@ workflow for detecting new conversations and extracting connector session handle
853
1670
  from tool-call history. It is a caller-side recipe, not additional library API.
854
1671
 
855
1672
  For npm packaging, account setup, and GitHub publishing, see
856
- [Publishing](npm/PUBLISHING.md). Publishing only copies `cdp.js` and this README
857
- into `npm/` alongside its manifest; no build script is needed.
1673
+ [Publishing](npm/PUBLISHING.md). Publishing copies `cdp.js`, this README and the
1674
+ two `cdp_ext/` files into `npm/` alongside its manifest; no build script is needed.
858
1675
 
859
1676
  ```sh
860
1677
  node --check cdp.js
1678
+ node --check cdp_ext/cdp_ext.js
861
1679
  node --test tests/cdp.test.js
1680
+ deno test -A tests/reverse.test.js
1681
+ bun test tests/cdp.test.js tests/reverse.test.js --timeout 60000
862
1682
  ```
863
1683
 
864
1684
  Integration tests launch local headless Chromium with disposable profiles under
865
1685
  `build/tests/`. Set `CDP_BROWSER` if discovery does not match the machine. Direct
866
1686
  library use needs no build or package installation; npm packaging is optional.
1687
+ The shared Deno/Bun reverse suite loads the real extension into two headless Chrome instances on one
1688
+ server port without remote-debugging arguments. It checks native/custom calls,
1689
+ Network events, webRequest notifications without debugger attachment, bindings,
1690
+ child sessions, reconnect, detach/recreate, incognito and isolation.
1691
+ Node coverage also checks actual browser/context proxy routing, shared private
1692
+ storage, concurrent context creation, automatic disposal, unselected targets and
1693
+ context ownership in externally attached browsers.
1694
+ Extension API coverage checks granted permissions, positional arguments, nested
1695
+ API receivers, synchronous/Promise/callback results, CSS/script injection, storage,
1696
+ native validation errors and false/null/undefined results. Function-injection checks
1697
+ cover native string rejection, ignored custom string conversions, the bridge's
1698
+ string-function adapter and async arguments/results. Real page CSP checks cover
1699
+ both evaluators in both worlds, explicit debugger bypass after reload, and explicit
1700
+ header removal through session rules. User-script checks cover default denied
1701
+ access and explicit activation, DOM changes and Promise results under restrictive
1702
+ page CSP. Only disposable-profile preparation uses direct CDP to operate Chrome's
1703
+ Allow User Scripts and Allow in incognito toggles; subsequent reverse execution
1704
+ uses no remote-debugging flags and never enables these grants automatically.
1705
+ Managed-launch coverage checks concurrent first use, profile-specific identity,
1706
+ connection timeout without process replacement, lazy listener startup/shutdown
1707
+ independent of direct browsers, and relaunch after confirmed exit. External reverse
1708
+ timeouts release the listening port; other live reverse connections retain it.
1709
+ The npm tarball contains `package.json`, `cdp.js`, `README.md` and the two extension
1710
+ files required by managed reverse launches.