@mefistofelix/cdp.js 0.7.0 → 0.8.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
@@ -246,6 +246,7 @@ local configuration.
246
246
  | `websocket_url` | string, unset | Attach directly to a browser WebSocket endpoint. |
247
247
  | `cdp_ext` | boolean, `false` | Use the extension transport under Deno/Bun: start the shared reverse listener and open a local bootstrap URL containing the logical browser name and WebSocket endpoint. No extension ID is required. |
248
248
  | `cdp_ext_install` | boolean, presence of an effective user-data directory | Prepare an extension copy and startup loading arguments. Defaults on for an explicit custom directory, off for the system profile. `false` uses an already installed extension. Only applies with `cdp_ext: true`. |
249
+ | `cdp_ext_user_scripts` | boolean, `cdp_ext_install` plus an effective custom directory | Automatically enable Allow User Scripts for the managed bridge. False preserves the current grant. True requires installation and a custom directory; system/passive browsers use manual consent. See activation below. |
249
250
  | `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. |
250
251
  | `extension_id` | string, browser name for passive `reverse: true` attachment | Optional legacy incoming extension identity. Not needed with `cdp_ext: true`; observed automatically from the connection. |
251
252
  | `http_url` | string, unset | Discover the endpoint from an HTTP(S) base URL or exact `/json/version` URL. |
@@ -924,7 +925,8 @@ every past attachment or identify the caller by itself.
924
925
  For native script-text injection without changing the page's CSP,
925
926
  `chrome.userScripts.execute` (Chrome 135+) accepts `js[].code`. Its default
926
927
  `USER_SCRIPT` world shares the DOM but has separate JavaScript globals and is exempt
927
- from the page's CSP. `MAIN` instead shares the page's globals and CSP.
928
+ from the page's CSP. `MAIN` shares the page's globals; source injection and later
929
+ calls to eval/new Function have different CSP behavior, explained below.
928
930
 
929
931
  ```js
930
932
  const frames = await client.call({
@@ -944,18 +946,102 @@ const frames = await client.call({
944
946
  console.log(frames[0].result) // 42
945
947
  ```
946
948
 
947
- The manifest includes the permission, but Chrome also requires explicit user
948
- activation: Developer mode before Chrome 138, or **Allow User Scripts** on the
949
- extension's details page from Chrome 138 onward. Open
950
- `chrome://extensions/?id=<extension_id>` using `client.browsers.work.extension_id`.
951
- If necessary, reload the extension after enabling the toggle. Neither `cdp_ext: true`
952
- nor `extensions` enables it or adds a default profile setting. A previously enabled
953
- profile retains its choice. Chrome stores the extension-specific
954
- `extensions.settings.<extension_id>.user_scripts_enabled` setting; on Windows it
955
- is in protected `Secure Preferences`, and simply writing the key into a fresh
956
- profile did not enable access in our test. Use Chrome's toggle rather than assuming
957
- a normal `preferences` entry can update protected extension settings.
958
- See the native [userScripts API and activation steps](https://developer.chrome.com/docs/extensions/reference/api/userScripts).
949
+ ### Arbitrary client code in MAIN, without debugger
950
+
951
+ `chrome.userScripts.execute` also accepts `world: 'MAIN'`. With user-script access
952
+ enabled, Chrome compiles the supplied `js[].code` directly in the page's JavaScript
953
+ world. The source can arrive from the JSON-RPC client at runtime. It can read/write
954
+ page globals and the DOM, including on a page with `script-src 'none'`, without
955
+ debugger attachment or changing the page's CSP:
956
+
957
+ ```js
958
+ const frames = await client.call({
959
+ method: 'chrome.userScripts.execute',
960
+ params: {
961
+ browser: 'work',
962
+ args: [{
963
+ target: { tabId },
964
+ world: 'MAIN',
965
+ js: [{
966
+ code: 'window.clientValue = 42; document.querySelector("h1").dataset.source = "client"; Promise.resolve(window.clientValue)',
967
+ }],
968
+ }],
969
+ },
970
+ })
971
+ console.log(frames[0].result) // 42
972
+ ```
973
+
974
+ This is native source injection, not a call to `eval`. If the injected source
975
+ itself calls `eval` or `new Function` in MAIN, those operations still obey the
976
+ page's CSP and can remain blocked. The Allow User Scripts toggle enables this
977
+ API; it does not globally enable string evaluation in MAIN, the worker, or normal
978
+ `chrome.scripting.executeScript` calls. No additional RPC method is needed.
979
+
980
+ In `USER_SCRIPT`, the DOM is shared but page JavaScript globals are separate.
981
+ That world's own CSP can permit eval/new Function through
982
+ `chrome.userScripts.configureWorld`, with a CSP such as
983
+ `script-src 'self' 'unsafe-eval'; object-src 'self'`. Configure the world before its
984
+ first injection into a document, or configure a fresh `worldId` and use that ID
985
+ when injecting. Changing the configuration after a world already exists did not
986
+ relax that existing world's CSP in our Chrome test. `configureWorld` does not
987
+ configure MAIN's CSP. Plain `js[].code` injection needs neither eval nor this
988
+ additional world configuration.
989
+
990
+ These behaviors were verified on real Chrome with a restrictive page CSP:
991
+ MAIN source execution returned a Promise result, shared page globals and changed
992
+ the DOM while the tab remained debugger-detached; eval and new Function inside
993
+ that MAIN source remained blocked. The native API and result arrays already pass
994
+ through the bridge unchanged. See [Chrome userScripts](https://developer.chrome.com/docs/extensions/reference/api/userScripts).
995
+ The migration discussion in [Tampermonkey #644](https://github.com/Tampermonkey/tampermonkey/issues/644#issuecomment-2094925643)
996
+ and its [Content Script API documentation](https://www.tampermonkey.net/documentation.php?locale=en&q=content_script_api)
997
+ describe the corresponding native user-script injection mechanism.
998
+
999
+ ### User-script consent: system and managed profiles
1000
+
1001
+ The shipped manifest already declares `userScripts`, but a manifest permission
1002
+ alone cannot turn on Chrome's user consent. Before Chrome 138 the gate is Developer
1003
+ mode; from Chrome 138 it is **Allow User Scripts** for the individual extension.
1004
+
1005
+ For a **system profile**, passive `reverse: true`, or `cdp_ext_install: false`,
1006
+ enable it manually in `chrome://extensions/?id=<extension_id>` using the observed
1007
+ `client.browsers.work.extension_id`. This is a per-extension/profile choice, not
1008
+ something to repeat at every connection. Reload the extension if the API is still
1009
+ unavailable after enabling it. Use the shipped `cdp_ext/` folder for manual installs.
1010
+
1011
+ For **managed custom profiles**, `cdp_ext_user_scripts` defaults to true when
1012
+ `cdp_ext: true`, `cdp_ext_install: true` and an effective custom user-data directory
1013
+ are all present. Effective raw directory overrides count, including null/false
1014
+ overrides that remove the directory. Before announcing a new bootstrap binding,
1015
+ the bridge checks access and, if needed, uses Chrome's own extensions-settings UI
1016
+ API to enable it for its own extension. It briefly opens an inactive internal
1017
+ settings tab, runs a fixed native scripting function, and closes that tab. This
1018
+ uses no debugger and no remote-debugging flags.
1019
+
1020
+ This developer setup needs generated `extensions-on-chrome-urls: true` and
1021
+ `DisableExtensionsOnChromeUrlsSwitch` in `disable-features`. The manager adds
1022
+ `chrome://extensions/*` only to the **managed copy** of the manifest. The shipped
1023
+ manifest remains suitable for a normal manual installation. The worker and sidebar
1024
+ files are copied unchanged. Chrome writes its own protected permission settings;
1025
+ the library does not edit `Secure Preferences` or compute preference signatures.
1026
+ Legacy Chrome uses the internal Developer mode setting instead of the newer
1027
+ per-extension setting; the automatic path is tested on Chrome 154.
1028
+
1029
+ Set `cdp_ext_user_scripts: false` to skip the permission step, extra host access
1030
+ and development flags. Existing grants are preserved. Explicit true without both
1031
+ installation and a custom directory rejects before launching; it never infers or
1032
+ modifies the system profile. `update_preferences: false` controls JSON file writes
1033
+ independently and does not turn off this native permission setup. Raw CLI overrides
1034
+ still win; disabling a required switch can make setup fail. Such failures reach
1035
+ the caller through the initial reverse announcement and appear in the local
1036
+ trigger history, rather than being reported as a successful connection.
1037
+ An already running Chrome cannot adopt new command-line capabilities or a changed
1038
+ loaded manifest; restart that custom-profile browser after enabling this option.
1039
+
1040
+ The internal settings API and developer switches are Chrome implementation
1041
+ details; unsupported browsers/policies produce errors. Simply writing
1042
+ `extensions.settings.<extension_id>.user_scripts_enabled` into `Preferences` did
1043
+ not enable access in our Windows test because Chrome protects these settings.
1044
+ See the native [activation steps](https://developer.chrome.com/docs/extensions/reference/api/userScripts).
959
1045
 
960
1046
  Chrome's native `scripting.executeScript` still rejects strings supplied directly
961
1047
  as `func`; the bridge's fixed function makes this adaptation possible. Overriding
@@ -1307,6 +1393,10 @@ See [Chrome's debugging-port restrictions](https://developer.chrome.com/blog/rem
1307
1393
  | `login: true` plus `args['disable-sync']: true` | Allow profile sign-in through generated preferences while still requesting sync to be disabled. |
1308
1394
  | 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. |
1309
1395
  | `user_data_dir` plus `args['user-data-dir']` | The raw argument determines both the profile write location and the launched browser directory. |
1396
+ | Managed extension plus effective custom directory | Default `cdp_ext_user_scripts` to true; grant through Chrome's own UI API before accepting the reverse binding. |
1397
+ | `cdp_ext_user_scripts: false` plus an existing grant | Preserve the grant, skip the permission step and omit its extra generated flags/manifest host. |
1398
+ | `cdp_ext_user_scripts: true` without custom directory or installation | Reject before launch; the system profile remains a manual choice. |
1399
+ | `update_preferences: false` plus automatic user-script consent | Skip JSON preference-file writes; still let Chrome apply its native per-extension grant. |
1310
1400
  | `port` plus `args['remote-debugging-port']` | Attach using `port`; no launch occurs, so the raw launch argument is unused. |
1311
1401
  | Constructor options plus an existing browser label | Reuse the stored record; configuration is not merged again on every call. |
1312
1402
  | `initialize: false` plus `runtime: true` or a binding name | Skip optional target setup, including domain enabling and binding installation. |
@@ -1658,7 +1748,13 @@ XPath snapshot length, even when `items` is limited or empty.
1658
1748
  not a complete parser; arbitrary nested or comma-containing arguments are outside
1659
1749
  that rewriting logic. Ordinary XPath expressions pass through unchanged.
1660
1750
 
1661
- `client.evaluate_xpath(params, fn, options)` is available for other helpers:
1751
+ `_.find` and `_.click` already contain fixed browser-side functions. In API mode
1752
+ only the XPath string and data options are sent to them; neither needs user-script
1753
+ consent, eval or new Function. This is the appropriate path for those two built-ins.
1754
+
1755
+ `client.evaluate_xpath(params, fn, options)` is a separate, general-purpose callback
1756
+ helper: its `fn` parameter is executable code supplied by its caller, not just an
1757
+ XPath string. It is available for other helpers:
1662
1758
 
1663
1759
  ```js
1664
1760
  const text = await client.evaluate_xpath({
@@ -1679,14 +1775,17 @@ console.log(text.result.value)
1679
1775
  the target as `fn(normalizedXPath, options)`; it cannot capture Node.js variables
1680
1776
  or imported helpers. `options` must be JSON-serializable; omission becomes null
1681
1777
  in the argument array. Direct/debugger results retain native `exceptionDetails`
1682
- if page code throws. In reverse API mode this arbitrary-function helper uses the
1683
- existing string-function scripting adapter in the **MAIN** world and returns
1684
- `{ result: { type, value } }`. Unlike the two fixed built-ins, arbitrary source
1685
- evaluation requires the page CSP to allow eval. Injection errors or missing
1686
- execution results reject; no debugger, CSP bypass or user-script toggle is enabled
1687
- automatically. Choose `execution_mode: 'debugger'` explicitly when native CDP
1688
- evaluation is required. Native `chrome.scripting.executeScript` calls retain their
1689
- existing native world default and native result array.
1778
+ if page code throws. In reverse API mode this arbitrary-function helper compiles
1779
+ the serialized invocation directly through `chrome.userScripts.execute` in the
1780
+ **MAIN** world and returns `{ result: { type, value } }`. It requires user-script
1781
+ access, automatically prepared for managed profiles as described above. The
1782
+ wrapper awaits the callback and preserves undefined values; callback exceptions
1783
+ reject with their message. It does not use eval/new Function, change CSP, or fall
1784
+ back to the debugger. A callback that itself invokes eval still obeys page CSP.
1785
+ Choose `execution_mode: 'debugger'` explicitly when native CDP evaluation is
1786
+ required. Native `chrome.scripting.executeScript` calls keep their existing string
1787
+ adapter, native world default and native result array; the fixed XPath built-ins
1788
+ do not use that string adapter either.
1690
1789
 
1691
1790
  ### Register, replace or remove handlers
1692
1791
 
@@ -1987,10 +2086,13 @@ cover native string rejection, ignored custom string conversions, the bridge's
1987
2086
  string-function adapter and async arguments/results. Real page CSP checks cover
1988
2087
  both evaluators in both worlds, explicit debugger bypass after reload, and explicit
1989
2088
  header removal through session rules. User-script checks cover default denied
1990
- access and explicit activation, DOM changes and Promise results under restrictive
1991
- page CSP. Only disposable-profile preparation uses direct CDP to operate Chrome's
1992
- Allow User Scripts and Allow in incognito toggles; subsequent reverse execution
1993
- uses no remote-debugging flags and never enables these grants automatically.
2089
+ access in external/opt-out profiles, automatic consent in managed custom profiles,
2090
+ MAIN globals, DOM changes and Promise results under restrictive page CSP, and
2091
+ continued CSP restrictions on eval/new Function. Generic XPath callbacks run through
2092
+ native userScripts while fixed XPath commands work even with userScripts denied.
2093
+ Only disposable incognito-grant preparation uses direct CDP to operate Chrome's
2094
+ Allow in incognito toggle; reverse execution and automatic user-script consent
2095
+ use no remote-debugging flags or debugger attachment.
1994
2096
  Managed-launch coverage checks concurrent first use, profile-specific identity,
1995
2097
  connection timeout without process replacement, lazy listener startup/shutdown
1996
2098
  independent of direct browsers, and relaunch after confirmed exit. External reverse
package/cdp.js CHANGED
@@ -188,6 +188,10 @@ export class browser {
188
188
  }
189
189
  if (options.cdp_ext) {
190
190
  args['remote-debugging-port'] = false
191
+ if (options.cdp_ext_user_scripts) {
192
+ args['extensions-on-chrome-urls'] = true
193
+ args['disable-features'].push('DisableExtensionsOnChromeUrlsSwitch')
194
+ }
191
195
  delete args['disable-extensions']
192
196
  delete args['disable-component-extensions-with-background-pages']
193
197
  if (options.extension_path) args['disable-extensions-except'] = [
@@ -383,7 +387,7 @@ export class cdp extends EventTarget {
383
387
  const error = new Error(message.error)
384
388
  if (record) record.reverse_error = error
385
389
  util.emit(this, 'reverse_' + encodeURIComponent(id), error)
386
- return socket.close(1008, 'Chrome profile already connected')
390
+ return socket.close(1008, 'Reverse browser initialization failed')
387
391
  }
388
392
  if (record) record.extension_id = extension_id
389
393
  this.reverse_sockets[id] = socket
@@ -489,18 +493,25 @@ export class cdp extends EventTarget {
489
493
  sessionId: entry.sessionId,
490
494
  })
491
495
  const results = await record.socket.req({
492
- method: 'chrome.scripting.executeScript',
496
+ method: 'chrome.userScripts.execute',
493
497
  params: {
494
498
  args: [{
495
499
  target: { tabId: Number(entry.targetId) },
496
500
  world: 'MAIN',
497
- func: 'async (...args) => ({ value: await (' + fn + ')(...args) })',
498
- args: [xpath, options ?? null],
501
+ js: [{
502
+ code: `(async () => {
503
+ try {
504
+ return { value: await (${fn})(...${JSON.stringify([xpath, options])}) }
505
+ } catch (error) {
506
+ return { error: String(error) }
507
+ }
508
+ })()`,
509
+ }],
499
510
  }],
500
511
  },
501
512
  })
502
- const result = results[0]?.result
503
- if (!result) throw new Error('Browser API evaluation failed; check page CSP and extension access. No debugger fallback was used.')
513
+ const result = results[0].result
514
+ if (!result || Object.hasOwn(result, 'error')) throw new Error(result?.error ?? results[0].error ?? 'XPath callback did not execute')
504
515
  return {
505
516
  result: {
506
517
  type: typeof result.value,
@@ -648,6 +659,14 @@ export class cdp extends EventTarget {
648
659
  }
649
660
 
650
661
  async _connect(record) {
662
+ const args = browser.build_args(record)
663
+ if (record.cdp_ext) {
664
+ record.cdp_ext_install ??= !!args['user-data-dir']
665
+ record.cdp_ext_user_scripts ??= record.cdp_ext_install && !!args['user-data-dir']
666
+ if (record.cdp_ext_user_scripts && (!record.cdp_ext_install || !args['user-data-dir'])) {
667
+ throw new Error('Automatic user-script access requires cdp_ext_install and a custom user-data-dir')
668
+ }
669
+ }
651
670
  if (record.reverse || record.cdp_ext) {
652
671
  await this.server_closing
653
672
  if (!this.server) this.listen_reverse()
@@ -663,6 +682,7 @@ export class cdp extends EventTarget {
663
682
  bootstrap.searchParams.set('browser', record.name)
664
683
  bootstrap.searchParams.set('websocket_url', record.reverse_url)
665
684
  bootstrap.searchParams.set('token', record.reverse_token)
685
+ if (record.cdp_ext_user_scripts) bootstrap.searchParams.set('user_scripts', '1')
666
686
  record.bootstrap_url = bootstrap.href
667
687
  }
668
688
  delete record.reverse_error
@@ -676,8 +696,6 @@ export class cdp extends EventTarget {
676
696
  url = (await response.json()).webSocketDebuggerUrl
677
697
  }
678
698
  if (record.cdp_ext || (!record.reverse && !url)) {
679
- const args = browser.build_args(record)
680
- if (record.cdp_ext) record.cdp_ext_install ??= !!args['user-data-dir']
681
699
  if ((record.proc?.exitCode === null && record.proc.signalCode === null) ||
682
700
  (record.opener?.exitCode === null && record.opener.signalCode === null)) url = record.socket?.url
683
701
  else {
@@ -693,6 +711,12 @@ export class cdp extends EventTarget {
693
711
  for (const filename of ['manifest.json', 'cdp_ext.js', 'sidebar.html', 'sidebar.js']) {
694
712
  await fs.copyFile(new URL('./cdp_ext/' + filename, import.meta.url), path.join(record.extension_path, filename))
695
713
  }
714
+ if (record.cdp_ext_user_scripts) {
715
+ const filename = path.join(record.extension_path, 'manifest.json')
716
+ const manifest = JSON.parse(await fs.readFile(filename, 'utf8'))
717
+ manifest.host_permissions.push('chrome://extensions/*')
718
+ await fs.writeFile(filename, JSON.stringify(manifest, null, 2) + '\n')
719
+ }
696
720
  }
697
721
  if (args['user-data-dir'] && record.update_preferences !== false) {
698
722
  await browser.update_profile_preferences(path.join(args['user-data-dir'], args['profile-directory'] || 'Default', 'Preferences'), {
@@ -12,6 +12,7 @@ let auto_attach = {}
12
12
  let bootstrapping
13
13
  let checking = false
14
14
  let connection_message
15
+ let setup_error
15
16
  const debug_commands = []
16
17
  const debug_ports = []
17
18
 
@@ -65,6 +66,36 @@ function report_connection(message) {
65
66
  debug_update()
66
67
  }
67
68
 
69
+ async function enable_user_scripts() {
70
+ try {
71
+ await chrome.userScripts.getScripts()
72
+ return
73
+ } catch {}
74
+ const tab = await chrome.tabs.create({
75
+ url: 'chrome://extensions/?id=' + chrome.runtime.id,
76
+ active: false,
77
+ })
78
+ try {
79
+ const results = await chrome.scripting.executeScript({
80
+ target: { tabId: tab.id },
81
+ world: 'MAIN',
82
+ args: [chrome.runtime.id],
83
+ func: async id => {
84
+ const version = Number(navigator.userAgent.match(/(?:Chrome|Chromium)\/(\d+)/)?.[1])
85
+ if (version < 138) await chrome.developerPrivate.updateProfileConfiguration({ inDeveloperMode: true })
86
+ else await chrome.developerPrivate.updateExtensionConfiguration({
87
+ extensionId: id,
88
+ userScriptsAccess: true,
89
+ })
90
+ return true
91
+ },
92
+ })
93
+ if (results[0]?.result !== true) throw new Error('Chrome did not enable user-script access')
94
+ } finally {
95
+ await chrome.tabs.remove(tab.id)
96
+ }
97
+ }
98
+
68
99
  async function bootstrap(tab) {
69
100
  const text = tab.pendingUrl ?? tab.url ?? ''
70
101
  if (!/^http:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?\/cdp_ext\?/.test(text)) return
@@ -101,6 +132,7 @@ async function bootstrap(tab) {
101
132
  error: trigger.error,
102
133
  }))
103
134
  } else {
135
+ setup_error = undefined
104
136
  if (changed) {
105
137
  Object.assign(config, settings)
106
138
  connection_message = undefined
@@ -114,7 +146,7 @@ async function bootstrap(tab) {
114
146
  }
115
147
  await disconnecting
116
148
  }
117
- await connect()
149
+ if (url.searchParams.get('user_scripts') === '1') await enable_user_scripts()
118
150
  }
119
151
  const current_tabs = await chrome.tabs.query({})
120
152
  const current = current_tabs.find(current => current.id === tab.id)
@@ -131,13 +163,14 @@ async function bootstrap(tab) {
131
163
  } catch (error) {
132
164
  trigger.status = 'error'
133
165
  trigger.error = error.message
134
- throw error
166
+ setup_error = 'Extension setup failed: ' + error.message
135
167
  } finally {
136
168
  trigger.duration_ms = Date.now() - trigger.time
137
169
  debug_update()
138
170
  }
139
171
  })().finally(() => {
140
172
  bootstrapping = null
173
+ return connect()
141
174
  })
142
175
  }
143
176
 
@@ -344,7 +377,7 @@ for (const [name, options] of Object.entries({
344
377
  async function connect() {
345
378
  await configured
346
379
  if (!config.browser || !config.websocket_url || !config.token) return
347
- if (disconnecting || checking) return
380
+ if (bootstrapping || disconnecting || checking) return
348
381
  if (socket?.readyState === WebSocket.CONNECTING) return
349
382
  if (socket?.readyState === WebSocket.OPEN) return socket.send('')
350
383
  const endpoint = config.websocket_url
@@ -378,6 +411,7 @@ async function connect() {
378
411
  browser: config.browser,
379
412
  extension_id: chrome.runtime.id,
380
413
  token: config.token,
414
+ error: setup_error,
381
415
  }))
382
416
  }
383
417
  connection.onmessage = async event => {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "CDP reverse connection",
4
- "version": "0.7.0",
4
+ "version": "0.8.0",
5
5
  "minimum_chrome_version": "125",
6
6
  "permissions": [
7
7
  "accessibilityFeatures.read",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mefistofelix/cdp.js",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Low-level Chromium DevTools Protocol client for Node.js, Deno and Bun, with no runtime dependencies.",
5
5
  "type": "module",
6
6
  "exports": "./cdp.js",