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