Bug 1509339 - Implement a UserScript API object and remove the userScripts.setScriptAPIs method. r=zombie,robwu
authorLuca Greco <lgreco@mozilla.com>
Fri, 30 Nov 2018 16:10:58 +0000
changeset 508219 541cb39b63231ebdd85159013b9f384d39a13b29
parent 508218 39b6008cb9cfb02228f4946463d458d4dee8a8df
child 508220 911cad5eea69459f2fa6f7354476d196f614348e
push id1905
push userffxbld-merge
push dateMon, 21 Jan 2019 12:33:13 +0000
treeherdermozilla-release@c2fca1944d8c [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerszombie, robwu
bugs1509339
milestone65.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1509339 - Implement a UserScript API object and remove the userScripts.setScriptAPIs method. r=zombie,robwu Depends on D12676 Differential Revision: https://phabricator.services.mozilla.com/D12678
toolkit/components/extensions/ExtensionContent.jsm
toolkit/components/extensions/child/ext-toolkit.js
toolkit/components/extensions/child/ext-userScripts-content.js
toolkit/components/extensions/ext-toolkit.json
toolkit/components/extensions/schemas/user_scripts_content.json
toolkit/components/extensions/test/xpcshell/test_ext_userScripts.js
toolkit/components/extensions/test/xpcshell/test_ext_userScripts_telemetry.js
--- a/toolkit/components/extensions/ExtensionContent.jsm
+++ b/toolkit/components/extensions/ExtensionContent.jsm
@@ -33,17 +33,16 @@ ChromeUtils.import("resource://gre/modul
 ChromeUtils.import("resource://gre/modules/ExtensionCommon.jsm");
 ChromeUtils.import("resource://gre/modules/ExtensionUtils.jsm");
 
 XPCOMUtils.defineLazyGlobalGetters(this, ["crypto", "TextEncoder"]);
 
 const {
   DefaultMap,
   DefaultWeakMap,
-  ExtensionError,
   getInnerWindowID,
   getWinUtils,
   promiseDocumentIdle,
   promiseDocumentLoaded,
   promiseDocumentReady,
 } = ExtensionUtils;
 
 const {
@@ -600,19 +599,20 @@ class UserScript extends Script {
         close: () => {
           // Destroy the userScript sandbox when the related ContentScriptContextChild instance
           // is being closed.
           this.sandboxes.delete(context);
           Cu.nukeSandbox(userScriptSandbox);
         },
       });
 
-      // Inject the custom API registered by the extension API script.
+      // Notify listeners subscribed to the userScripts.onBeforeScript API event,
+      // to allow extension API script to provide its custom APIs to the userScript.
       if (apiScript) {
-        this.injectUserScriptAPIs(userScriptSandbox, context);
+        context.userScriptsEvents.emit("on-before-script", this.scriptMetadata, userScriptSandbox);
       }
 
       for (let script of sandboxScripts) {
         script.executeInGlobal(userScriptSandbox);
       }
     } finally {
       ExtensionTelemetry.userScriptInjection.stopwatchFinish(extension, context);
     }
@@ -640,98 +640,16 @@ class UserScript extends Script {
       metadata: {
         "inner-window-id": context.innerWindowID,
         addonId: this.extension.policy.id,
       },
     });
 
     return sandbox;
   }
-
-  injectUserScriptAPIs(userScriptScope, context) {
-    const {extension, scriptMetadata} = this;
-    const {userScriptAPIs, cloneScope: apiScope} = context;
-
-    if (!userScriptAPIs) {
-      return;
-    }
-
-    let clonedMetadata;
-
-    const UserScriptError = userScriptScope.Error;
-    const UserScriptPromise = userScriptScope.Promise;
-
-    const wrappedFnMap = new WeakMap();
-
-    function safeReturnCloned(res) {
-      try {
-        return Cu.cloneInto(res, userScriptScope);
-      } catch (err) {
-        Cu.reportError(
-          `userScripts API method wrapper for ${extension.policy.debugName}: ${err}`
-        );
-        throw new UserScriptError("Unable to clone object in the userScript sandbox");
-      }
-    }
-
-    function wrapUserScriptAPIMethod(fn, fnName) {
-      return Cu.exportFunction(function(...args) {
-        let fnArgs = Cu.cloneInto([], apiScope);
-
-        try {
-          for (let arg of args) {
-            if (typeof arg === "function") {
-              if (!wrappedFnMap.has(arg)) {
-                wrappedFnMap.set(arg, Cu.exportFunction(arg, apiScope));
-              }
-              fnArgs.push(wrappedFnMap.get(arg));
-            } else {
-              fnArgs.push(Cu.cloneInto(arg, apiScope));
-            }
-          }
-        } catch (err) {
-          Cu.reportError(`Error cloning userScriptAPIMethod parameters in ${fnName}: ${err}`);
-          throw new UserScriptError("Only serializable parameters are supported");
-        }
-
-        if (clonedMetadata === undefined) {
-          clonedMetadata = Cu.cloneInto(scriptMetadata, apiScope);
-        }
-
-        const res = runSafeSyncWithoutClone(fn, fnArgs, clonedMetadata, userScriptScope);
-
-        if (res instanceof context.Promise) {
-          return UserScriptPromise.resolve().then(async () => {
-            let value;
-            try {
-              value = await res;
-            } catch (err) {
-              if (err instanceof context.Error) {
-                throw new UserScriptError(err.message);
-              } else {
-                throw safeReturnCloned(err);
-              }
-            }
-            return safeReturnCloned(value);
-          });
-        }
-
-        return safeReturnCloned(res);
-      }, userScriptScope);
-    }
-
-    for (let key of Object.keys(userScriptAPIs)) {
-      Schemas.exportLazyGetter(userScriptScope, key, () => {
-        // Wrap the custom API methods exported to the userScript sandbox.
-        return wrapUserScriptAPIMethod(userScriptAPIs[key], key);
-      });
-    }
-
-    context.userScriptsEvents.emit("on-before-script", clonedMetadata, userScriptScope);
-  }
 }
 
 var contentScripts = new DefaultWeakMap(matcher => {
   const extension = processScript.extensions.get(matcher.extension);
 
   if ("userScriptOptions" in matcher) {
     return new UserScript(extension, matcher);
   }
@@ -843,19 +761,16 @@ class ContentScriptContextChild extends 
 
       this.childManager.inject(chromeObj);
       return chromeObj;
     });
 
     Schemas.exportLazyGetter(this.sandbox, "browser", () => this.chromeObj);
     Schemas.exportLazyGetter(this.sandbox, "chrome", () => this.chromeObj);
 
-    // A set of exported API methods provided by the extension to the userScripts sandboxes.
-    this.userScriptAPIs = null;
-
     // Keep track if the userScript API script has been already executed in this context
     // (e.g. because there are more then one UserScripts that match the related webpage
     // and so the UserScript apiScript has already been executed).
     this.hasUserScriptAPIs = false;
 
     // A lazy created EventEmitter related to userScripts-specific events.
     defineLazyGetter(this, "userScriptsEvents", () => {
       return new ExtensionCommon.EventEmitter();
@@ -873,24 +788,16 @@ class ContentScriptContextChild extends 
     Schemas.exportLazyGetter(this.contentWindow,
                              "chrome", () => this.chromeObj);
   }
 
   get cloneScope() {
     return this.sandbox;
   }
 
-  setUserScriptAPIs(extCustomAPIs) {
-    if (this.userScriptAPIs) {
-      throw new ExtensionError("userScripts APIs may only be set once");
-    }
-
-    this.userScriptAPIs = extCustomAPIs;
-  }
-
   async executeAPIScript(apiScript) {
     // Execute the UserScript apiScript only once per context (e.g. more then one UserScripts
     // match the same webpage and the apiScript has already been executed).
     if (apiScript && !this.hasUserScriptAPIs) {
       this.hasUserScriptAPIs = true;
       apiScript.executeInGlobal(this.cloneScope);
     }
   }
--- a/toolkit/components/extensions/child/ext-toolkit.js
+++ b/toolkit/components/extensions/child/ext-toolkit.js
@@ -71,17 +71,16 @@ extensions.registerModules({
     paths: [
       ["userScripts"],
     ],
   },
   userScriptsContent: {
     url: "chrome://extensions/content/child/ext-userScripts-content.js",
     scopes: ["content_child"],
     paths: [
-      ["userScripts", "setScriptAPIs"],
       ["userScripts", "onBeforeScript"],
     ],
   },
   webRequest: {
     url: "chrome://extensions/content/child/ext-webRequest.js",
     scopes: ["addon_child"],
     paths: [
       ["webRequest"],
--- a/toolkit/components/extensions/child/ext-userScripts-content.js
+++ b/toolkit/components/extensions/child/ext-userScripts-content.js
@@ -1,40 +1,353 @@
 /* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
 /* vim: set sts=2 sw=2 et tw=80: */
 "use strict";
 
 var USERSCRIPT_PREFNAME = "extensions.webextensions.userScripts.enabled";
 var USERSCRIPT_DISABLED_ERRORMSG = `userScripts APIs are currently experimental and must be enabled with the ${USERSCRIPT_PREFNAME} preference.`;
 
+ChromeUtils.defineModuleGetter(this, "Schemas", "resource://gre/modules/Schemas.jsm");
+
 XPCOMUtils.defineLazyPreferenceGetter(this, "userScriptsEnabled", USERSCRIPT_PREFNAME, false);
 
 var {
   ExtensionError,
 } = ExtensionUtils;
 
+const TYPEOF_PRIMITIVES = ["bigint", "boolean", "number", "string", "symbol"];
+
+/**
+ * Represents a user script in the child content process.
+ *
+ * This class implements the API object that is passed as a parameter to the
+ * browser.userScripts.onBeforeScript API Event.
+ *
+ * @param {Object} params
+ * @param {ContentScriptContextChild} params.context
+ *        The context which has registered the userScripts.onBeforeScript listener.
+ * @param {PlainJSONValue}            params.metadata
+ *        An opaque user script metadata value (as set in userScripts.register).
+ * @param {Sandbox}                   params.scriptSandbox
+ *        The Sandbox object of the userScript.
+ */
+class UserScript {
+  constructor({context, metadata, scriptSandbox}) {
+    this.context = context;
+    this.extension = context.extension;
+    this.apiSandbox = context.cloneScope;
+    this.metadata = metadata;
+    this.scriptSandbox = scriptSandbox;
+
+    this.ScriptError = scriptSandbox.Error;
+    this.ScriptPromise = scriptSandbox.Promise;
+  }
+
+  /**
+   * Returns the API object provided to the userScripts.onBeforeScript listeners.
+   *
+   * @returns {Object}
+   *          The API object with the properties and methods to export
+   *          to the extension code.
+   */
+  api() {
+    return {
+      metadata: this.metadata,
+      defineGlobals: (sourceObject) => this.defineGlobals(sourceObject),
+      export: (value) => this.export(value),
+    };
+  }
+
+  /**
+   * Define all the properties of a given plain object as lazy getters of the
+   * userScript global object.
+   *
+   * @param {Object} sourceObject
+   *        A set of objects and methods to export into the userScript scope as globals.
+   *
+   * @throws {context.Error}
+   *         Throws an apiScript error when sourceObject is not a plain object.
+   */
+  defineGlobals(sourceObject) {
+    let className;
+    try {
+      className = ChromeUtils.getClassName(sourceObject, true);
+    } catch (e) {
+      // sourceObject is not an object;
+    }
+
+    if (className !== "Object") {
+      throw new this.context.Error("Invalid sourceObject type, plain object expected.");
+    }
+
+    this.exportLazyGetters(sourceObject, this.scriptSandbox);
+  }
+
+  /**
+   * Convert a given value to make it accessible to the userScript code.
+   *
+   * - any property value that is already accessible to the userScript code is returned unmodified by
+   *   the lazy getter
+   * - any apiScript's Function is wrapped using the `wrapFunction` method
+   * - any apiScript's Object is lazily exported (and the same wrappers are lazily applied to its
+   *   properties).
+   *
+   * @param {any} valueToExport
+   *        A value to convert into an object accessible to the userScript.
+   *
+   * @param {Object} privateOptions
+   *        A set of options used when this method is called internally (not exposed in the
+   *        api object exported to the onBeforeScript listeners).
+   * @param {Error}  Error
+   *        The Error constructor to use to report errors (defaults to the apiScript context's Error
+   *        when missing).
+   * @param {Error}  errorMessage
+   *        A custom error message to report exporting error on values not allowed.
+   *
+   * @returns {any}
+   *        The resulting userScript object.
+   *
+   * @throws {context.Error | privateOptions.Error}
+   *         Throws an error when the value is not allowed and it can't be exported into an allowed one.
+   */
+  export(valueToExport, privateOptions = {}) {
+    const ExportError = privateOptions.Error || this.context.Error;
+
+    if (this.canAccess(valueToExport, this.scriptSandbox)) {
+      // Return the value unmodified if the userScript principal is already allowed
+      // to access it.
+      return valueToExport;
+    }
+
+    let className;
+
+    try {
+      className = ChromeUtils.getClassName(valueToExport, true);
+    } catch (e) {
+      // sourceObject is not an object;
+    }
+
+    if (className === "Function") {
+      return this.wrapFunction(valueToExport);
+    }
+
+    if (className === "Object") {
+      return this.exportLazyGetters(valueToExport);
+    }
+
+    let valueType = className || typeof valueToExport;
+    throw new ExportError(privateOptions.errorMessage ||
+                          `${valueType} cannot be exported to the userScript`);
+  }
+
+  /**
+   * Export all the properties of the `src` plain object as lazy getters on the `dest` object,
+   * or in a newly created userScript object if `dest` is `undefined`.
+   *
+   * @param {Object} src
+   *        A set of properties to define on a `dest` object as lazy getters.
+   * @param {Object} [dest]
+   *        An optional `dest` object (a new userScript object is created by default when not specified).
+   *
+   * @returns {Object}
+   *          The resulting userScript object.
+   */
+  exportLazyGetters(src, dest = undefined) {
+    dest = dest || Cu.createObjectIn(this.scriptSandbox);
+
+    for (let [key, value] of this.shallowCloneEntries(src)) {
+      Schemas.exportLazyGetter(dest, key, () => {
+        return this.export(value, {
+          // Lazy properties will raise an error for properties with not allowed
+          // values to the userScript scope, and so we have to raise an userScript
+          // Error here.
+          Error: this.ScriptError,
+          errorMessage: `Error accessing disallowed property "${key}"`,
+        });
+      });
+    }
+
+    return dest;
+  }
+
+  /**
+   * Export and wrap an apiScript function to provide the following behaviors:
+   *   - errors throws from an exported function are checked by `handleAPIScriptError`
+   *   - returned apiScript's Promises (not accessible to the userScript) are converted into a
+   *     userScript's Promise
+   *   - check if the returned or resolved value is accessible to the userScript code
+   *     (and raise a userScript error if it is not)
+   *
+   * @param {Function} fn
+   *        The apiScript function to wrap
+   *
+   * @returns {Object}
+   *          The resulting userScript function.
+   */
+  wrapFunction(fn) {
+    return Cu.exportFunction((...args) => {
+      let res;
+      try {
+        // Checks that all the elements in the `...args` array are allowed to be
+        // received from the apiScript.
+        for (let arg of args) {
+          if (!this.canAccess(arg, this.apiSandbox)) {
+            throw new this.ScriptError(`Parameter not accessible to the userScript API`);
+          }
+        }
+
+        res = fn(...args);
+      } catch (err) {
+        this.handleAPIScriptError(err);
+      }
+
+      // Prevent execution of proxy traps while checking if the return value is a Promise.
+      if (!Cu.isProxy(res) && res instanceof this.context.Promise) {
+        return this.ScriptPromise.resolve().then(async () => {
+          let value;
+
+          try {
+            value = await res;
+          } catch (err) {
+            this.handleAPIScriptError(err);
+          }
+
+          return this.ensureAccessible(value);
+        });
+      }
+
+      return this.ensureAccessible(res);
+    }, this.scriptSandbox);
+  }
+
+  /**
+   * Shallow clone the source object and iterate over its Object properties (or Array elements),
+   * which allow us to safely iterate over all its properties (including callable objects that
+   * would be hidden by the xrays vision, but excluding any property that could be tricky, e.g.
+   * getters).
+   *
+   * @param {Object|Array} obj
+   *        The Object or Array object to shallow clone and iterate over.
+   */
+  * shallowCloneEntries(obj) {
+    const clonedObj = ChromeUtils.shallowClone(obj);
+
+    for (let entry of Object.entries(clonedObj)) {
+      yield entry;
+    }
+  }
+
+  /**
+   * Check if the given value is accessible to the targetScope.
+   *
+   * @param {any}     val
+   *        The value to check.
+   * @param {Sandbox} targetScope
+   *        The targetScope that should be able to access the value.
+   *
+   * @returns {boolean}
+   */
+  canAccess(val, targetScope) {
+    if (val == null || TYPEOF_PRIMITIVES.includes(typeof val)) {
+      return true;
+    }
+
+    // Disallow objects that are coming from principals that are not
+    // subsumed by the targetScope's principal.
+    try {
+      const targetPrincipal = Cu.getObjectPrincipal(targetScope);
+      if (!targetPrincipal.subsumes(Cu.getObjectPrincipal(val))) {
+        return false;
+      }
+    } catch (err) {
+      Cu.reportError(err);
+      return false;
+    }
+
+    return true;
+  }
+
+  /**
+   * Check if the value returned (or resolved) from an apiScript method is accessible
+   * to the userScript code, and throw a userScript Error if it is not allowed.
+   *
+   * @param {any} res
+   *        The value to return/resolve.
+   *
+   * @returns {any}
+   *          The exported value.
+   *
+   * @throws {Error}
+   *         Throws a userScript error when the value is not accessible to the userScript scope.
+   */
+  ensureAccessible(res) {
+    if (this.canAccess(res, this.scriptSandbox)) {
+      return res;
+    }
+
+    throw new this.ScriptError("Return value not accessible to the userScript");
+  }
+
+  /**
+   * Handle the error raised (and rejected promise returned) from apiScript functions exported to the
+   * userScript.
+   *
+   * @param {any} err
+   *        The value to return/resolve.
+   *
+   * @throws {any}
+   *         This method is expected to throw:
+   *         - any value that is already accessible to the userScript code is forwarded unmodified
+   *         - any value that is not accessible to the userScript code is logged in the console
+   *           (to make it easier to investigate the underlying issue) and converted into a
+   *           userScript Error (with the generic "An unexpected apiScript error occurred" error
+   *           message accessible to the userScript)
+   */
+  handleAPIScriptError(err) {
+    if (this.canAccess(err, this.scriptSandbox)) {
+      throw err;
+    }
+
+    // Log the actual error on the console and raise a generic userScript Error
+    // on error objects that can't be accessed by the UserScript principal.
+    try {
+      const debugName = this.extension.policy.debugName;
+      Cu.reportError(
+        `An unexpected apiScript error occurred for '${debugName}': ${err} :: ${err.stack}`);
+    } catch (e) {}
+
+    throw new this.ScriptError(`An unexpected apiScript error occurred`);
+  }
+}
+
 this.userScriptsContent = class extends ExtensionAPI {
   getAPI(context) {
     return {
       userScripts: {
-        setScriptAPIs(exportedAPIMethods) {
-          if (!userScriptsEnabled) {
-            throw new ExtensionError(USERSCRIPT_DISABLED_ERRORMSG);
-          }
-
-          context.setUserScriptAPIs(exportedAPIMethods);
-        },
         onBeforeScript: new EventManager({
           context,
           name: "userScripts.onBeforeScript",
           register: fire => {
-            let handler = (event, userScriptMetadata, userScriptSandbox) => {
-              const apiObj = Cu.createObjectIn(context.cloneScope);
-              apiObj.metadata = userScriptMetadata;
-              apiObj.global = userScriptSandbox;
+            if (!userScriptsEnabled) {
+              throw new ExtensionError(USERSCRIPT_DISABLED_ERRORMSG);
+            }
+
+            let handler = (event, metadata, scriptSandbox, eventResult) => {
+              const us = new UserScript({
+                context, metadata, scriptSandbox,
+              });
+
+              const apiObj = Cu.cloneInto(us.api(), context.cloneScope, {cloneFunctions: true});
+
+              Object.defineProperty(apiObj, "global", {
+                value: scriptSandbox,
+                enumerable: true,
+                configurable: true,
+                writable: true,
+              });
 
               fire.raw(apiObj);
             };
 
             context.userScriptsEvents.on("on-before-script", handler);
             return () => {
               context.userScriptsEvents.off("on-before-script", handler);
             };
--- a/toolkit/components/extensions/ext-toolkit.json
+++ b/toolkit/components/extensions/ext-toolkit.json
@@ -196,17 +196,16 @@
     "paths": [
       ["userScripts"]
     ]
   },
   "userScriptsContent": {
     "schema": "chrome://extensions/content/schemas/user_scripts_content.json",
     "scopes": ["content_child"],
     "paths": [
-      ["userScripts", "setScriptAPIs"],
       ["userScripts", "onBeforeScript"]
     ]
   },
   "webNavigation": {
     "url": "chrome://extensions/content/parent/ext-webNavigation.js",
     "schema": "chrome://extensions/content/schemas/web_navigation.json",
     "scopes": ["addon_parent"],
     "paths": [
--- a/toolkit/components/extensions/schemas/user_scripts_content.json
+++ b/toolkit/components/extensions/schemas/user_scripts_content.json
@@ -2,39 +2,16 @@
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 [
   {
     "namespace": "userScripts",
     "permissions": ["manifest:user_scripts"],
     "allowedContexts": ["content"],
-    "types": [
-      {
-        "id": "ExportedAPIMethods",
-        "type": "object",
-        "description": "A set of API methods provided by the extensions to its userScripts",
-        "additionalProperties": { "type": "function" }
-      }
-    ],
-    "functions": [
-      {
-        "name": "setScriptAPIs",
-        "permissions": ["manifest:user_scripts.api_script"],
-        "allowedContexts": ["content", "content_only"],
-        "type": "function",
-        "description": "Provides a set of custom API methods available to the registered userScripts",
-        "parameters": [
-          {
-            "name": "exportedAPIMethods",
-            "$ref": "ExportedAPIMethods"
-          }
-        ]
-      }
-    ],
     "events": [
       {
         "name": "onBeforeScript",
         "permissions": ["manifest:user_scripts.api_script"],
         "allowedContexts": ["content", "content_only"],
         "type": "function",
         "description": "Event called when a new userScript global has been created",
         "parameters": [
@@ -44,16 +21,41 @@
             "properties": {
               "metadata": {
                 "type": "any",
                 "description": "The userScript metadata (as set in userScripts.register)"
               },
               "global": {
                 "type": "any",
                 "description": "The userScript global"
+              },
+              "defineGlobals": {
+                "type": "function",
+                "description": "Exports all the properties of a given plain object as userScript globals",
+                "parameters": [
+                  {
+                    "type": "object",
+                    "name": "sourceObject",
+                    "description": "A plain object whose properties are exported as userScript globals"
+                  }
+                ]
+              },
+              "export": {
+                "type": "function",
+                "description": "Convert a given value to make it accessible to the userScript code",
+                "parameters": [
+                  {
+                    "type": "any",
+                    "name": "value",
+                    "description": "A value to convert into an object accessible to the userScript"
+                  }
+                ],
+                "returns": {
+                  "type": "any"
+                }
               }
             }
           }
         ]
       }
     ]
   }
 ]
--- a/toolkit/components/extensions/test/xpcshell/test_ext_userScripts.js
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_userScripts.js
@@ -334,37 +334,48 @@ async function test_userScript_APIMethod
 
     if (messageListener) {
       browser.runtime.onMessage.addListener(messageListener);
     }
 
     browser.test.sendMessage("background-ready");
   }
 
-  function notifyFinish([failureReason]) {
+  function notifyFinish(failureReason) {
     browser.test.assertEq(undefined, failureReason, "should be completed without errors");
     browser.test.sendMessage("test_userScript_APIMethod:done");
   }
 
+  function assertTrue(val, message) {
+    browser.test.assertTrue(val, message);
+    if (!val) {
+      browser.test.sendMessage("test_userScript_APIMethod:done");
+      throw message;
+    }
+  }
+
   let extension = ExtensionTestUtils.loadExtension({
     manifest: {
       permissions: [
         "http://localhost/*/file_sample.html",
       ],
       user_scripts: {
         api_script: "api-script.js",
       },
     },
     // Defines a background script that receives all the needed test parameters.
     background: `
         const metadata = ${JSON.stringify(userScriptMetadata)};
         (${backgroundScript})(${userScript}, metadata, ${runtimeMessageListener})
      `,
     files: {
-      "api-script.js": `(${apiScript})(${notifyFinish})`,
+      "api-script.js": `(${apiScript})({
+        assertTrue: ${assertTrue},
+        notifyFinish: ${notifyFinish}
+      })`,
     },
   });
 
   // Load a page in a content process, register the user script and then load a
   // new page in the existing content process.
   let url = `${BASE_URL}/file_sample.html`;
   let contentPage = await ExtensionTestUtils.loadContentPage(`about:blank`);
 
@@ -379,60 +390,65 @@ async function test_userScript_APIMethod
 
   await extension.awaitMessage("test_userScript_APIMethod:done");
 
   await extension.unload();
   await contentPage.close();
 }
 
 add_task(async function test_apiScript_exports_simple_sync_method() {
-  function apiScript(notifyFinish) {
-    browser.userScripts.setScriptAPIs({
-      notifyFinish,
-      testAPIMethod([param1, param2, arrayParam], scriptMetadata) {
-        browser.test.assertEq("test-user-script-exported-apis", scriptMetadata.name,
-                              "Got the expected value for a string scriptMetadata property");
-        browser.test.assertEq(null, scriptMetadata.nullProperty,
-                              "Got the expected value for a null scriptMetadata property");
-        browser.test.assertTrue(scriptMetadata.arrayProperty &&
-                                scriptMetadata.arrayProperty.length === 1 &&
-                                scriptMetadata.arrayProperty[0] === "el1",
-                                "Got the expected value for an array scriptMetadata property");
-        browser.test.assertTrue(scriptMetadata.objectProperty &&
-                                scriptMetadata.objectProperty.nestedProp === "nestedValue",
-                                "Got the expected value for an object scriptMetadata property");
+  function apiScript(sharedTestAPIMethods) {
+    browser.userScripts.onBeforeScript.addListener(script => {
+      const scriptMetadata = script.metadata;
 
-        browser.test.assertEq("param1", param1, "Got the expected parameter value");
-        browser.test.assertEq("param2", param2, "Got the expected parameter value");
+      script.defineGlobals({
+        ...sharedTestAPIMethods,
+        testAPIMethod(stringParam, numberParam, boolParam, nullParam, undefinedParam, arrayParam) {
+          browser.test.assertEq("test-user-script-exported-apis", scriptMetadata.name,
+                                "Got the expected value for a string scriptMetadata property");
+          browser.test.assertEq(null, scriptMetadata.nullProperty,
+                                "Got the expected value for a null scriptMetadata property");
+          browser.test.assertTrue(scriptMetadata.arrayProperty &&
+                                  scriptMetadata.arrayProperty.length === 1 &&
+                                  scriptMetadata.arrayProperty[0] === "el1",
+                                  "Got the expected value for an array scriptMetadata property");
+          browser.test.assertTrue(scriptMetadata.objectProperty &&
+                                  scriptMetadata.objectProperty.nestedProp === "nestedValue",
+                                  "Got the expected value for an object scriptMetadata property");
 
-        browser.test.assertEq(3, arrayParam.length, "Got the expected length on the array param");
-        browser.test.assertTrue(arrayParam.includes(1),
-                                "Got the expected result when calling arrayParam.includes");
+          browser.test.assertEq("param1", stringParam, "Got the expected string parameter value");
+          browser.test.assertEq(123, numberParam, "Got the expected number parameter value");
+          browser.test.assertEq(true, boolParam, "Got the expected boolean parameter value");
+          browser.test.assertEq(null, nullParam, "Got the expected null parameter value");
+          browser.test.assertEq(undefined, undefinedParam, "Got the expected undefined parameter value");
 
-        return "returned_value";
-      },
+          browser.test.assertEq(3, arrayParam.length, "Got the expected length on the array param");
+          browser.test.assertTrue(arrayParam.includes(1),
+                                  "Got the expected result when calling arrayParam.includes");
+
+          return "returned_value";
+        },
+      });
     });
   }
 
   function userScript() {
-    const {testAPIMethod, notifyFinish} = this;
+    const {assertTrue, notifyFinish, testAPIMethod} = this;
 
     // Redefine the includes method on the Array prototype, to explicitly verify that the method
     // redefined in the userScript is not used when accessing arrayParam.includes from the API script.
     Array.prototype.includes = () => { // eslint-disable-line no-extend-native
       throw new Error("Unexpected prototype leakage");
     };
     const arrayParam = new Array(1, 2, 3); // eslint-disable-line no-array-constructor
-    const result = testAPIMethod("param1", "param2", arrayParam);
+    const result = testAPIMethod("param1", 123, true, null, undefined, arrayParam);
 
-    if (result !== "returned_value") {
-      notifyFinish(`userScript got an unexpected result value: ${result}`);
-    } else {
-      notifyFinish();
-    }
+    assertTrue(result === "returned_value", `userScript got an unexpected result value: ${result}`);
+
+    notifyFinish();
   }
 
   const userScriptMetadata = {
     name: "test-user-script-exported-apis",
     arrayProperty: ["el1"],
     objectProperty: {nestedProp: "nestedValue"},
     nullProperty: null,
   };
@@ -440,59 +456,58 @@ add_task(async function test_apiScript_e
   await test_userScript_APIMethod({
     userScript,
     apiScript,
     userScriptMetadata,
   });
 });
 
 add_task(async function test_apiScript_async_method() {
-  function apiScript(notifyFinish) {
-    const {cloneInto} = this;
+  function apiScript(sharedTestAPIMethods) {
+    browser.userScripts.onBeforeScript.addListener(script => {
+      script.defineGlobals({
+        ...sharedTestAPIMethods,
+        testAPIMethod(param, cb, cb2, objWithCb) {
+          browser.test.assertEq("function", typeof cb, "Got a callback function parameter");
+          browser.test.assertTrue(cb === cb2, "Got the same cloned function for the same function parameter");
 
-    browser.userScripts.setScriptAPIs({
-      notifyFinish,
-      async testAPIMethod([param, cb, cb2], scriptMetadata, scriptGlobal) {
-        browser.test.assertEq("function", typeof cb, "Got a callback function parameter");
-        browser.test.assertTrue(cb === cb2, "Got the same cloned function for the same function parameter");
+          browser.runtime.sendMessage(param).then(bgPageRes => {
+            const cbResult = cb(script.export(bgPageRes));
+            browser.test.sendMessage("user-script-callback-return", cbResult);
+          });
 
-        browser.runtime.sendMessage(param).then(bgPageRes => {
-          const cbResult = cb(cloneInto(bgPageRes, scriptGlobal));
-          browser.test.sendMessage("user-script-callback-return", cbResult);
-        });
-
-        return "resolved_value";
-      },
+          return "resolved_value";
+        },
+      });
     });
   }
 
   async function userScript() {
     // Redefine Promise to verify that it doesn't break the WebExtensions internals
     // that are going to use them.
     const {Promise} = this;
     Promise.resolve = function() {
       throw new Error("Promise.resolve poisoning");
     };
     this.Promise = function() {
       throw new Error("Promise constructor poisoning");
     };
 
-    const {testAPIMethod, notifyFinish} = this;
+    const {assertTrue, notifyFinish, testAPIMethod} = this;
 
     const cb = (cbParam) => {
       return `callback param: ${JSON.stringify(cbParam)}`;
     };
     const cb2 = cb;
     const asyncAPIResult = await testAPIMethod("param3", cb, cb2);
 
-    if (asyncAPIResult !== "resolved_value") {
-      notifyFinish(`userScript got an unexpected resolved value: ${asyncAPIResult}`);
-    } else {
-      notifyFinish();
-    }
+    assertTrue(asyncAPIResult === "resolved_value",
+               `userScript got an unexpected resolved value: ${asyncAPIResult}`);
+
+    notifyFinish();
   }
 
   async function runtimeMessageListener(param) {
     if (param !== "param3") {
       browser.test.fail(`Got an unexpected message: ${param}`);
     }
 
     return {bgPageReply: true};
@@ -505,25 +520,672 @@ add_task(async function test_apiScript_a
     async testFn({extension}) {
       const res = await extension.awaitMessage("user-script-callback-return");
       equal(res, `callback param: ${JSON.stringify({bgPageReply: true})}`,
             "Got the expected userScript callback return value");
     },
   });
 });
 
+add_task(async function test_apiScript_method_with_webpage_objects_params() {
+  function apiScript(sharedTestAPIMethods) {
+    browser.userScripts.onBeforeScript.addListener(script => {
+      script.defineGlobals({
+        ...sharedTestAPIMethods,
+        testAPIMethod(windowParam, documentParam) {
+          browser.test.assertEq(window, windowParam, "Got a reference to the native window as first param");
+          browser.test.assertEq(window.document, documentParam,
+                                "Got a reference to the native document as second param");
+
+          // Return an uncloneable webpage object, which checks that if the returned object is from a principal
+          // that is subsumed by the userScript sandbox principal, it is returned without being cloned.
+          return windowParam;
+        },
+      });
+    });
+  }
+
+  async function userScript() {
+    const {assertTrue, notifyFinish, testAPIMethod} = this;
+
+    const result = testAPIMethod(window, document);
+
+    // We expect the returned value to be the uncloneable window object.
+    assertTrue(result === window,
+               `userScript got an unexpected returned value: ${result}`);
+    notifyFinish();
+  }
+
+  await test_userScript_APIMethod({
+    userScript,
+    apiScript,
+  });
+});
+
+add_task(async function test_apiScript_method_got_param_with_methods() {
+  function apiScript(sharedTestAPIMethods) {
+    browser.userScripts.onBeforeScript.addListener(script => {
+      const scriptGlobal = script.global;
+      const ScriptFunction = scriptGlobal.Function;
+
+      script.defineGlobals({
+        ...sharedTestAPIMethods,
+        testAPIMethod(objWithMethods) {
+          browser.test.assertEq("objPropertyValue", objWithMethods && objWithMethods.objProperty,
+                                "Got the expected property on the object passed as a parameter");
+          browser.test.assertEq(undefined, typeof objWithMethods && objWithMethods.objMethod,
+                                "XrayWrapper should deny access to a callable property");
+
+          browser.test.assertTrue(
+            objWithMethods && objWithMethods.wrappedJSObject &&
+            objWithMethods.wrappedJSObject.objMethod instanceof ScriptFunction.wrappedJSObject,
+            "The callable property is accessible on the wrappedJSObject");
+
+          browser.test.assertEq("objMethodResult: p1", objWithMethods && objWithMethods.wrappedJSObject &&
+                                objWithMethods.wrappedJSObject.objMethod("p1"),
+                                "Got the expected result when calling the method on the wrappedJSObject");
+          return true;
+        },
+      });
+    });
+  }
+
+  async function userScript() {
+    const {assertTrue, notifyFinish, testAPIMethod} = this;
+
+    let result = testAPIMethod({
+      objProperty: "objPropertyValue",
+      objMethod(param) {
+        return `objMethodResult: ${param}`;
+      },
+    });
+
+    assertTrue(result === true, `userScript got an unexpected returned value: ${result}`);
+    notifyFinish();
+  }
+
+  await test_userScript_APIMethod({
+    userScript,
+    apiScript,
+  });
+});
+
+add_task(async function test_apiScript_method_throws_errors() {
+  function apiScript({notifyFinish}) {
+    let proxyTrapsCount = 0;
+
+    browser.userScripts.onBeforeScript.addListener(script => {
+      const scriptGlobals = {
+        Error: script.global.Error,
+        TypeError: script.global.TypeError,
+        Proxy: script.global.Proxy,
+      };
+
+      script.defineGlobals({
+        notifyFinish,
+        testAPIMethod(errorTestName, returnRejectedPromise) {
+          let err;
+
+          switch (errorTestName) {
+            case "apiScriptError":
+              err = new Error(`${errorTestName} message`);
+              break;
+            case "apiScriptThrowsPlainString":
+              err = `${errorTestName} message`;
+              break;
+            case "apiScriptThrowsNull":
+              err = null;
+              break;
+            case "userScriptError":
+              err = new scriptGlobals.Error(`${errorTestName} message`);
+              break;
+            case "userScriptTypeError":
+              err = new scriptGlobals.TypeError(`${errorTestName} message`);
+              break;
+            case "userScriptProxyObject":
+              let proxyTarget = script.export({
+                name: "ProxyObject", message: "ProxyObject message",
+              });
+              let proxyHandlers = script.export({
+                get(target, prop) {
+                  proxyTrapsCount++;
+                  switch (prop) {
+                    case "name":
+                      return "ProxyObjectGetName";
+                    case "message":
+                      return "ProxyObjectGetMessage";
+                  }
+                  return undefined;
+                },
+                getPrototypeOf() {
+                  proxyTrapsCount++;
+                  return scriptGlobals.TypeError;
+                },
+              });
+              err = new scriptGlobals.Proxy(proxyTarget, proxyHandlers);
+              break;
+            default:
+              browser.test.fail(`Unknown ${errorTestName} error testname`);
+              return undefined;
+          }
+
+          if (returnRejectedPromise) {
+            return Promise.reject(err);
+          }
+
+          throw err;
+        },
+        assertNoProxyTrapTriggered() {
+          browser.test.assertEq(0, proxyTrapsCount, "Proxy traps should not be triggered");
+        },
+        resetProxyTrapCounter() {
+          proxyTrapsCount = 0;
+        },
+        sendResults(results) {
+          browser.test.sendMessage("test-results", results);
+        },
+      });
+    });
+  }
+
+  async function userScript() {
+    const {
+      assertNoProxyTrapTriggered,
+      notifyFinish,
+      resetProxyTrapCounter,
+      sendResults,
+      testAPIMethod,
+    } = this;
+
+    let apiThrowResults = {};
+    let apiThrowTestCases = [
+      "apiScriptError",
+      "apiScriptThrowsPlainString",
+      "apiScriptThrowsNull",
+      "userScriptError",
+      "userScriptTypeError",
+      "userScriptProxyObject",
+    ];
+    for (let errorTestName of apiThrowTestCases) {
+      try {
+        testAPIMethod(errorTestName);
+      } catch (err) {
+        // We expect that no proxy traps have been triggered by the WebExtensions internals.
+        if (errorTestName === "userScriptProxyObject") {
+          assertNoProxyTrapTriggered();
+        }
+
+        if (err instanceof Error) {
+          apiThrowResults[errorTestName] = {name: err.name, message: err.message};
+        } else {
+          apiThrowResults[errorTestName] = {
+            name: err && err.name,
+            message: err && err.message,
+            typeOf: typeof err,
+            value: err,
+          };
+        }
+      }
+    }
+
+    sendResults(apiThrowResults);
+
+    resetProxyTrapCounter();
+
+    let apiRejectsResults = {};
+    for (let errorTestName of apiThrowTestCases) {
+      try {
+        await testAPIMethod(errorTestName, true);
+      } catch (err) {
+        // We expect that no proxy traps have been triggered by the WebExtensions internals.
+        if (errorTestName === "userScriptProxyObject") {
+          assertNoProxyTrapTriggered();
+        }
+
+        if (err instanceof Error) {
+          apiRejectsResults[errorTestName] = {name: err.name, message: err.message};
+        } else {
+          apiRejectsResults[errorTestName] = {
+            name: err && err.name,
+            message: err && err.message,
+            typeOf: typeof err,
+            value: err,
+          };
+        }
+      }
+    }
+
+    sendResults(apiRejectsResults);
+
+    notifyFinish();
+  }
+
+  await test_userScript_APIMethod({
+    userScript,
+    apiScript,
+    async testFn({extension}) {
+      const expectedResults = {
+        // Any error not explicitly raised as a userScript objects or error instance is
+        // expected to be turned into a generic error message.
+        "apiScriptError": {name: "Error", message: "An unexpected apiScript error occurred"},
+
+        // When the api script throws a primitive value, we expect to receive it unmodified on
+        // the userScript side.
+        "apiScriptThrowsPlainString": {
+          typeOf: "string", value: "apiScriptThrowsPlainString message",
+          name: undefined, message: undefined,
+        },
+        "apiScriptThrowsNull": {
+          typeOf: "object", value: null,
+          name: undefined, message: undefined,
+        },
+
+        // Error messages that the apiScript has explicitly created as userScript's Error
+        // global instances are expected to be passing through unmodified.
+        "userScriptError": {name: "Error", message: "userScriptError message"},
+        "userScriptTypeError": {name: "TypeError", message: "userScriptTypeError message"},
+
+        // Error raised from the apiScript as userScript proxy objects are expected to
+        // be passing through unmodified.
+        "userScriptProxyObject": {
+          typeOf: "object",  name: "ProxyObjectGetName", message: "ProxyObjectGetMessage",
+        },
+      };
+
+      info("Checking results from errors raised from an apiScript exported function");
+
+      const apiThrowResults = await extension.awaitMessage("test-results");
+
+      for (let [key, expected] of Object.entries(expectedResults)) {
+        Assert.deepEqual(apiThrowResults[key], expected,
+                         `Got the expected error object for test case "${key}"`);
+      }
+
+      Assert.deepEqual(Object.keys(expectedResults).sort(),
+                       Object.keys(apiThrowResults).sort(),
+                       "the expected and actual test case names matches");
+
+      info("Checking expected results from errors raised from an apiScript exported function");
+
+      // Verify expected results from rejected promises returned from an apiScript exported function.
+      const apiThrowRejections = await extension.awaitMessage("test-results");
+
+      for (let [key, expected] of Object.entries(expectedResults)) {
+        Assert.deepEqual(apiThrowRejections[key], expected,
+                         `Got the expected rejected object for test case "${key}"`);
+      }
+
+      Assert.deepEqual(Object.keys(expectedResults).sort(),
+                       Object.keys(apiThrowRejections).sort(),
+                       "the expected and actual test case names matches");
+    },
+  });
+});
+
+add_task(async function test_apiScript_method_ensure_xraywrapped_proxy_in_params() {
+  function apiScript(sharedTestAPIMethods) {
+    browser.userScripts.onBeforeScript.addListener(script => {
+      script.defineGlobals({
+        ...sharedTestAPIMethods,
+        testAPIMethod(...args) {
+          // Proxies are opaque when wrapped in Xrays, and the proto of an opaque object
+          // is supposed to be Object.prototype.
+          browser.test.assertEq(
+            script.global.Object.prototype,
+            Object.getPrototypeOf(args[0]),
+            "Calling getPrototypeOf on the XrayWrapped proxy object doesn't run the proxy trap");
+
+          browser.test.assertTrue(Array.isArray(args[0]),
+                                  "Got an array object for the XrayWrapped proxy object param");
+          browser.test.assertEq(undefined, args[0].length,
+                                "XrayWrappers deny access to the length property");
+          browser.test.assertEq(undefined, args[0][0],
+                                "Got the expected item in the array object");
+          return true;
+        },
+      });
+    });
+  }
+
+  async function userScript() {
+    const {
+      assertTrue,
+      notifyFinish,
+      testAPIMethod,
+    } = this;
+
+    let proxy = new Proxy(["expectedArrayValue"], {
+      getPrototypeOf() {
+        throw new Error("Proxy's getPrototypeOf trap");
+      },
+      get(target, prop, receiver) {
+        throw new Error("Proxy's get trap");
+      },
+    });
+
+    let result = testAPIMethod(proxy);
+
+    assertTrue(result, `userScript got an unexpected returned value: ${result}`);
+    notifyFinish();
+  }
+
+  await test_userScript_APIMethod({
+    userScript,
+    apiScript,
+  });
+});
+
+add_task(async function test_apiScript_method_return_proxy_object() {
+  function apiScript(sharedTestAPIMethods) {
+    const {cloneInto} = this;
+    let proxyTrapsCount = 0;
+    let scriptTrapsCount = 0;
+
+    browser.userScripts.onBeforeScript.addListener(script => {
+      script.defineGlobals({
+        ...sharedTestAPIMethods,
+        testAPIMethodError() {
+          return new Proxy(["expectedArrayValue"], {
+            getPrototypeOf(target) {
+              proxyTrapsCount++;
+              return Object.getPrototypeOf(target);
+            },
+          });
+        },
+        testAPIMethodOk() {
+          return new script.global.Proxy(
+            cloneInto(["expectedArrayValue"], script.global),
+            cloneInto({
+              getPrototypeOf(target) {
+                scriptTrapsCount++;
+                return script.global.Object.getPrototypeOf(target);
+              },
+            }, script.global, {cloneFunctions: true}));
+        },
+        assertNoProxyTrapTriggered() {
+          browser.test.assertEq(0, proxyTrapsCount, "Proxy traps should not be triggered");
+        },
+        assertScriptProxyTrapsCount(expected) {
+          browser.test.assertEq(expected, scriptTrapsCount, "Script Proxy traps should have been triggered");
+        },
+      });
+    });
+  }
+
+  async function userScript() {
+    const {
+      assertTrue,
+      assertNoProxyTrapTriggered,
+      assertScriptProxyTrapsCount,
+      notifyFinish,
+      testAPIMethodError,
+      testAPIMethodOk,
+    } = this;
+
+    let error;
+    try {
+      let result = testAPIMethodError();
+      notifyFinish(`Unexpected returned value while expecting error: ${result}`);
+      return;
+    } catch (err) {
+      error = err;
+    }
+
+    assertTrue(error && error.message.includes("Return value not accessible to the userScript"),
+               `Got an unexpected error message: ${error}`);
+
+    error = undefined;
+    try {
+      let result = testAPIMethodOk();
+      assertScriptProxyTrapsCount(0);
+      if (!(result instanceof Array)) {
+        notifyFinish(`Got an unexpected result: ${result}`);
+        return;
+      }
+      assertScriptProxyTrapsCount(1);
+    } catch (err) {
+      error = err;
+    }
+
+    assertTrue(!error, `Got an unexpected error: ${error}`);
+
+    assertNoProxyTrapTriggered();
+
+    notifyFinish();
+  }
+
+  await test_userScript_APIMethod({
+    userScript,
+    apiScript,
+  });
+});
+
+add_task(async function test_apiScript_returns_functions() {
+  function apiScript(sharedTestAPIMethods) {
+    browser.userScripts.onBeforeScript.addListener(script => {
+      script.defineGlobals({
+        ...sharedTestAPIMethods,
+        testAPIReturnsFunction() {
+          // Return a function with provides the same kind of behavior
+          // of the API methods exported as globals.
+          return script.export(() => window);
+        },
+        testAPIReturnsObjWithMethod() {
+          return script.export({
+            getWindow() {
+              return window;
+            },
+          });
+        },
+      });
+    });
+  }
+
+  async function userScript() {
+    const {
+      assertTrue,
+      notifyFinish,
+      testAPIReturnsFunction,
+      testAPIReturnsObjWithMethod,
+    } = this;
+
+    let resultFn = testAPIReturnsFunction();
+    assertTrue(typeof resultFn === "function",
+               `userScript got an unexpected returned value: ${typeof resultFn}`);
+
+    let fnRes = resultFn();
+    assertTrue(fnRes === window,
+               `Got an unexpected value from the returned function: ${fnRes}`);
+
+    let resultObj = testAPIReturnsObjWithMethod();
+    let actualTypeof = resultObj && typeof resultObj.getWindow;
+    assertTrue(actualTypeof === "function",
+               `Returned object does not have the expected getWindow method: ${actualTypeof}`);
+
+    let methodRes = resultObj.getWindow();
+    assertTrue(methodRes === window,
+               `Got an unexpected value from the returned method: ${methodRes}`);
+
+    notifyFinish();
+  }
+
+  await test_userScript_APIMethod({
+    userScript,
+    apiScript,
+  });
+});
+
+add_task(async function test_apiScript_method_clone_non_subsumed_returned_values() {
+  function apiScript(sharedTestAPIMethods) {
+    browser.userScripts.onBeforeScript.addListener(script => {
+      script.defineGlobals({
+        ...sharedTestAPIMethods,
+        testAPIMethodReturnOk() {
+          return script.export({
+            objKey1: {
+              nestedProp: "nestedvalue",
+            },
+            window,
+          });
+        },
+        testAPIMethodExplicitlyClonedError() {
+          let result = script.export({apiScopeObject: undefined});
+
+          browser.test.assertThrows(
+            () => {
+              result.apiScopeObject = {disallowedProp: "disallowedValue"};
+            },
+            /Not allowed to define cross-origin object as property on .* XrayWrapper/,
+            "Assigning a property to a xRayWrapper is expected to throw");
+
+          // Let the exception to be raised, so that we check that the actual underlying
+          // error message is not leaking in the userScript (replaced by the generic
+          // "An unexpected apiScript error occurred" error message).
+          result.apiScopeObject = {disallowedProp: "disallowedValue"};
+        },
+      });
+    });
+  }
+
+  async function userScript() {
+    const {
+      assertTrue,
+      notifyFinish,
+      testAPIMethodReturnOk,
+      testAPIMethodExplicitlyClonedError,
+    } = this;
+
+    let result = testAPIMethodReturnOk();
+
+    assertTrue(result && ("objKey1" in result) && result.objKey1.nestedProp === "nestedvalue",
+               `userScript got an unexpected returned value: ${result}`);
+
+    assertTrue(result.window === window,
+               `userScript should have access to the window property: ${result.window}`);
+
+    let error;
+    try {
+      result = testAPIMethodExplicitlyClonedError();
+      notifyFinish(`Unexpected returned value while expecting error: ${result}`);
+      return;
+    } catch (err) {
+      error = err;
+    }
+
+    // We expect the generic "unexpected apiScript error occurred" to be raised to the
+    // userScript code.
+    assertTrue(error && error.message.includes("An unexpected apiScript error occurred"),
+               `Got an unexpected error message: ${error}`);
+
+    notifyFinish();
+  }
+
+  await test_userScript_APIMethod({
+    userScript,
+    apiScript,
+  });
+});
+
+add_task(async function test_apiScript_method_export_primitive_types() {
+  function apiScript(sharedTestAPIMethods) {
+    browser.userScripts.onBeforeScript.addListener(script => {
+      script.defineGlobals({
+        ...sharedTestAPIMethods,
+        testAPIMethod(typeToExport) {
+          switch (typeToExport) {
+            case "boolean": return script.export(true);
+            case "number": return script.export(123);
+            case "string": return script.export("a string");
+            case "symbol": return script.export(Symbol("a symbol"));
+          }
+          return undefined;
+        },
+      });
+    });
+  }
+
+  async function userScript() {
+    const {assertTrue, notifyFinish, testAPIMethod} = this;
+
+    let v = testAPIMethod("boolean");
+    assertTrue(v === true, `Should export a boolean`);
+
+    v = testAPIMethod("number");
+    assertTrue(v === 123, `Should export a number`);
+
+    v = testAPIMethod("string");
+    assertTrue(v === "a string", `Should export a string`);
+
+    v = testAPIMethod("symbol");
+    assertTrue(typeof v === "symbol", `Should export a symbol`);
+
+    notifyFinish();
+  }
+
+  await test_userScript_APIMethod({
+    userScript,
+    apiScript,
+  });
+});
+
+add_task(async function test_apiScript_method_avoid_unnecessary_params_cloning() {
+  function apiScript(sharedTestAPIMethods) {
+    browser.userScripts.onBeforeScript.addListener(script => {
+      script.defineGlobals({
+        ...sharedTestAPIMethods,
+        testAPIMethodReturnsParam(param) {
+          return param;
+        },
+        testAPIMethodReturnsUnwrappedParam(param) {
+          return param.wrappedJSObject;
+        },
+      });
+    });
+  }
+
+  async function userScript() {
+    const {
+      assertTrue,
+      notifyFinish,
+      testAPIMethodReturnsParam,
+      testAPIMethodReturnsUnwrappedParam,
+    } = this;
+
+    let obj = {};
+
+    let result = testAPIMethodReturnsParam(obj);
+
+    assertTrue(result === obj,
+               `Expect returned value to be strictly equal to the API method parameter`);
+
+    result = testAPIMethodReturnsUnwrappedParam(obj);
+
+    assertTrue(result === obj,
+               `Expect returned value to be strictly equal to the unwrapped API method parameter`);
+
+    notifyFinish();
+  }
+
+  await test_userScript_APIMethod({
+    userScript,
+    apiScript,
+  });
+});
+
 // This test verify that a cached script is still able to catch the document
 // while it is still loading (when we do not block the document parsing as
 // we do for a non cached script).
 add_task(async function test_cached_userScript_on_document_start() {
   function apiScript() {
-    browser.userScripts.setScriptAPIs({
-      sendTestMessage([name, params]) {
-        return browser.test.sendMessage(name, params);
-      },
+    browser.userScripts.onBeforeScript.addListener(script => {
+      script.defineGlobals({
+        sendTestMessage(name, params) {
+          return browser.test.sendMessage(name, params);
+        },
+      });
     });
   }
 
   async function background() {
     function userScript() {
       this.sendTestMessage("user-script-loaded", {
         url: window.location.href,
         documentReadyState: document.readyState,
@@ -605,24 +1267,22 @@ add_task(async function test_userScripts
         /userScripts APIs are currently experimental/,
         "Got the expected error from userScripts.register when the userScripts API is disabled");
 
       browser.test.sendMessage("background-page:done");
     }
 
     async function contentScript() {
       let promise = (async () => {
-        browser.userScripts.setScriptAPIs({
-          GM_apiMethod() {},
-        });
+        browser.userScripts.onBeforeScript.addListener(() => {});
       })();
       await browser.test.assertRejects(
         promise,
         /userScripts APIs are currently experimental/,
-        "Got the expected error from userScripts.setScriptAPIs when the userScripts API is disabled");
+        "Got the expected error from userScripts.onBeforeScript when the userScripts API is disabled");
 
       browser.test.sendMessage("content-script:done");
     }
 
     let extension = ExtensionTestUtils.loadExtension({
       background,
       manifest: {
         permissions: ["http://*/*/file_sample.html"],
@@ -652,45 +1312,45 @@ add_task(async function test_userScripts
     await extension.unload();
     await contentPage.close();
   }
 
   await runWithPrefs([["extensions.webextensions.userScripts.enabled", false]],
                      run_userScript_on_pref_disabled_test);
 });
 
-// This test verify that userScripts.setScriptAPIs is not available without
+// This test verify that userScripts.onBeforeScript API Event is not available without
 // a "user_scripts.api_script" property in the manifest.
 add_task(async function test_user_script_api_script_required() {
   let extension = ExtensionTestUtils.loadExtension({
     manifest: {
       content_scripts: [
         {
           matches: ["http://localhost/*/file_sample.html"],
           js: ["content_script.js"],
           run_at: "document_start",
         },
       ],
       user_scripts: {},
     },
     files: {
       "content_script.js": function() {
-        browser.test.assertEq(undefined, browser.userScripts && browser.userScripts.setScriptAPIs,
-                              "Got an undefined setScriptAPIs as expected");
-        browser.test.sendMessage("no-setScriptAPIs:done");
+        browser.test.assertEq(undefined, browser.userScripts && browser.userScripts.onBeforeScript,
+                              "Got an undefined onBeforeScript property as expected");
+        browser.test.sendMessage("no-onBeforeScript:done");
       },
     },
   });
 
   await extension.startup();
 
   let url = `${BASE_URL}/file_sample.html`;
   let contentPage = await ExtensionTestUtils.loadContentPage(url);
 
-  await extension.awaitMessage("no-setScriptAPIs:done");
+  await extension.awaitMessage("no-onBeforeScript:done");
 
   await extension.unload();
   await contentPage.close();
 });
 
 add_task(async function test_scriptMetaData() {
   function getTestCases(isUserScriptsRegister) {
     return [
@@ -730,55 +1390,50 @@ add_task(async function test_scriptMetaD
     f.src = pageUrl;
     document.body.append(f);
     browser.test.sendMessage("background-page:done");
   }
 
   function apiScript() {
     let testCases = getTestCases(false);
     let i = 0;
-    let j = 0;
-    let metadataOnFirstCall = [];
-    browser.userScripts.setScriptAPIs({
-      checkMetadata(params, metadata, scriptGlobal) {
-        // We save the reference to the received metadata object, so that
-        // checkMetadataAgain can verify that the same object is received.
-        metadataOnFirstCall[i] = metadata;
 
-        let expectation = testCases[i];
-        if (typeof expectation === "object" && expectation !== null) {
-          // Non-primitive values cannot be compared with assertEq,
-          // so serialize both and just verify that they are equal.
-          expectation = JSON.stringify(expectation);
-          metadata = JSON.stringify(metadata);
-        }
-        browser.test.assertEq(expectation, metadata, `Expected metadata at call ${i}`);
-        ++i;
-      },
-      checkMetadataAgain(params, metadata, scriptGlobal) {
-        browser.test.assertEq(metadataOnFirstCall[j], metadata, `Expected same metadata at call ${j}`);
+    browser.userScripts.onBeforeScript.addListener(script => {
+      script.defineGlobals({
+        checkMetadata() {
+          let expectation = testCases[i];
+          let metadata = script.metadata;
+          if (typeof expectation === "object" && expectation !== null) {
+            // Non-primitive values cannot be compared with assertEq,
+            // so serialize both and just verify that they are equal.
+            expectation = JSON.stringify(expectation);
+            metadata = JSON.stringify(script.metadata);
+          }
 
-        if (++j === testCases.length) {
-          browser.test.sendMessage("apiscript:done");
-        }
-      },
+          browser.test.assertEq(expectation, metadata,
+                                `Expected metadata at call ${i}`);
+          if (++i === testCases.length) {
+            browser.test.sendMessage("apiscript:done");
+          }
+        },
+      });
     });
   }
 
   let extension = ExtensionTestUtils.loadExtension({
     background: `${getTestCases};(${background})("${BASE_URL}/file_sample.html")`,
     manifest: {
       permissions: ["http://*/*/file_sample.html"],
       user_scripts: {
         api_script: "apiscript.js",
       },
     },
     files: {
       "apiscript.js": `${getTestCases};(${apiScript})()`,
-      "userscript.js": "checkMetadata();checkMetadataAgain();",
+      "userscript.js": "checkMetadata();",
     },
   });
 
   await extension.startup();
 
   await extension.awaitMessage("background-page:done");
   await extension.awaitMessage("apiscript:done");
 
--- a/toolkit/components/extensions/test/xpcshell/test_ext_userScripts_telemetry.js
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_userScripts_telemetry.js
@@ -7,20 +7,24 @@ const HISTOGRAM_KEYED = "WEBEXT_USER_SCR
 
 const server = createHttpServer();
 server.registerDirectory("/data/", do_get_file("data"));
 
 const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`;
 
 async function run_userScripts_telemetry_test() {
   function apiScript() {
-    browser.userScripts.setScriptAPIs({
-      US_test_sendMessage([msg, data], scriptMetadata, scriptGlobal) {
-        browser.test.sendMessage(msg, {data, scriptMetadata});
-      },
+    browser.userScripts.onBeforeScript.addListener(userScript => {
+      const scriptMetadata = userScript.metadata;
+
+      userScript.defineGlobals({
+        US_test_sendMessage(msg, data) {
+          browser.test.sendMessage(msg, {data, scriptMetadata});
+        },
+      });
     });
   }
 
   async function background() {
     const code = `
       US_test_sendMessage("userScript-run", {location: window.location.href});
     `;
     await browser.userScripts.register({